From 6b6389cb0fa0e03e84e5cc8d820592d35ceb8a34 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 28 Jun 2026 11:35:54 +0000 Subject: [PATCH] Security header setup for the Website service (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/8 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- Services/Website/Sources/App/App+build.swift | 58 ++++++- .../Extensions/HTTPFieldName+Constants.swift | 10 ++ .../AbsoluteConfigKey+Constants.swift | 15 ++ .../Extensions/ConfigKey+Constants.swift | 15 ++ .../Public/Extensions/String+Constants.swift | 20 +++ .../SecurityHeadersMiddleware.swift | 146 ++++++++++++++++++ Services/Website/Tests/App/AppTests.swift | 129 ++++++++++++---- .../SecurityHeadersMiddlewareTests.swift | 134 ++++++++++++++++ Services/Website/docker-compose.yml | 1 + 9 files changed, 497 insertions(+), 31 deletions(-) create mode 100644 Services/Website/Sources/Library/Internal/Extensions/HTTPFieldName+Constants.swift create mode 100644 Services/Website/Sources/Library/Public/Middlewares/SecurityHeadersMiddleware.swift create mode 100644 Services/Website/Tests/Library/Cases/Public/Middlewares/SecurityHeadersMiddlewareTests.swift diff --git a/Services/Website/Sources/App/App+build.swift b/Services/Website/Sources/App/App+build.swift index 67f9bf4..6514a8e 100644 --- a/Services/Website/Sources/App/App+build.swift +++ b/Services/Website/Sources/App/App+build.swift @@ -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.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.Configuration, logLevel: Logger.Level ) -> Router { let router = Router(context: AppRequestContext.self) router.addMiddleware { LogRequestsMiddleware(logLevel) + SecurityHeadersMiddleware( + configuration: securityHeaders + ) ResponseCompressionMiddleware( minimumResponseSizeToCompress: compressionMinResponseSize ) diff --git a/Services/Website/Sources/Library/Internal/Extensions/HTTPFieldName+Constants.swift b/Services/Website/Sources/Library/Internal/Extensions/HTTPFieldName+Constants.swift new file mode 100644 index 0000000..4ebf3bb --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Extensions/HTTPFieldName+Constants.swift @@ -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")! +} diff --git a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift index f1ab24d..06afdd4 100644 --- a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift @@ -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) + } } diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift index 4a34574..5d27088 100644 --- a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift @@ -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" + } } diff --git a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift index 22a1e0f..63639d9 100644 --- a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift @@ -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 `