Security header setup for the Website service (#8)

This PR contains the work done to add a `SecurityHeadersMiddleware` middleware that stamps hardened security-related HTTP headers onto every response.

To provide further details about the work:

* Implemented the `SecurityHeadersMiddleware` middleware, which precomputes headers once from a `Configuration` object and applies them to every response:
  * _Content-Security-Policy_,
  * _X-Content-Type-Options_,
  * _X-Frame-Options_,
  * _Referrer-Policy_,
  * _Permissions-Policy_,
  * _Strict-Transport-Security_ (optional).
* Integrated this middleware into the router (near the top of the chain), reading each value from configuration with hardened defaults.
* The _Strict-Transport-Security_ has no default value — omitted unless explicitly set, so it stays off in plain-HTTP during development and on only behind TLS.
* Added security-header constants keys and values.

Reviewed-on: rock-n-code/loud-amsterdam#8
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
2026-06-28 11:35:54 +00:00
committed by javier
parent 7c18cd9ec0
commit 6b6389cb0f
9 changed files with 497 additions and 31 deletions
+55 -3
View File
@@ -6,8 +6,8 @@ import WebsiteCore
/// Builds the website application.
///
/// Reads the log level, server name, static files location, and minimum response size to
/// compress from the configuration, then assembles the router, server configuration, and logger.
/// Reads the log level, server name, static files location, minimum response size to compress, and
/// security headers from the configuration, then assembles the router, server configuration, and logger.
/// - Parameter reader: the configuration reader the values are read from.
/// - Returns: the configured application, ready to run as a service.
func application(
@@ -44,12 +44,16 @@ func application(
forKey: .Path.staticFiles,
default: .Path.staticResources
)
let securityHeaders = securityHeaders(
reader: reader
)
return Application(
router: router(
staticFilesPath: staticFilesPath,
cacheControl: cacheControl,
compressionMinResponseSize: compressionMinResponseSize,
securityHeaders: securityHeaders,
logLevel: logLevel
),
configuration: ApplicationConfiguration(
@@ -90,6 +94,44 @@ private func cacheControl(
])
}
/// Builds the security-headers configuration applied to every response.
///
/// Each header value falls back to the hardened default in `String.Security` when the matching
/// configuration key is unset. `Strict-Transport-Security` has no default: it is read as an optional
/// and omitted entirely unless explicitly configured, so it stays off in plain-HTTP development and
/// is enabled only behind TLS in production.
/// - Parameter reader: the configuration reader the header values are read from.
/// - Returns: the configured security-headers configuration.
private func securityHeaders(
reader: ConfigReader
) -> SecurityHeadersMiddleware<AppRequestContext>.Configuration {
.init(
contentSecurityPolicy: reader.string(
forKey: .Security.contentSecurityPolicy,
default: .Security.contentSecurityPolicy
),
contentTypeOptions: reader.string(
forKey: .Security.contentTypeOptions,
default: .Security.contentTypeOptions
),
frameOptions: reader.string(
forKey: .Security.frameOptions,
default: .Security.frameOptions
),
referrerPolicy: reader.string(
forKey: .Security.referrerPolicy,
default: .Security.referrerPolicy
),
permissionsPolicy: reader.string(
forKey: .Security.permissionsPolicy,
default: .Security.permissionsPolicy
),
strictTransportSecurity: reader.string(
forKey: .Security.strictTransportSecurity
)
)
}
/// Builds the application's logger.
/// - Parameters:
/// - serverName: the label applied to the logger.
@@ -108,27 +150,37 @@ private func logger(
/// Builds the application's router.
///
/// Registers the request-logging middleware, the response-compression middleware that compresses
/// Registers the request-logging middleware, the security-headers middleware that stamps the given
/// `securityHeaders` onto every response, the response-compression middleware that compresses
/// responses larger than `minimumResponseSizeToCompress` when the client advertises support, the
/// not-found middleware that serves the error page, and the static file middleware that serves the
/// contents of `staticFilesPath` (tagging responses with the given `cacheControl` directives), then
/// adds the `RootController` routes that render the landing page.
///
/// The security-headers middleware sits just inside request logging so it covers every response that
/// reaches a client the landing page, the compressed responses, the rendered error page, and the
/// served static files.
/// - Parameters:
/// - staticFilesPath: the folder, relative to the working directory, the static files are served from.
/// - cacheControl: the cache-control directives applied to the served static files.
/// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied.
/// - securityHeaders: the security headers applied to every response.
/// - logLevel: the level the request-logging middleware logs at.
/// - Returns: the configured router.
private func router(
staticFilesPath: String,
cacheControl: CacheControl,
compressionMinResponseSize: Int,
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
logLevel: Logger.Level
) -> Router<AppRequestContext> {
let router = Router(context: AppRequestContext.self)
router.addMiddleware {
LogRequestsMiddleware(logLevel)
SecurityHeadersMiddleware(
configuration: securityHeaders
)
ResponseCompressionMiddleware(
minimumResponseSizeToCompress: compressionMinResponseSize
)
@@ -0,0 +1,10 @@
import HTTPTypes
extension HTTPField.Name {
/// The `Permissions-Policy` field name (not provided as a standard `HTTPField.Name`).
static let permissionsPolicy = Self("Permissions-Policy")!
/// The `Referrer-Policy` field name (not provided as a standard `HTTPField.Name`).
static let referrerPolicy = Self("Referrer-Policy")!
/// The `X-Frame-Options` field name (not provided as a standard `HTTPField.Name`).
static let frameOptions = Self("X-Frame-Options")!
}
@@ -34,4 +34,19 @@ extension AbsoluteConfigKey {
/// The absolute configuration key for the directory the static files are served from.
public static let staticFiles: AbsoluteConfigKey = .init(.Path.staticFiles)
}
/// A namespace for the security headers configuration keys, as absolute keys.
public enum Security {
/// The absolute configuration key for the `Content-Security-Policy` header value.
public static let contentSecurityPolicy: AbsoluteConfigKey = .init(.Security.contentSecurityPolicy)
/// The absolute configuration key for the `X-Content-Type-Options` header value.
public static let contentTypeOptions: AbsoluteConfigKey = .init(.Security.contentTypeOptions)
/// The absolute configuration key for the `X-Frame-Options` header value.
public static let frameOptions: AbsoluteConfigKey = .init(.Security.frameOptions)
/// The absolute configuration key for the `Referrer-Policy` header value.
public static let referrerPolicy: AbsoluteConfigKey = .init(.Security.referrerPolicy)
/// The absolute configuration key for the `Permissions-Policy` header value.
public static let permissionsPolicy: AbsoluteConfigKey = .init(.Security.permissionsPolicy)
/// The absolute configuration key for the `Strict-Transport-Security` header value.
public static let strictTransportSecurity: AbsoluteConfigKey = .init(.Security.strictTransportSecurity)
}
}
@@ -34,4 +34,19 @@ extension ConfigKey {
/// The configuration key for the directory the static files are served from.
public static let staticFiles: ConfigKey = "path.staticFiles"
}
/// A namespace for the security headers configuration keys.
public enum Security {
/// The configuration key for the `Content-Security-Policy` header value.
public static let contentSecurityPolicy: ConfigKey = "security.contentSecurityPolicy"
/// The configuration key for the `X-Content-Type-Options` header value.
public static let contentTypeOptions: ConfigKey = "security.contentTypeOptions"
/// The configuration key for the `X-Frame-Options` header value.
public static let frameOptions: ConfigKey = "security.frameOptions"
/// The configuration key for the `Referrer-Policy` header value.
public static let referrerPolicy: ConfigKey = "security.referrerPolicy"
/// The configuration key for the `Permissions-Policy` header value.
public static let permissionsPolicy: ConfigKey = "security.permissionsPolicy"
/// The configuration key for the `Strict-Transport-Security` header value (omitted when unset).
public static let strictTransportSecurity: ConfigKey = "security.strictTransportSecurity"
}
}
@@ -4,6 +4,26 @@ extension String {
/// The directory, relative to the working directory, that the website's static files are served from.
public static let staticResources = "Resources/Static"
}
/// A namespace for the security headers' default configuration values.
///
/// `Strict-Transport-Security` is intentionally absent: it is only safe over HTTPS and is
/// "sticky" in browsers, so it stays off unless explicitly configured in production.
public enum Security {
/// The default `Content-Security-Policy`.
///
/// Restricts every resource to the site's own origin. `style-src` additionally allows
/// `'unsafe-inline'` because ``ErrorPage`` ships an inline `<style>` block; remove it once
/// the error page's styles move to an external stylesheet.
public static let contentSecurityPolicy = "default-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
/// The default `X-Content-Type-Options` (disables MIME sniffing).
public static let contentTypeOptions = "nosniff"
/// The default `X-Frame-Options` (forbids framing the page).
public static let frameOptions = "DENY"
/// The default `Referrer-Policy`.
public static let referrerPolicy = "strict-origin-when-cross-origin"
/// The default `Permissions-Policy` (denies access to powerful browser features the site does not use).
public static let permissionsPolicy = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
}
/// A namespace for the server string constants.
public enum Server {
/// The website server's name.
@@ -0,0 +1,146 @@
import HTTPTypes
import Hummingbird
/// Stamps a set of security-related HTTP headers onto every response.
///
/// Placed at (or near) the top of the middleware chain, it adds the configured headers to whatever
/// response bubbles back up the rendered landing page, the ``ErrorPage`` produced by
/// ``NotFoundMiddleware``, and every static file served by `FileMiddleware` so the browser applies
/// the strict, hardened interpretation of the content instead of its lenient legacy defaults.
///
/// The headers are precomputed once from the ``Configuration`` at initialization and reused for
/// every request, so the per-request cost is a handful of header copies.
public struct SecurityHeadersMiddleware<Context: RequestContext> {
// MARK: Properties
/// The precomputed headers applied to every response.
private let fields: HTTPFields
// MARK: Initializers
/// Creates a security-headers middleware.
/// - Parameter configuration: the headers applied to every response. Defaults to a hardened
/// baseline suitable for a static site, with `Strict-Transport-Security` left off (see
/// ``Configuration``).
public init(
configuration: Configuration = .init()
) {
self.fields = configuration.fields
}
}
// MARK: - RouterMiddleware
extension SecurityHeadersMiddleware: RouterMiddleware {
// MARK: Functions
/// Passes the request down the chain and stamps the configured security headers onto the
/// response on the way back up.
///
/// Existing values for the same header names are replaced so downstream middleware cannot leave
/// a weaker policy in place.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - next: the next responder in the middleware chain.
/// - Returns: the downstream response with the security headers applied.
/// - Throws: any error thrown downstream.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
var response = try await next(request, context)
for field in fields {
response.headers[field.name] = field.value
}
return response
}
}
// MARK: - Helpers
private extension SecurityHeadersMiddleware.Configuration {
// MARK: Computed
/// The configuration expressed as the headers to apply, omitting any whose value is `nil`.
var fields: HTTPFields {
var fields = HTTPFields()
fields[.contentSecurityPolicy] = contentSecurityPolicy
fields[.xContentTypeOptions] = contentTypeOptions
fields[.frameOptions] = frameOptions
fields[.referrerPolicy] = referrerPolicy
fields[.permissionsPolicy] = permissionsPolicy
fields[.strictTransportSecurity] = strictTransportSecurity
return fields
}
}
// MARK: - Configuration
extension SecurityHeadersMiddleware {
/// The set of security headers a ``SecurityHeadersMiddleware`` applies.
///
/// Each property maps to a single response header. A `nil` value omits that header entirely,
/// which is how `Strict-Transport-Security` stays disabled by default: it is only safe to send
/// over HTTPS and is "sticky" in browsers, so it must stay off in plain-HTTP development and be
/// switched on (via configuration) only in TLS-terminated production.
public struct Configuration: Sendable {
// MARK: Properties
/// The `Content-Security-Policy` value (controls which sources the browser will load).
public let contentSecurityPolicy: String?
/// The `X-Content-Type-Options` value (disables MIME sniffing when set to `nosniff`).
public let contentTypeOptions: String?
/// The `X-Frame-Options` value (controls whether the page may be framed).
public let frameOptions: String?
/// The `Referrer-Policy` value (controls how much referrer information is shared).
public let referrerPolicy: String?
/// The `Permissions-Policy` value (gates access to powerful browser features).
public let permissionsPolicy: String?
/// The `Strict-Transport-Security` value, or `nil` to omit the header (the default).
public let strictTransportSecurity: String?
// MARK: Initializers
/// Creates a security-headers configuration.
///
/// Every parameter defaults to the hardened baseline defined in `String.Security`, except
/// `strictTransportSecurity`, which defaults to `nil` (omitted). Pass `nil` for any header
/// to drop it from the response.
/// - Parameters:
/// - contentSecurityPolicy: the `Content-Security-Policy` value.
/// - contentTypeOptions: the `X-Content-Type-Options` value.
/// - frameOptions: the `X-Frame-Options` value.
/// - referrerPolicy: the `Referrer-Policy` value.
/// - permissionsPolicy: the `Permissions-Policy` value.
/// - strictTransportSecurity: the `Strict-Transport-Security` value, or `nil` to omit it.
public init(
contentSecurityPolicy: String? = String.Security.contentSecurityPolicy,
contentTypeOptions: String? = String.Security.contentTypeOptions,
frameOptions: String? = String.Security.frameOptions,
referrerPolicy: String? = String.Security.referrerPolicy,
permissionsPolicy: String? = String.Security.permissionsPolicy,
strictTransportSecurity: String? = nil
) {
self.contentSecurityPolicy = contentSecurityPolicy
self.contentTypeOptions = contentTypeOptions
self.frameOptions = frameOptions
self.referrerPolicy = referrerPolicy
self.permissionsPolicy = permissionsPolicy
self.strictTransportSecurity = strictTransportSecurity
}
}
}
+101 -28
View File
@@ -33,7 +33,9 @@ struct AppTests {
@Test
func `landing page to be served at root`() async throws {
try await app.test(.router) { client in
try await app(
staticFilesPath: staticFilesPath
).test(.router) { client in
try await client.execute(
uri: "/",
method: .get
@@ -51,7 +53,9 @@ struct AppTests {
func `static files to be served`(
staticFile file: StaticFile
) async throws {
try await app.test(.router) { client in
try await app(
staticFilesPath: staticFilesPath
).test(.router) { client in
try await client.execute(
uri: "/\(file.relativePath)",
method: .get
@@ -73,7 +77,9 @@ struct AppTests {
@Test
func `response to be compressed when the client supports it`() async throws {
try await app.test(.router) { client in
try await app(
staticFilesPath: staticFilesPath
).test(.router) { client in
try await client.execute(
uri: "/",
method: .get,
@@ -87,7 +93,9 @@ struct AppTests {
@Test
func `response to not be compressed when the client does not support it`() async throws {
try await app.test(.router) { client in
try await app(
staticFilesPath: staticFilesPath
).test(.router) { client in
try await client.execute(
uri: "/",
method: .get
@@ -100,7 +108,9 @@ struct AppTests {
@Test
func `error page to be served when not found`() async throws {
try await app.test(.router) { client in
try await app(
staticFilesPath: staticFilesPath
).test(.router) { client in
try await client.execute(
uri: "/this-path-does-not-exist",
method: .get
@@ -114,37 +124,100 @@ struct AppTests {
}
}
@Test
func `security headers to be applied to the landing page`() async throws {
try await app(
staticFilesPath: staticFilesPath
).test(.router) { client in
try await client.execute(
uri: "/",
method: .get
) { response in
#expect(response.status == .ok)
#expect(response.headers[.contentSecurityPolicy] == String.Security.contentSecurityPolicy)
#expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions)
#expect(response.headers[.frameOptions] == String.Security.frameOptions)
#expect(response.headers[.referrerPolicy] == String.Security.referrerPolicy)
#expect(response.headers[.permissionsPolicy] == String.Security.permissionsPolicy)
#expect(response.headers[.strictTransportSecurity] == nil)
}
}
}
@Test
func `security headers to be applied to the error page`() async throws {
try await app(
staticFilesPath: staticFilesPath
).test(.router) { client in
try await client.execute(
uri: "/this-path-does-not-exist",
method: .get
) { response in
#expect(response.status == .notFound)
#expect(response.headers[.contentSecurityPolicy] == String.Security.contentSecurityPolicy)
#expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions)
}
}
}
@Test
func `strict-transport-security to be applied when configured`() async throws {
try await app(
staticFilesPath: staticFilesPath,
strictTransportSecurity: "max-age=31536000; includeSubDomains"
).test(.router) { client in
try await client.execute(
uri: "/",
method: .get
) { response in
#expect(response.status == .ok)
#expect(response.headers[.strictTransportSecurity] == "max-age=31536000; includeSubDomains")
}
}
}
}
// MARK: - Helpers
private extension AppTests {
// MARK: Computed
var app: some ApplicationProtocol {
get async {
await application(
reader: reader(
staticFilesPath: staticFilesPath
)
)
}
}
// MARK: Methods
func reader(
staticFilesPath: String
) -> ConfigReader {
ConfigReader(providers: [
InMemoryProvider(values: [
.HTTP.host: "127.0.0.1",
.HTTP.port: "0",
.Log.level: "trace",
.Path.staticFiles: .init(stringLiteral: staticFilesPath),
])
])
func app(
staticFilesPath: String,
strictTransportSecurity: String? = nil
) async -> some ApplicationProtocol {
await application(
reader: reader(
staticFilesPath: staticFilesPath,
strictTransportSecurity: strictTransportSecurity
)
)
}
func reader(
staticFilesPath: String,
strictTransportSecurity: String? = nil
) -> ConfigReader {
ConfigReader(providers: [{
if let strictTransportSecurity {
InMemoryProvider(values: [
.HTTP.host: "127.0.0.1",
.HTTP.port: "0",
.Log.level: "trace",
.Path.staticFiles: .init(stringLiteral: staticFilesPath),
.Security.strictTransportSecurity: .init(stringLiteral: strictTransportSecurity)
])
} else {
InMemoryProvider(values: [
.HTTP.host: "127.0.0.1",
.HTTP.port: "0",
.Log.level: "trace",
.Path.staticFiles: .init(stringLiteral: staticFilesPath),
])
}
}()])
}
}
@@ -0,0 +1,134 @@
import Hummingbird
import HummingbirdTesting
import Testing
@testable import WebsiteCore
@Suite("SecurityHeadersMiddleware middleware")
struct SecurityHeadersMiddlewareTests {
// MARK: Functional tests
@Test
func `applies the default security headers to a response`() async throws {
try await app().test(.router) { client in
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.status == .ok)
#expect(response.headers[.contentSecurityPolicy] == String.Security.contentSecurityPolicy)
#expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions)
#expect(response.headers[.frameOptions] == String.Security.frameOptions)
#expect(response.headers[.referrerPolicy] == String.Security.referrerPolicy)
#expect(response.headers[.permissionsPolicy] == String.Security.permissionsPolicy)
}
}
}
@Test
func `omits strict-transport-security by default`() async throws {
try await app().test(.router) { client in
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.headers[.strictTransportSecurity] == nil)
}
}
}
@Test
func `applies strict-transport-security when configured`() async throws {
let value = "max-age=31536000; includeSubDomains"
try await app(
configuration: .init(strictTransportSecurity: value)
).test(.router) { client in
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.headers[.strictTransportSecurity] == value)
}
}
}
@Test
func `applies a custom header value`() async throws {
let value = "default-src 'none'"
try await app(
configuration: .init(contentSecurityPolicy: value)
).test(.router) { client in
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.headers[.contentSecurityPolicy] == value)
}
}
}
@Test
func `omits a header whose configured value is nil`() async throws {
try await app(
configuration: .init(contentTypeOptions: nil)
).test(.router) { client in
try await client.execute(
uri: "/hello",
method: .get
) { response in
#expect(response.headers[.xContentTypeOptions] == nil)
}
}
}
@Test
func `replaces an existing header value set downstream`() async throws {
try await app().test(.router) { client in
try await client.execute(
uri: "/weak",
method: .get
) { response in
#expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions)
}
}
}
}
// MARK: - Helpers
private extension SecurityHeadersMiddlewareTests {
// MARK: Methods
/// Builds an application whose router applies the security-headers middleware ahead of two
/// routes: `/hello` returns a plain body, and `/weak` returns a response that already carries a
/// deliberately weak `X-Content-Type-Options` value for the middleware to override.
func app(
configuration: SecurityHeadersMiddleware<BasicRequestContext>.Configuration = .init()
) -> some ApplicationProtocol {
let router = Router()
router.addMiddleware {
SecurityHeadersMiddleware(configuration: configuration)
}
router.get("hello") { _, _ in
"Hello!"
}
router.get("weak") { _, _ -> Response in
var response = Response(status: .ok)
response.headers[.xContentTypeOptions] = "weak"
return response
}
return Application(router: router)
}
}
+1
View File
@@ -20,3 +20,4 @@ services:
environment:
LOG_LEVEL: ${LOG_LEVEL:-info}
HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-LoudWebsite}
SECURITY_STRICT_TRANSPORT_SECURITY: "${SECURITY_STRICT_TRANSPORT_SECURITY:-max-age=31536000; includeSubDomains}"