diff --git a/chess-server/Sources/App/Middleware/SecurityHeadersMiddleware.swift b/chess-server/Sources/App/Middleware/SecurityHeadersMiddleware.swift new file mode 100644 index 0000000..2415c26 --- /dev/null +++ b/chess-server/Sources/App/Middleware/SecurityHeadersMiddleware.swift @@ -0,0 +1,30 @@ +import Vapor + +/// Adds baseline hardening headers to every response. This is a JSON API +/// with no HTML surface, so these mitigate second-order risk (a browser ever +/// rendering a response body, e.g. an error page or a misconfigured proxy) +/// rather than a direct attack this server itself is exposed to. +struct SecurityHeadersMiddleware: AsyncMiddleware { + func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response { + let response = try await next.respond(to: request) + // A JSON API is never a plausible framing target, but this costs + // nothing and forecloses it outright. + response.headers.replaceOrAdd(name: .init("X-Frame-Options"), value: "DENY") + // Stops a browser from MIME-sniffing a response into something more + // dangerous than the declared Content-Type (e.g. text/html). + response.headers.replaceOrAdd(name: .init("X-Content-Type-Options"), value: "nosniff") + // No page on this origin should ever leak the request URL (which can + // carry a bearer-adjacent path segment, e.g. /players/:id) via the + // Referer header on an outbound link. + response.headers.replaceOrAdd(name: .init("Referrer-Policy"), value: "no-referrer") + // Fly's edge terminates TLS and forwards over the same TLS + // connection to the client, so this reaches browsers over HTTPS even + // though Vapor itself only ever sees plain HTTP behind the proxy. + // Harmless for the iOS client, which ignores it. + response.headers.replaceOrAdd( + name: .init("Strict-Transport-Security"), + value: "max-age=31536000; includeSubDomains" + ) + return response + } +} diff --git a/chess-server/Sources/App/configure.swift b/chess-server/Sources/App/configure.swift index e647f9a..2cf0c45 100644 --- a/chess-server/Sources/App/configure.swift +++ b/chess-server/Sources/App/configure.swift @@ -79,5 +79,14 @@ public func configure(_ app: Application) async throws { app.gameCoordinator = GameCoordinator(app: app) + // MARK: Response hardening + + // `.beginning` so this wraps Vapor's default ErrorMiddleware rather than + // being wrapped by it: at `.end` (the default position), an error thrown + // by routing or a handler unwinds straight past this middleware without + // running its header-setting code, so 404s/401s/429s/500s would ship + // without these headers — exactly the responses where they matter most. + app.middleware.use(SecurityHeadersMiddleware(), at: .beginning) + try routes(app) } diff --git a/chess-server/Tests/AppTests/SecurityHeadersMiddlewareTests.swift b/chess-server/Tests/AppTests/SecurityHeadersMiddlewareTests.swift new file mode 100644 index 0000000..b42420b --- /dev/null +++ b/chess-server/Tests/AppTests/SecurityHeadersMiddlewareTests.swift @@ -0,0 +1,32 @@ +@testable import App +import XCTVapor + +final class SecurityHeadersMiddlewareTests: XCTestCase { + var app: Application! + + override func setUp() async throws { + app = try await Application.make(.testing) + try await configure(app) + } + + override func tearDown() async throws { + try await app.asyncShutdown() + } + + func testResponsesCarryBaselineSecurityHeaders() async throws { + try await app.test(.GET, "health", afterResponse: { res async in + XCTAssertEqual(res.headers.first(name: "X-Frame-Options"), "DENY") + XCTAssertEqual(res.headers.first(name: "X-Content-Type-Options"), "nosniff") + XCTAssertEqual(res.headers.first(name: "Referrer-Policy"), "no-referrer") + XCTAssertEqual(res.headers.first(name: "Strict-Transport-Security"), "max-age=31536000; includeSubDomains") + }) + } + + func testHeadersAreAlsoPresentOnErrorResponses() async throws { + // The middleware wraps every response, including ones the default + // error handler generates further down the chain (a 404 here). + try await app.test(.GET, "no-such-route", afterResponse: { res async in + XCTAssertEqual(res.headers.first(name: "X-Content-Type-Options"), "nosniff") + }) + } +}