diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 700a27f..a51fd1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - name: Run Bionic Tests run: swift test --enable-test-discovery --enable-code-coverage --sanitize=thread - name: Setup container for codecov upload - run: apt-get update && apt-get install curl + run: apt-get update && apt-get install curl -y - name: Process coverage file run: llvm-cov show .build/x86_64-unknown-linux-gnu/debug/VaporSecurityHeadersPackageTests.xctest -instr-profile=.build/debug/codecov/default.profdata > coverage.txt - name: Upload code coverage diff --git a/README.md b/README.md index 6120819..e4e26dd 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Easily add headers to all your responses for improving the security of your site * X-Frame-Options * X-Content-Type-Options * Strict-Transport-Security (HSTS) +* Redirect HTTP to HTTPS * Server * Referrer Policy @@ -436,6 +437,16 @@ let securityHeadersFactory = SecurityHeadersFactory().with(strictTransportSecuri strict-transport-security: max-age=31536000; includeSubDomains; preload ``` +## Redirect HTTP to HTTPS + +If Strict-Transport-Security is not enough to accomplish a forwarding connection to HTTPS from the browsers, you can opt to add an additional middleware who provides this redirection if clients try to reach your site with an HTTP connection. + +To use the HTTPS Redirect Middleware, you can add the following line in **configure.swift** to enable the middleware. This must be done before `securityHeadersFactory.build()` to ensure HSTS works: + +```swift +app.middleware.use(HTTPSRedirectMiddleware()) +``` + ## Server The Server header is usually hidden from responses in order to not give away what type of server you are running and what version you are using. This is to stop attackers from scanning your site and using known vulnerabilities against it easily. By default Vapor does not show the server header in responses for this reason. diff --git a/Sources/VaporSecurityHeaders/Configurations/HTTPSRedirectMiddleware.swift b/Sources/VaporSecurityHeaders/Configurations/HTTPSRedirectMiddleware.swift new file mode 100644 index 0000000..8c06047 --- /dev/null +++ b/Sources/VaporSecurityHeaders/Configurations/HTTPSRedirectMiddleware.swift @@ -0,0 +1,25 @@ +import Vapor + +public class HTTPSRedirectMiddleware: Middleware { + + public init() {} + + public func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture { + if request.application.environment == .development { + return next.respond(to: request) + } + + let proto = request.headers.first(name: "X-Forwarded-Proto") + ?? request.url.scheme + ?? "http" + + guard proto == "https" else { + guard let host = request.headers.first(name: .host) else { + return request.eventLoop.makeFailedFuture(Abort(.badRequest)) + } + let httpsURL = "https://" + host + "\(request.url)" + return request.redirect(to: "\(httpsURL)", type: .permanent).encodeResponse(for: request) + } + return next.respond(to: request) + } +} diff --git a/Tests/VaporSecurityHeadersTests/RedirectionTest.swift b/Tests/VaporSecurityHeadersTests/RedirectionTest.swift new file mode 100644 index 0000000..bb2ad34 --- /dev/null +++ b/Tests/VaporSecurityHeadersTests/RedirectionTest.swift @@ -0,0 +1,86 @@ +import XCTest + +@testable import Vapor + +import VaporSecurityHeaders + +class RedirectionTest: XCTestCase { + + // MARK: - Properties + + private var application: Application! + private var eventLoopGroup: EventLoopGroup! + private var request: Request! + + override func setUp() { + eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) + application = Application(.testing, .shared(eventLoopGroup)) + request = Request(application: application, method: .GET, on: eventLoopGroup.next()) + } + + override func tearDownWithError() throws { + application.shutdown() + try eventLoopGroup.syncShutdownGracefully() + } + + func testWithRedirectionMiddleware() throws { + let expectedRedirectStatus: HTTPStatus = HTTPResponseStatus(statusCode: 301, reasonPhrase: "Moved permanently") + request.headers.add(name: .host, value: "localhost:8080") + let responseRedirected = try makeTestResponse(for: request, withRedirection: true) + XCTAssertEqual(expectedRedirectStatus, responseRedirected.status) + } + func testWithoutRedirectionMiddleware() throws { + let expectedNoRedirectStatus: HTTPStatus = HTTPResponseStatus(statusCode: 200, reasonPhrase: "Ok") + request.headers.add(name: .host, value: "localhost:8080") + let response = try makeTestResponse(for: request, withRedirection: false) + XCTAssertEqual(expectedNoRedirectStatus, response.status) + } + + func testOnDevelopmentEnvironment() throws { + let expectedStatus: HTTPStatus = HTTPResponseStatus(statusCode: 200, reasonPhrase: "Ok") + request.headers.add(name: .host, value: "localhost:8080") + let response = try makeTestResponse(for: request, withRedirection: true, environment: .development) + XCTAssertEqual(expectedStatus, response.status) + } + + func testWithoutHost() throws { + let expectedOutcome: String = "Abort.400: Bad Request" + do { + _ = try makeTestResponse(for: request, withRedirection: true) + } catch (let error) { + XCTAssertEqual(expectedOutcome, error.localizedDescription) + } + } + + func testWithProtoSet() throws { + let expectedStatus: HTTPStatus = HTTPResponseStatus(statusCode: 200, reasonPhrase: "Ok") + request.headers.add(name: .xForwardedProto, value: "https") + let response = try makeTestResponse(for: request, withRedirection: true) + XCTAssertEqual(expectedStatus, response.status) + } + + private func makeTestResponse(for request: Request, withRedirection: Bool, environment: Environment? = nil) throws -> Response { + application.middleware = Middlewares() + if let environment = environment { + application.environment = environment + } + if withRedirection == true { + application.middleware.use(HTTPSRedirectMiddleware()) + } + try routes(application) + return try application.responder.respond(to: request).wait() + } + + func routes(_ app: Application) throws { + try app.register(collection: RouteController()) + } + + struct RouteController: RouteCollection { + func boot(routes: RoutesBuilder) throws { + routes.get(use: testing) + } + func testing(req: Request) throws -> String { + return "Test" + } + } +}