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
+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)
}
}