Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
}
9 changes: 9 additions & 0 deletions chess-server/Sources/App/configure.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
32 changes: 32 additions & 0 deletions chess-server/Tests/AppTests/SecurityHeadersMiddlewareTests.swift
Original file line number Diff line number Diff line change
@@ -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")
})
}
}
Loading