├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── VaporSecurityHeaders │ ├── Configurations │ ├── ContentSecurityPolicyConfiguration.swift │ ├── ContentSecurityPolicyReportOnlyConfiguration.swift │ ├── ContentTypeOptionsConfiguration.swift │ ├── FrameOptionsConfiguration.swift │ ├── HTTPSRedirectMiddleware.swift │ ├── ReferrerPolicyConfiguration.swift │ ├── ServerConfiguration.swift │ ├── StrictTransportSecurityConfiguration.swift │ └── XSSProtectionConfiguration.swift │ ├── SecurityHeaderConfiguration.swift │ ├── SecurityHeaders+HeaderKey.swift │ ├── SecurityHeaders.swift │ └── SecurityHeadersFactory.swift ├── Tests └── VaporSecurityHeadersTests │ ├── Fakes │ └── StubFileMiddleware.swift │ ├── HeaderTests.swift │ └── RedirectionTest.swift └── codecov.yml /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | pull_request: 5 | jobs: 6 | test: 7 | container: 8 | image: swift:5.8 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Run Tests 13 | run: swift test --enable-code-coverage --sanitize=thread 14 | - name: Setup container for codecov upload 15 | run: apt-get update && apt-get install curl -y 16 | - name: Process coverage file 17 | run: llvm-cov show .build/x86_64-unknown-linux-gnu/debug/VaporSecurityHeadersPackageTests.xctest -instr-profile=.build/debug/codecov/default.profdata > coverage.txt 18 | - name: Upload code coverage 19 | uses: codecov/codecov-action@v1 20 | with: 21 | token: ${{ secrets.CODECOV_UPLOAD_KEY }} 22 | file: coverage.txt 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | DerivedData/ 6 | Package.pins 7 | Package.resolved 8 | .swiftpm/ 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at support@brokenhands.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to a Broken Hands project 2 | 3 | :+1::tada: Thank you for wanting to contribute to this project! :tada::+1: 4 | 5 | We ask that you follow a few guidelines when contributing to one of our projects. 6 | 7 | ## Code of Conduct 8 | 9 | This project and everyone participating in it is governed by the [Broken Hands Code of Conduct](https://github.com/brokenhandsio/SteamPress/blob/master/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to [support@brokenhands.io](mailto:support@brokenhandsio). 10 | 11 | # How Can I Contribute? 12 | 13 | ### Reporting Bugs 14 | 15 | This section guides you through submitting a bug report for a Broken Hands project. Following these guidelines helps maintainers and the community understand your report :pencil:, reproduce the behavior :computer: :computer:, and find related reports :mag_right:. 16 | 17 | Before creating bug reports, please check [this list](#before-submitting-a-bug-report) as you might find out that you don't need to create one. When you are creating a bug report, please [include as many details as possible](#how-do-i-submit-a-good-bug-report). 18 | 19 | > **Note:** If you find a **Closed** issue that seems like it is the same thing that you're experiencing, open a new issue and include a link to the original issue in the body of your new one. 20 | 21 | #### Before Submitting A Bug Report 22 | 23 | * **Perform a [cursory search](https://github.com/issues?q=+is%3Aissue+user%3Abrokenhandsio)** to see if the problem has already been reported. If it has **and the issue is still open**, add a comment to the existing issue instead of opening a new one. 24 | 25 | #### How Do I Submit A (Good) Bug Report? 26 | 27 | Bugs are tracked as [GitHub issues](https://guides.github.com/features/issues/). Create an issue on the repository and provide the following information by filling in the issue form. 28 | 29 | Explain the problem and include additional details to help maintainers reproduce the problem: 30 | 31 | * **Use a clear and descriptive title** for the issue to identify the problem. 32 | * **Describe the exact steps which reproduce the problem** in as many details as possible. This usually means including some code, as well as __full__ error messages if applicable. 33 | * **Provide specific examples to demonstrate the steps**. Include links to files or GitHub projects, or copy/pasteable snippets, which you use in those examples. If you're providing snippets in the issue, use [Markdown code blocks](https://help.github.com/articles/markdown-basics/#multiple-lines). 34 | * **Describe the behavior you observed after following the steps** and point out what exactly is the problem with that behavior. 35 | * **Explain which behavior you expected to see instead and why.** 36 | * **If the problem wasn't triggered by a specific action**, describe what you were doing before the problem happened and share more information using the guidelines below. 37 | 38 | ### Suggesting Enhancements 39 | 40 | This section guides you through submitting an enhancement suggestion for a Broken Hands project, including completely new features and minor improvements to existing functionality. Following these guidelines helps maintainers and the community understand your suggestion :pencil: and find related suggestions :mag_right:. 41 | 42 | Before creating enhancement suggestions, please check [this list](#before-submitting-an-enhancement-suggestion) as you might find out that you don't need to create one. When you are creating an enhancement suggestion, please [include as many details as possible](#how-do-i-submit-a-good-enhancement-suggestion). Fill in issue form, including the steps that you imagine you would take if the feature you're requesting existed. 43 | 44 | #### Before Submitting An Enhancement Suggestion 45 | 46 | * **Perform a [cursory search](https://github.com/issues?q=+is%3Aissue+user%3Abrokenhandsio)** to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. 47 | 48 | #### How Do I Submit A (Good) Enhancement Suggestion? 49 | 50 | Enhancement suggestions are tracked as [GitHub issues](https://guides.github.com/features/issues/). Create an issue and provide the following information: 51 | 52 | * **Use a clear and descriptive title** for the issue to identify the suggestion. 53 | * **Provide a step-by-step description of the suggested enhancement** in as many details as possible. 54 | * **Provide specific examples to demonstrate the steps**. Include copy/pasteable snippets which you use in those examples, as [Markdown code blocks](https://help.github.com/articles/markdown-basics/#multiple-lines). 55 | * **Describe the current behavior** and **explain which behavior you expected to see instead** and why. 56 | * **Explain why this enhancement would be useful** to other users and isn't something that can or should be implemented as a separate package. 57 | 58 | ### Pull Requests 59 | 60 | * Do not include issue numbers in the PR title 61 | * End all files with a newline 62 | * All new code should be run through `swiftlint` 63 | * All code must run on both Linux and macOS 64 | * All new code must be covered by tests 65 | * All bug fixes must be accompanied by a test which would fail if the bug fix was not implemented 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Broken Hands 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "VaporSecurityHeaders", 7 | platforms: [ 8 | .macOS(.v10_15) 9 | ], 10 | products: [ 11 | .library(name: "VaporSecurityHeaders", targets: ["VaporSecurityHeaders"]), 12 | ], 13 | dependencies: [ 14 | .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"), 15 | ], 16 | targets: [ 17 | .target(name: "VaporSecurityHeaders", dependencies: [ 18 | .product(name: "Vapor", package: "vapor") 19 | ]), 20 | .testTarget(name: "VaporSecurityHeadersTests", dependencies: ["VaporSecurityHeaders"]), 21 | ] 22 | ) 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Vapor Security Headers 3 |
4 |
5 | 6 | Language 7 | 8 | 9 | Build Status 10 | 11 | Code Coverage 12 | 13 | 14 | MIT License 15 | 16 |

17 | 18 | A Middleware library for adding security headers to your Vapor application. 19 | 20 | # Features 21 | 22 | Easily add headers to all your responses for improving the security of your site for you and your users. Currently supports: 23 | 24 | * Content-Security-Policy 25 | * Content-Security-Policy-Report-Only 26 | * X-XSS-Protection 27 | * X-Frame-Options 28 | * X-Content-Type-Options 29 | * Strict-Transport-Security (HSTS) 30 | * Redirect HTTP to HTTPS 31 | * Server 32 | * Referrer Policy 33 | 34 | These headers will *help* prevent cross-site scripting attacks, SSL downgrade attacks, content injection attacks, click-jacking etc. They will not help for any attacks directly against your server, but they will help your users and help secure sensitive information (CSRF tokens). Please note that this library does not guarantee anything and nothing is ever completely secure. 35 | 36 | # Usage 37 | 38 | ## Add the package 39 | 40 | Add the package as a dependency in your `Package.swift` manifest: 41 | 42 | ```swift 43 | dependencies: [ 44 | ..., 45 | .package(url: "https://github.com/brokenhandsio/VaporSecurityHeaders.git", from: "3.0.0") 46 | ] 47 | ``` 48 | 49 | Then add the dependency to your target: 50 | 51 | ```swift 52 | .target(name: "App", 53 | dependencies: [ 54 | // ... 55 | "VaporSecurityHeaders"]), 56 | ``` 57 | 58 | ## Configuration 59 | 60 | To use Vapor Security Headers, you need to add the middleware to your `Application`'s `Middlewares`. Vapor Security Headers makes this easy to do with a `build` function on the factory. **Note:** if you want security headers added to error reponses (recommended), you need to initialise the `Middlewares` from fresh and add the middleware in _after_ the `SecuriyHeaders`. In `configure.swift` add: 61 | 62 | ```swift 63 | let securityHeadersFactory = SecurityHeadersFactory() 64 | 65 | application.middleware = Middlewares() 66 | application.middleware.use(securityHeadersFactory.build()) 67 | application.middleware.use(ErrorMiddleware.default(environment: application.environment)) 68 | // Add other middlewares... 69 | ``` 70 | 71 | The default factory will add default values to your site for Content-Security-Policy, X-XSS-Protection, X-Frame-Options and X-Content-Type-Options. 72 | 73 | ```HTTP 74 | x-content-type-options: nosniff 75 | content-security-policy: default-src 'self' 76 | x-frame-options: DENY 77 | x-xss-protection: 0 78 | ``` 79 | 80 | ***Note:*** You should ensure you set the security headers as the first middleware in your `Middlewares` (i.e., the first middleware to be applied to responses) to make sure the headers get added to all responses. 81 | 82 | If you want to add your own values, it is easy to do using the factory. For instance, to add a content security policy configuration, just do: 83 | 84 | ```swift 85 | let cspValue = "default-src 'none'; script-src https://static.brokenhands.io;" 86 | 87 | let cspConfig = ContentSecurityPolicyConfiguration(value: cspValue) 88 | 89 | let securityHeadersFactory = SecurityHeadersFactory().with(contentSecurityPolicy: cspConfig) 90 | application.middleware.use(securityHeadersFactory.build()) 91 | ``` 92 | 93 | ```HTTP 94 | x-content-type-options: nosniff 95 | content-security-policy: default-src 'none'; script-src https://static.brokenhands.io; 96 | x-frame-options: DENY 97 | x-xss-protection: 0 98 | ``` 99 | 100 | Each different header has its own configuration and options, details of which can be found below. 101 | 102 | You can test your site by visiting the awesome [Security Headers](https://securityheaders.io) (no affiliation) website. 103 | 104 | ## API Headers 105 | 106 | If you are running an API you can choose a default configuration for that by creating it with: 107 | 108 | ```swift 109 | let securityHeaders = SecurityHeadersFactory.api() 110 | application.middleware.use(securityHeaders.build()) 111 | ``` 112 | 113 | ```http 114 | x-content-type-options: nosniff 115 | content-security-policy: default-src 'none' 116 | x-frame-options: DENY 117 | x-xss-protection: 0 118 | ``` 119 | 120 | # Server Configuration 121 | 122 | ## Vapor 123 | 124 | If you are running Vapor on it's own (i.e. not as a CGI application or behind a reverse proxy) then you do not need to do anything more to get it running! 125 | 126 | ## Nginx, Apache and 3rd Party Services 127 | 128 | Both web servers should pass on the response headers from Vapor without issue when running as a reverse proxy. Some servers and providers (such as Heroku) will inject their own headers or block certain headers (such as HSTS to stop you locking out their whole site). You will need to check with your provider to see what is enabled and allowed. 129 | 130 | # Security Header Information 131 | 132 | ## Content-Security-Policy 133 | 134 | Content Security Policy is one of the most effective tools for protecting against cross-site scripting attacks. In essence it is a way of whitelisting sources for content so that you only load from known and trusted sources. For more information about CSP, read Scott Helme's [awesome blog post](https://scotthelme.co.uk/content-security-policy-an-introduction/) which tells you how to configure it and what to use. 135 | 136 | The Vapor Security Headers package will set a default CSP of `default-src: 'self'`, which means that you can load images, scripts, fonts, CSS etc **only** from your domain. It also means that you cannot have any inline Javascript or CSS, which is one of the most effective measures you can take in protecting your site, and will wipe out a large proportion of content-injection attacks. 137 | 138 | The API default CSP is `default-src: 'none'` as an API should only return data and never be loading scripts or images to display! 139 | 140 | You can build a CSP header (`ContentSecurityPolicy`) with the following directives: 141 | 142 | - baseUri(sources) 143 | - blockAllMixedContent() 144 | - connectSrc(sources) 145 | - defaultSrc(sources) 146 | - fontSrc(sources) 147 | - formAction(sources) 148 | - frameAncestors(sources) 149 | - frameSrc(sources) 150 | - imgSrc(sources) 151 | - manifestSrc(sources) 152 | - mediaSrc(sources) 153 | - objectSrc(sources) 154 | - pluginTypes(types) 155 | - reportTo(json_object) 156 | - reportUri(uri) 157 | - requireSriFor(values) 158 | - sandbox(values) 159 | - scriptSrc(sources) 160 | - styleSrc(sources) 161 | - upgradeInsecureRequests() 162 | - workerSrc(sources) 163 | 164 | *Example:* 165 | 166 | ```swift 167 | let cspConfig = ContentSecurityPolicy() 168 | .scriptSrc(sources: "https://static.brokenhands.io") 169 | .styleSrc(sources: "https://static.brokenhands.io") 170 | .imgSrc(sources: "https://static.brokenhands.io") 171 | ``` 172 | 173 | ```http 174 | Content-Security-Policy: script-src https://static.brokenhands.io; style-src https://static.brokenhands.io; img-src https://static.brokenhands.io 175 | ``` 176 | 177 | You can set a custom header with ContentSecurityPolicy().set(value) or ContentSecurityPolicyConfiguration(value). 178 | 179 | **ContentSecurityPolicy().set(value)** 180 | 181 | ```swift 182 | let cspBuilder = ContentSecurityPolicy().set(value: "default-src: 'none'") 183 | 184 | let cspConfig = ContentSecurityPolicyConfiguration(value: cspBuilder) 185 | 186 | let securityHeadersFactory = SecurityHeadersFactory().with(contentSecurityPolicy: cspConfig) 187 | ``` 188 | 189 | **ContentSecurityPolicyConfiguration(value)** 190 | 191 | ```swift 192 | let cspConfig = ContentSecurityPolicyConfiguration(value: "default-src 'none'") 193 | 194 | let securityHeadersFactory = SecurityHeadersFactory().with(contentSecurityPolicy: cspConfig) 195 | ``` 196 | 197 | ```http 198 | Content-Security-Policy: default-src: 'none' 199 | ``` 200 | 201 | The following CSP keywords (`CSPKeywords`) are also available to you: 202 | 203 | * CSPKeywords.all = * 204 | * CSPKeywords.none = 'none' 205 | * CSPKeywords.\`self\` = 'self' 206 | * CSPKeywords.strictDynamic = 'strict-dynamic' 207 | * CSPKeywords.unsafeEval = 'unsafe-eval' 208 | * CSPKeywords.unsafeHashedAttributes = 'unsafe-hashed-attributes' 209 | * CSPKeywords.unsafeInline = 'unsafe-inline' 210 | 211 | *Example:* 212 | 213 | ``` swift 214 | CSPKeywords.`self` // “‘self’” 215 | ContentSecurityPolicy().defaultSrc(sources: CSPKeywords.`self`) 216 | ``` 217 | 218 | ```http 219 | Content-Security-Policy: default-src 'self' 220 | ``` 221 | 222 | You can also utilize the `Report-To` directive: 223 | 224 | ```swift 225 | let reportToEndpoint = CSPReportToEndpoint(url: "https://csp-report.brokenhands.io/csp-reports") 226 | 227 | let reportToValue = CSPReportTo(group: "vapor-csp", max_age: 10886400, endpoints: [reportToEndpoint], include_subdomains: true) 228 | 229 | let cspValue = ContentSecurityPolicy() 230 | .defaultSrc(sources: CSPKeywords.none) 231 | .scriptSrc(sources: "https://static.brokenhands.io") 232 | .reportTo(reportToObject: reportToValue) 233 | ``` 234 | 235 | ```http 236 | Content-Security-Policy: default-src 'none'; script-src https://static.brokenhands.io; report-to {"group":"vapor-csp","endpoints":[{"url":"https:\/\/csp-report.brokenhands.io\/csp-reports"}],"include_subdomains":true,"max_age":10886400} 237 | ``` 238 | 239 | See [Google Developers - The Reporting API](https://developers.google.com/web/updates/2018/09/reportingapi) for more information on the Report-To directive. 240 | 241 | #### Content Security Policy Configuration 242 | 243 | To configure your CSP you can add it to your `ContentSecurityPolicyConfiguration` like so: 244 | 245 | ```swift 246 | let cspBuilder = ContentSecurityPolicy() 247 | .defaultSrc(sources: CSPKeywords.none) 248 | .scriptSrc(sources: "https://static.brokenhands.io") 249 | .styleSrc(sources: "https://static.brokenhands.io") 250 | .imgSrc(sources: "https://static.brokenhands.io") 251 | .fontSrc(sources: "https://static.brokenhands.io") 252 | .connectSrc(sources: "https://*.brokenhands.io") 253 | .formAction(sources: CSPKeywords.`self`) 254 | .upgradeInsecureRequests() 255 | .blockAllMixedContent() 256 | .requireSriFor(values: "script", "style") 257 | .reportUri(uri: "https://csp-report.brokenhands.io") 258 | 259 | let cspConfig = ContentSecurityPolicyConfiguration(value: cspBuilder) 260 | 261 | let securityHeadersFactory = SecurityHeadersFactory().with(contentSecurityPolicy: cspConfig) 262 | ``` 263 | 264 | ```http 265 | Content-Security-Policy: default-src 'none'; script-src https://static.brokenhands.io; style-src https://static.brokenhands.io; img-src https://static.brokenhands.io; font-src https://static.brokenhands.io; connect-src https://*.brokenhands.io; form-action 'self'; upgrade-insecure-requests; block-all-mixed-content; require-sri-for script style; report-uri https://csp-report.brokenhands.io 266 | ``` 267 | 268 | This policy means that by default everything is blocked, however: 269 | 270 | * Scripts can be loaded from `https://static.brokenhands.io` 271 | * CSS can be loaded from `https://static.brokenhands.io` 272 | * Images can be loaded from `https://static.brokenhands.io` 273 | * Fonts can be loaded from `https://static.brokenhands.io` 274 | * Any JS connections can only be made to any `brokenhands.io` subdomain over HTTPS 275 | * Form actions go only go to the same site 276 | * Any HTTP requests will be sent over HTTPS 277 | * Any attempts to load HTTP content will be blocked 278 | * Any scripts and style links must have [SRI](https://scotthelme.co.uk/subresource-integrity/) values 279 | * Any policy violations will be sent to `https://csp-report.brokenhands.io` 280 | 281 | Check out [https://report-uri.io/](https://report-uri.io/) for a free tool to send all of your CSP reports to. 282 | 283 | ### Page Specific CSP 284 | 285 | Vapor Security Headers also supports setting the CSP on a route or request basis. If the middleware has been added to the `Middlewares`, you can override the CSP for a request. This allows you to have a strict default CSP, but allow content from extra sources when required, such as only allowing the Javascript for blog comments on the blog page. Create a separate `ContentSecurityPolicyConfiguration` and then add it to the request. For example, inside a route handler, you could do: 286 | 287 | ```swift 288 | let cspConfig = ContentSecurityPolicy() 289 | .defaultSrc(sources: CSPKeywords.none) 290 | .scriptSrc(sources: "https://comments.disqus.com") 291 | 292 | let pageSpecificCSP = ContentSecurityPolicyConfiguration(value: cspConfig) 293 | req.contentSecurityPolicy = pageSpecificCSP 294 | ``` 295 | 296 | ```http 297 | content-security-policy: default-src 'none'; script-src https://comments.disqus.com 298 | ``` 299 | 300 | ## Content-Security-Policy-Report-Only 301 | 302 | Content-Security-Policy-Report-Only works in exactly the same way as Content-Security-Policy except that any violations will not block content, but they will be reported back to you. This is extremely useful for testing a CSP before rolling it out over your site. You can run both side by side - so for example have a fairly simply policy under Content-Security-Policy but test a more restrictive policy over Content-Security-Policy-Report-Only. The great thing about this is that your users do all your testing for you! 303 | 304 | To configure this, just pass in your policy to the `ContentSecurityPolicyReportOnlyConfiguration`: 305 | 306 | ```swift 307 | let cspConfig = ContentSecurityPolicyReportOnlyConfiguration(value: "default-src https:; report-uri https://csp-report.brokenhands.io") 308 | 309 | let securityHeadersFactory = SecurityHeadersFactory().with(contentSecurityPolicyReportOnly: cspConfig) 310 | ``` 311 | 312 | ```http 313 | content-security-policy-report-only: default-src https:; report-uri https://csp-report.brokenhands.io 314 | ``` 315 | 316 | The [above blog post](https://scotthelme.co.uk/content-security-policy-an-introduction/) goes into more details about this. 317 | 318 | ## X-XSS-Protection 319 | 320 | X-XSS-Protection configures the browser's cross-site scripting filter. This package configures the header to be disabled, which (surprisingly) offers security benefits. See [this article on MDN for more information](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection). 321 | 322 | ```swift 323 | let xssProtectionConfig = XSSProtectionConfiguration() 324 | 325 | let securityHeadersFactory = SecurityHeadersFactory().with(XSSProtection: xssProtectionConfig) 326 | ``` 327 | 328 | ```http 329 | x-xss-protection: 0 330 | ``` 331 | 332 | ## X-Content-Type-Options 333 | 334 | X-Content-Type-Options stops a browser from trying to MIME-sniff content types from requests and makes sure that the declared content type is used. It only has one option, which is `nosniff`. To use this, set your `ContentTypeOptionsConfiguration` as so (this is set by default on any `SecurityHeaders` object): 335 | 336 | ```swift 337 | let contentTypeConfig = ContentTypeOptionsConfiguration(option: .nosniff) 338 | 339 | let securityHeadersFactory = SecurityHeadersFactory().with(contentTypeOptions: contentTypeConfig) 340 | ``` 341 | 342 | ```http 343 | x-content-type-options: nosniff 344 | ``` 345 | 346 | To disable it: 347 | 348 | ```swift 349 | let contentTypeConfig = ContentTypeOptionsConfiguration(option: .none) 350 | ``` 351 | 352 | ## X-Frame-Options 353 | 354 | The X-Frame-Options header is for click-jacking attacks and tells the browser whether your site can be framed. To stop your site from being framed completely (the default setting): 355 | 356 | ```swift 357 | let frameOptionsConfig = FrameOptionsConfiguration(option: .deny) 358 | 359 | let securityHeadersFactory = SecurityHeadersFactory().with(frameOptions: frameOptionsConfig) 360 | ``` 361 | 362 | ```http 363 | x-frame-options: DENY 364 | ``` 365 | 366 | To allow you to frame your own site: 367 | 368 | ```swift 369 | let frameOptionsConfig = FrameOptionsConfiguration(option: .sameOrigin) 370 | ``` 371 | 372 | ```http 373 | x-frame-options: SAMEORIGIN 374 | ``` 375 | 376 | To allow a specific site to frame yours, use: 377 | 378 | ```swift 379 | let frameOptionsConfig = FrameOptionsConfiguration(option: .allow(from: "https://mytrustedsite.com")) 380 | ``` 381 | 382 | ```http 383 | x-frame-options: ALLOW-FROM https://mytrustedsite.com 384 | ``` 385 | 386 | ## Strict-Transport-Security 387 | 388 | Strict-Transport-Security is an improvement over 301/302 redirects or HTTPS forwarding. Browsers will default to HTTP when you navigate to an address but HSTS (HTTP Strict Transport Security) tells the browser that it should always connect over HTTPS, so all future requests will be HTTPS, even if you click on an HTTP link. By default this is not turned on with the Security Headers library as it can cause issues if you haven't got HTTPS set up properly. If you specify this header and then at a future date you don't renew your SSL certificate or disable SSL then the browser will refuse to load your site! However, it is highly recommended as it ensures that all connections are over HTTPS, even if a user clicks on an HTTP link. 389 | 390 | The default configuration is `max-age=31536000; includeSubDomains; preload`. This tells the browser to force HTTPS for a year, and for *every* subdomain as well. So if you specify this, make sure you have SSL properly configured for all subdomains, e.g. `test.mysite.com`, `dev.mysite.com` etc. 391 | 392 | The `preload` tag tells Chrome that you want to be preloaded. This will add you to the preload list, which means that the browser will automatically know you want an HTTPS connection before you have even visited the site, so removes the initial HTTP handshake the first time you specify the header. However, this has now been superseded and you should now submit your site at [https://hstspreload.org](https://hstspreload.org). This will add your site to Chrome's source to preload it in the future and it is the list that other browsers use as well. Note that it is difficult to remove yourself from the list (and can take months to get it rolled out to the browsers), so by submitting your site you are effectively guaranteeing working HTTPS for the rest of the life of your site. However, these days it shouldn't be a problem - use [Let's Encrypt](https://letsencrypt.org)! **Note**: You should be careful about using this on deployment sites such as Heroku as it may cause issues. 393 | 394 | To use the Strict-Transport-Security header, you can configure and add it as so (default values are shown): 395 | 396 | ```swift 397 | let strictTransportSecurityConfig = StrictTransportSecurityConfiguration(maxAge: 31536000, includeSubdomains: true, preload: true) 398 | 399 | let securityHeadersFactory = SecurityHeadersFactory().with(strictTransportSecurity: strictTransportSecurityConfig) 400 | ``` 401 | 402 | ```http 403 | strict-transport-security: max-age=31536000; includeSubDomains; preload 404 | ``` 405 | 406 | ## Redirect HTTP to HTTPS 407 | 408 | 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. 409 | 410 | 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: 411 | 412 | ```swift 413 | app.middleware.use(HTTPSRedirectMiddleware()) 414 | ``` 415 | 416 | The `HTTPSRedirectMiddleware` allows you to set an array of allowed hosts that the application can redirect to. This prevents attackers poisoning the `Host` header and forcing a redirect to a domain under their control. To use this, provide the list of allowed hosts to the initialiser: 417 | 418 | ```swift 419 | app.middleware.use(HTTPSRedirectMiddleware(allowedHosts: ["www.brokenhands.io", "brokenhands.io", "static.brokenhands.io")) 420 | ``` 421 | 422 | Any attempts to redirect to another host, for example `attacker.com` will result in a **400 Bad Request** response. 423 | 424 | ## Server 425 | 426 | 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. 427 | 428 | However, it can be fun to add in a custom server configuration for a bit of personalization, such as your website name, or company name (look at Github's response) and the `ServerConfiguraiton` allows this. So, for example, if I wanted my `Server` header to be `brokenhands.io`, I would configure it like: 429 | 430 | ```swift 431 | let serverConfig = ServerConfiguration(value: "brokenhands.io") 432 | 433 | let securityHeadersFactory = SecurityHeadersFactory().with(server: serverConfig) 434 | ``` 435 | 436 | ```http 437 | server: brokenhands.io 438 | ``` 439 | 440 | ## Referrer Policy 441 | 442 | The Referrer Policy is the latest header to have been introduced (the spec can be found [here](https://www.w3.org/TR/referrer-policy/)). It basically defines when the `Referrer` header can be sent with a request. You may want to not send the header when going from HTTPS to HTTP for example. 443 | 444 | The different options are: 445 | 446 | * "" 447 | * "no-referrer" 448 | * "no-referrer-when-downgrade" 449 | * "same-origin" 450 | * "origin" 451 | * "strict-origin" 452 | * "origin-when-cross-origin" 453 | * "strict-origin-when-cross-origin" 454 | * "unsafe-url" 455 | 456 | I won't go into details about each one, I will point you in the direction of a far better explanation [by Scott Helme](https://scotthelme.co.uk/a-new-security-header-referrer-policy/). 457 | 458 | ```swift 459 | let referrerPolicyConfig = ReferrerPolicyConfiguration(.noReferrer) 460 | 461 | let securityHeadersFactory = SecurityHeadersFactory().with(referrerPolicy: referrerPolicyConfig) 462 | ``` 463 | 464 | ```http 465 | referrer-policy: no-referrer 466 | ``` 467 | 468 | You can also [set a fallback policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy#specify_a_fallback_policy). 469 | 470 | ```swift 471 | let referrerPolicyConfig = ReferrerPolicyConfiguration([.noReferrer, .strictOriginWhenCrossOrigin]) 472 | 473 | let securityHeadersFactory = SecurityHeadersFactory().with(referrerPolicy: referrerPolicyConfig) 474 | ``` 475 | 476 | ```http 477 | referrer-policy: no-referrer, strict-origin-when-cross-origin 478 | ``` 479 | -------------------------------------------------------------------------------- /Sources/VaporSecurityHeaders/Configurations/ContentSecurityPolicyConfiguration.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Foundation 3 | 4 | public struct ContentSecurityPolicyConfiguration: SecurityHeaderConfiguration { 5 | private let value: String 6 | 7 | public init(value: String) { 8 | self.value = value 9 | } 10 | 11 | public init(value: ContentSecurityPolicy) { 12 | self.value = value.value 13 | } 14 | 15 | func setHeader(on response: Response, from request: Request) { 16 | if let requestCSP = request.contentSecurityPolicy { 17 | response.headers.replaceOrAdd(name: .contentSecurityPolicy, value: requestCSP.value) 18 | } else { 19 | response.headers.replaceOrAdd(name: .contentSecurityPolicy, value: value) 20 | } 21 | } 22 | } 23 | 24 | extension ContentSecurityPolicyConfiguration: StorageKey { 25 | public typealias Value = Self 26 | } 27 | 28 | extension Request { 29 | 30 | public var contentSecurityPolicy: ContentSecurityPolicyConfiguration? { 31 | get { 32 | return self.storage[ContentSecurityPolicyConfiguration.self] 33 | } 34 | set { 35 | self.storage[ContentSecurityPolicyConfiguration.self] = newValue 36 | } 37 | } 38 | } 39 | 40 | public struct CSPReportTo: Codable { 41 | private let group: String? 42 | private let max_age: Int 43 | private let endpoints: [CSPReportToEndpoint] 44 | private let include_subdomains: Bool? 45 | 46 | public init(group: String? = nil, max_age: Int, 47 | endpoints: [CSPReportToEndpoint], include_subdomains: Bool? = nil) { 48 | self.group = group 49 | self.max_age = max_age 50 | self.endpoints = endpoints 51 | self.include_subdomains = include_subdomains 52 | } 53 | } 54 | 55 | public struct CSPReportToEndpoint: Codable { 56 | private let url: String 57 | 58 | public init(url: String) { 59 | self.url = url 60 | } 61 | } 62 | 63 | extension CSPReportToEndpoint: Equatable { 64 | public static func == (lhs: CSPReportToEndpoint, rhs: CSPReportToEndpoint) -> Bool { 65 | return lhs.url == rhs.url 66 | } 67 | } 68 | 69 | extension CSPReportTo: Equatable { 70 | public static func == (lhs: CSPReportTo, rhs: CSPReportTo) -> Bool { 71 | return lhs.group == rhs.group && 72 | lhs.max_age == rhs.max_age && 73 | lhs.endpoints == rhs.endpoints && 74 | lhs.include_subdomains == rhs.include_subdomains 75 | } 76 | } 77 | 78 | public struct CSPKeywords { 79 | public static let all = "*" 80 | public static let none = "'none'" 81 | public static let `self` = "'self'" 82 | public static let strictDynamic = "'strict-dynamic'" 83 | public static let unsafeEval = "'unsafe-eval'" 84 | public static let unsafeHashedAttributes = "'unsafe-hashed-attributes'" 85 | public static let unsafeInline = "'unsafe-inline'" 86 | } 87 | 88 | public class ContentSecurityPolicy { 89 | private var policy: [String] = [] 90 | 91 | var value: String { 92 | return policy.joined(separator: "; ") 93 | } 94 | 95 | @discardableResult 96 | public func set(value: String) -> ContentSecurityPolicy { 97 | policy.append(value) 98 | return self 99 | } 100 | 101 | @discardableResult 102 | public func baseUri(sources: String...) -> ContentSecurityPolicy { 103 | join(sources: sources, to: "base-uri") 104 | return self 105 | } 106 | 107 | @discardableResult 108 | public func baseUri(sources: [String]) -> ContentSecurityPolicy { 109 | join(sources: sources, to: "base-uri") 110 | return self 111 | } 112 | 113 | @discardableResult 114 | public func blockAllMixedContent() -> ContentSecurityPolicy { 115 | policy.append("block-all-mixed-content") 116 | return self 117 | } 118 | 119 | @discardableResult 120 | public func childSrc(sources: String...) -> ContentSecurityPolicy { 121 | join(sources: sources, to: "child-src") 122 | return self 123 | } 124 | 125 | @discardableResult 126 | public func childSrc(sources: [String]) -> ContentSecurityPolicy { 127 | join(sources: sources, to: "child-src") 128 | return self 129 | } 130 | 131 | @discardableResult 132 | public func connectSrc(sources: String...) -> ContentSecurityPolicy { 133 | join(sources: sources, to: "connect-src") 134 | return self 135 | } 136 | 137 | @discardableResult 138 | public func connectSrc(sources: [String]) -> ContentSecurityPolicy { 139 | join(sources: sources, to: "connect-src") 140 | return self 141 | } 142 | 143 | @discardableResult 144 | public func defaultSrc(sources: String...) -> ContentSecurityPolicy { 145 | join(sources: sources, to: "default-src") 146 | return self 147 | } 148 | 149 | @discardableResult 150 | public func defaultSrc(sources: [String]) -> ContentSecurityPolicy { 151 | join(sources: sources, to: "default-src") 152 | return self 153 | } 154 | 155 | @discardableResult 156 | public func fontSrc(sources: String...) -> ContentSecurityPolicy { 157 | join(sources: sources, to: "font-src") 158 | return self 159 | } 160 | 161 | @discardableResult 162 | public func fontSrc(sources: [String]) -> ContentSecurityPolicy { 163 | join(sources: sources, to: "font-src") 164 | return self 165 | } 166 | 167 | @discardableResult 168 | public func formAction(sources: String...) -> ContentSecurityPolicy { 169 | join(sources: sources, to: "form-action") 170 | return self 171 | } 172 | 173 | @discardableResult 174 | public func formAction(sources: [String]) -> ContentSecurityPolicy { 175 | join(sources: sources, to: "form-action") 176 | return self 177 | } 178 | 179 | @discardableResult 180 | public func frameAncestors(sources: String...) -> ContentSecurityPolicy { 181 | join(sources: sources, to: "frame-ancestors") 182 | return self 183 | } 184 | 185 | @discardableResult 186 | public func frameAncestors(sources: [String]) -> ContentSecurityPolicy { 187 | join(sources: sources, to: "frame-ancestors") 188 | return self 189 | } 190 | 191 | @discardableResult 192 | public func frameSrc(sources: String...) -> ContentSecurityPolicy { 193 | join(sources: sources, to: "frame-src") 194 | return self 195 | } 196 | 197 | @discardableResult 198 | public func frameSrc(sources: [String]) -> ContentSecurityPolicy { 199 | join(sources: sources, to: "frame-src") 200 | return self 201 | } 202 | 203 | @discardableResult 204 | public func imgSrc(sources: String...) -> ContentSecurityPolicy { 205 | join(sources: sources, to: "img-src") 206 | return self 207 | } 208 | 209 | @discardableResult 210 | public func imgSrc(sources: [String]) -> ContentSecurityPolicy { 211 | join(sources: sources, to: "img-src") 212 | return self 213 | } 214 | 215 | @discardableResult 216 | public func manifestSrc(sources: String...) -> ContentSecurityPolicy { 217 | join(sources: sources, to: "manifest-src") 218 | return self 219 | } 220 | 221 | @discardableResult 222 | public func manifestSrc(sources: [String]) -> ContentSecurityPolicy { 223 | join(sources: sources, to: "manifest-src") 224 | return self 225 | } 226 | 227 | @discardableResult 228 | public func mediaSrc(sources: String...) -> ContentSecurityPolicy { 229 | join(sources: sources, to: "media-src") 230 | return self 231 | } 232 | 233 | @discardableResult 234 | public func mediaSrc(sources: [String]) -> ContentSecurityPolicy { 235 | join(sources: sources, to: "media-src") 236 | return self 237 | } 238 | 239 | @discardableResult 240 | public func objectSrc(sources: String...) -> ContentSecurityPolicy { 241 | join(sources: sources, to: "object-src") 242 | return self 243 | } 244 | 245 | @discardableResult 246 | public func objectSrc(sources: [String]) -> ContentSecurityPolicy { 247 | join(sources: sources, to: "object-src") 248 | return self 249 | } 250 | 251 | @discardableResult 252 | public func pluginTypes(types: String...) -> ContentSecurityPolicy { 253 | join(sources: types, to: "plugin-types") 254 | return self 255 | } 256 | 257 | @discardableResult 258 | public func pluginTypes(types: [String]) -> ContentSecurityPolicy { 259 | join(sources: types, to: "plugin-types") 260 | return self 261 | } 262 | 263 | @discardableResult 264 | public func requireSriFor(values: String...) -> ContentSecurityPolicy { 265 | join(sources: values, to: "require-sri-for") 266 | return self 267 | } 268 | 269 | @discardableResult 270 | public func requireSriFor(values: [String]) -> ContentSecurityPolicy { 271 | join(sources: values, to: "require-sri-for") 272 | return self 273 | } 274 | 275 | @discardableResult 276 | public func reportTo(reportToObject: CSPReportTo) -> ContentSecurityPolicy { 277 | let encoder = JSONEncoder() 278 | guard let data = try? encoder.encode(reportToObject) else { return self } 279 | guard let jsonString = String(data: data, encoding: .utf8) else { return self } 280 | policy.append("report-to \(String(describing: jsonString))") 281 | return self 282 | } 283 | 284 | @discardableResult 285 | public func reportUri(uri: String) -> ContentSecurityPolicy { 286 | policy.append("report-uri \(uri)") 287 | return self 288 | } 289 | 290 | @discardableResult 291 | public func sandbox(values: String...) -> ContentSecurityPolicy { 292 | join(sources: values, to: "sandbox") 293 | return self 294 | } 295 | 296 | @discardableResult 297 | public func sandbox(values: [String]) -> ContentSecurityPolicy { 298 | join(sources: values, to: "sandbox") 299 | return self 300 | } 301 | 302 | @discardableResult 303 | public func scriptSrc(sources: String...) -> ContentSecurityPolicy { 304 | join(sources: sources, to: "script-src") 305 | return self 306 | } 307 | 308 | @discardableResult 309 | public func scriptSrc(sources: [String]) -> ContentSecurityPolicy { 310 | join(sources: sources, to: "script-src") 311 | return self 312 | } 313 | 314 | @discardableResult 315 | public func styleSrc(sources: String...) -> ContentSecurityPolicy { 316 | join(sources: sources, to: "style-src") 317 | return self 318 | } 319 | 320 | @discardableResult 321 | public func styleSrc(sources: [String]) -> ContentSecurityPolicy { 322 | join(sources: sources, to: "style-src") 323 | return self 324 | } 325 | 326 | @discardableResult 327 | public func upgradeInsecureRequests() -> ContentSecurityPolicy { 328 | policy.append("upgrade-insecure-requests") 329 | return self 330 | } 331 | 332 | @discardableResult 333 | public func workerSrc(sources: String...) -> ContentSecurityPolicy { 334 | join(sources: sources, to: "worker-src") 335 | return self 336 | } 337 | 338 | @discardableResult 339 | public func workerSrc(sources: [String]) -> ContentSecurityPolicy { 340 | join(sources: sources, to: "worker-src") 341 | return self 342 | } 343 | 344 | private func join(sources: [String], to directive: String) { 345 | policy.append("\(directive) \(sources.joined(separator: " "))") 346 | } 347 | 348 | public init() {} 349 | } 350 | -------------------------------------------------------------------------------- /Sources/VaporSecurityHeaders/Configurations/ContentSecurityPolicyReportOnlyConfiguration.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public struct ContentSecurityPolicyReportOnlyConfiguration: SecurityHeaderConfiguration { 4 | 5 | private let value: String 6 | 7 | public init(value: String) { 8 | self.value = value 9 | } 10 | 11 | func setHeader(on response: Response, from request: Request) { 12 | response.headers.replaceOrAdd(name: .contentSecurityPolicyReportOnly, value: value) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/VaporSecurityHeaders/Configurations/ContentTypeOptionsConfiguration.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public struct ContentTypeOptionsConfiguration: SecurityHeaderConfiguration { 4 | 5 | private let option: Options 6 | 7 | public init(option: Options) { 8 | self.option = option 9 | } 10 | 11 | public enum Options { 12 | case nosniff 13 | case none 14 | } 15 | 16 | func setHeader(on response: Response, from request: Request) { 17 | switch option { 18 | case .nosniff: 19 | response.headers.replaceOrAdd(name: .xContentTypeOptions, value: "nosniff") 20 | default: 21 | break 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/VaporSecurityHeaders/Configurations/FrameOptionsConfiguration.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public struct FrameOptionsConfiguration: SecurityHeaderConfiguration { 4 | 5 | public enum Options { 6 | case deny 7 | case sameOrigin 8 | case allow(from: String) 9 | } 10 | 11 | private let option: Options 12 | 13 | public init(option: Options) { 14 | self.option = option 15 | } 16 | 17 | func setHeader(on response: Response, from request: Request) { 18 | switch option { 19 | case .deny: 20 | response.headers.replaceOrAdd(name: .xFrameOptions, value: "DENY") 21 | case .sameOrigin: 22 | response.headers.replaceOrAdd(name: .xFrameOptions, value: "SAMEORIGIN") 23 | case .allow(let from): 24 | response.headers.replaceOrAdd(name: .xFrameOptions, value: "ALLOW-FROM \(from)") 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/VaporSecurityHeaders/Configurations/HTTPSRedirectMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public class HTTPSRedirectMiddleware: Middleware { 4 | 5 | let allowedHosts: [String]? 6 | 7 | public init(allowedHosts: [String]? = nil) { 8 | self.allowedHosts = allowedHosts 9 | } 10 | 11 | public func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture { 12 | if request.application.environment == .development { 13 | return next.respond(to: request) 14 | } 15 | 16 | let proto = request.headers.first(name: "X-Forwarded-Proto") 17 | ?? request.url.scheme 18 | ?? "http" 19 | 20 | guard proto == "https" else { 21 | guard let host = request.headers.first(name: .host) else { 22 | return request.eventLoop.makeFailedFuture(Abort(.badRequest)) 23 | } 24 | 25 | if let allowedHosts = allowedHosts { 26 | guard allowedHosts.contains(host) else { 27 | return request.eventLoop.makeFailedFuture(Abort(.badRequest)) 28 | } 29 | } 30 | 31 | let httpsURL = "https://" + host + "\(request.url)" 32 | return request.redirect(to: "\(httpsURL)", redirectType: .permanent).encodeResponse(for: request) 33 | } 34 | return next.respond(to: request) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/VaporSecurityHeaders/Configurations/ReferrerPolicyConfiguration.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public struct ReferrerPolicyConfiguration: SecurityHeaderConfiguration { 4 | 5 | public enum Directive: String { 6 | case empty = "" 7 | case noReferrer = "no-referrer" 8 | case noReferrerWhenDowngrade = "no-referrer-when-downgrade" 9 | case sameOrigin = "same-origin" 10 | case origin = "origin" 11 | case strictOrigin = "strict-origin" 12 | case originWhenCrossOrigin = "origin-when-cross-origin" 13 | case strictOriginWhenCrossOrigin = "strict-origin-when-cross-origin" 14 | case unsafeUrl = "unsafe-url" 15 | } 16 | 17 | private let directives: [Directive] 18 | 19 | public init(_ directive: Directive) { 20 | self.directives = [directive] 21 | } 22 | 23 | public init(_ directives: [Directive]) { 24 | self.directives = directives 25 | } 26 | 27 | func setHeader(on response: Response, from request: Request) { 28 | response.headers.replaceOrAdd(name: .referrerPolicy, value: directives.map({ $0.rawValue }).joined(separator: ", ")) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/VaporSecurityHeaders/Configurations/ServerConfiguration.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public struct ServerConfiguration: SecurityHeaderConfiguration { 4 | private let value: String 5 | 6 | public init(value: String) { 7 | self.value = value 8 | } 9 | 10 | func setHeader(on response: Response, from request: Request) { 11 | response.headers.replaceOrAdd(name: .server, value: value) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/VaporSecurityHeaders/Configurations/StrictTransportSecurityConfiguration.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public struct StrictTransportSecurityConfiguration: SecurityHeaderConfiguration { 4 | 5 | private let maxAge: Int 6 | private let includeSubdomains: Bool 7 | private let preload: Bool 8 | 9 | public init(maxAge: Int = 31536000, includeSubdomains: Bool = true, preload: Bool = true) { 10 | self.maxAge = maxAge 11 | self.includeSubdomains = includeSubdomains 12 | self.preload = preload 13 | } 14 | 15 | func setHeader(on response: Response, from request: Request) { 16 | var headerValue = "max-age=\(maxAge);" 17 | if includeSubdomains { 18 | headerValue += " includeSubDomains;" 19 | } 20 | if preload { 21 | headerValue += " preload" 22 | } 23 | 24 | response.headers.replaceOrAdd(name: .strictTransportSecurity, value: headerValue) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/VaporSecurityHeaders/Configurations/XSSProtectionConfiguration.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public struct XSSProtectionConfiguration: SecurityHeaderConfiguration { 4 | public init () {} 5 | 6 | func setHeader(on response: Response, from request: Request) { 7 | response.headers.replaceOrAdd(name: .xssProtection, value: "0") 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/VaporSecurityHeaders/SecurityHeaderConfiguration.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | protocol SecurityHeaderConfiguration { 4 | func setHeader(on response: Response, from request: Request) 5 | } 6 | -------------------------------------------------------------------------------- /Sources/VaporSecurityHeaders/SecurityHeaders+HeaderKey.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public extension HTTPHeaders.Name { 4 | 5 | static let contentSecurityPolicyReportOnly = HTTPHeaders.Name("Content-Security-Policy-Report-Only") 6 | static let referrerPolicy = HTTPHeaders.Name("Referrer-Policy") 7 | } 8 | 9 | -------------------------------------------------------------------------------- /Sources/VaporSecurityHeaders/SecurityHeaders.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public struct SecurityHeaders { 4 | 5 | var configurations: [SecurityHeaderConfiguration] 6 | 7 | init(contentTypeConfiguration: ContentTypeOptionsConfiguration = ContentTypeOptionsConfiguration(option: .nosniff), 8 | contentSecurityPolicyConfiguration: ContentSecurityPolicyConfiguration = ContentSecurityPolicyConfiguration(value: ContentSecurityPolicy().defaultSrc(sources: CSPKeywords.`self`)), 9 | frameOptionsConfiguration: FrameOptionsConfiguration = FrameOptionsConfiguration(option: .deny), 10 | xssProtectionConfiguration: XSSProtectionConfiguration? = XSSProtectionConfiguration(), 11 | hstsConfiguration: StrictTransportSecurityConfiguration? = nil, 12 | serverConfiguration: ServerConfiguration? = nil, 13 | contentSecurityPolicyReportOnlyConfiguration: ContentSecurityPolicyReportOnlyConfiguration? = nil, 14 | referrerPolicyConfiguration: ReferrerPolicyConfiguration? = nil) { 15 | configurations = [contentTypeConfiguration, contentSecurityPolicyConfiguration, frameOptionsConfiguration] 16 | 17 | if let xssProtectionConfiguration { 18 | configurations.append(xssProtectionConfiguration) 19 | } 20 | 21 | if let hstsConfiguration = hstsConfiguration { 22 | configurations.append(hstsConfiguration) 23 | } 24 | 25 | if let serverConfiguration = serverConfiguration { 26 | configurations.append(serverConfiguration) 27 | } 28 | 29 | if let contentSecurityPolicyReportOnlyConfiguration = contentSecurityPolicyReportOnlyConfiguration { 30 | configurations.append(contentSecurityPolicyReportOnlyConfiguration) 31 | } 32 | 33 | if let referrerPolicyConfiguration = referrerPolicyConfiguration { 34 | configurations.append(referrerPolicyConfiguration) 35 | } 36 | } 37 | 38 | } 39 | 40 | extension SecurityHeaders: Middleware { 41 | 42 | public func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture { 43 | next.respond(to: request).map { response in 44 | for spec in self.configurations { 45 | spec.setHeader(on: response, from: request) 46 | } 47 | return response 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/VaporSecurityHeaders/SecurityHeadersFactory.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public class SecurityHeadersFactory { 4 | var contentTypeOptions = ContentTypeOptionsConfiguration(option: .nosniff) 5 | var contentSecurityPolicy = ContentSecurityPolicyConfiguration(value: ContentSecurityPolicy().defaultSrc(sources: CSPKeywords.`self`)) 6 | var frameOptions = FrameOptionsConfiguration(option: .deny) 7 | var xssProtection: XSSProtectionConfiguration? = XSSProtectionConfiguration() 8 | var hsts: StrictTransportSecurityConfiguration? 9 | var server: ServerConfiguration? 10 | var referrerPolicy: ReferrerPolicyConfiguration? 11 | var contentSecurityPolicyReportOnly: ContentSecurityPolicyReportOnlyConfiguration? 12 | 13 | public init() {} 14 | 15 | public static func api() -> SecurityHeadersFactory { 16 | let apiFactory = SecurityHeadersFactory() 17 | apiFactory.contentSecurityPolicy = ContentSecurityPolicyConfiguration(value: ContentSecurityPolicy().defaultSrc(sources: CSPKeywords.none)) 18 | return apiFactory 19 | } 20 | 21 | @discardableResult public func with(contentTypeOptions configuration: ContentTypeOptionsConfiguration) -> SecurityHeadersFactory { 22 | contentTypeOptions = configuration 23 | return self 24 | } 25 | 26 | @discardableResult public func with(contentSecurityPolicy configuration: ContentSecurityPolicyConfiguration) -> SecurityHeadersFactory { 27 | contentSecurityPolicy = configuration 28 | return self 29 | } 30 | 31 | @discardableResult public func with(frameOptions configuration: FrameOptionsConfiguration) -> SecurityHeadersFactory { 32 | frameOptions = configuration 33 | return self 34 | } 35 | 36 | @discardableResult public func with(XSSProtection configuration: XSSProtectionConfiguration?) -> SecurityHeadersFactory { 37 | xssProtection = configuration 38 | return self 39 | } 40 | 41 | @discardableResult public func with(strictTransportSecurity configuration: StrictTransportSecurityConfiguration) -> SecurityHeadersFactory { 42 | hsts = configuration 43 | return self 44 | } 45 | 46 | @discardableResult public func with(server configuration: ServerConfiguration) -> SecurityHeadersFactory { 47 | server = configuration 48 | return self 49 | } 50 | 51 | @discardableResult public func with(referrerPolicy configuration: ReferrerPolicyConfiguration) -> SecurityHeadersFactory { 52 | referrerPolicy = configuration 53 | return self 54 | } 55 | 56 | @discardableResult public func with(contentSecurityPolicyReportOnly configuration: ContentSecurityPolicyReportOnlyConfiguration) -> SecurityHeadersFactory { 57 | contentSecurityPolicyReportOnly = configuration 58 | return self 59 | } 60 | 61 | public func build() -> SecurityHeaders { 62 | return SecurityHeaders(contentTypeConfiguration: contentTypeOptions, 63 | contentSecurityPolicyConfiguration: contentSecurityPolicy, 64 | frameOptionsConfiguration: frameOptions, 65 | xssProtectionConfiguration: xssProtection, 66 | hstsConfiguration: hsts, 67 | serverConfiguration: server, 68 | contentSecurityPolicyReportOnlyConfiguration: contentSecurityPolicyReportOnly, 69 | referrerPolicyConfiguration: referrerPolicy) 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /Tests/VaporSecurityHeadersTests/Fakes/StubFileMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import VaporSecurityHeaders 3 | 4 | struct StubFileMiddleware: Middleware { 5 | var cspConfig: ContentSecurityPolicyConfiguration? 6 | init(cspConfig: ContentSecurityPolicyConfiguration? = nil) { 7 | self.cspConfig = cspConfig 8 | } 9 | 10 | func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture { 11 | if request.url.path == "/file/" { 12 | request.contentSecurityPolicy = self.cspConfig 13 | 14 | let body = Response.Body(string: "Hello World!") 15 | var headers = HTTPHeaders() 16 | headers.add(name: .eTag, value: "1491512490-\(body.count)") 17 | headers.add(name: .contentType, value: "text/plain") 18 | let response = Response(status: .ok, headers: headers, body: body) 19 | return request.eventLoop.future(response) 20 | } 21 | else { 22 | return next.respond(to: request) 23 | } 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /Tests/VaporSecurityHeadersTests/HeaderTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Vapor 4 | 5 | import VaporSecurityHeaders 6 | 7 | class HeaderTests: XCTestCase { 8 | 9 | // MARK: - Properties 10 | 11 | private var application: Application! 12 | private var eventLoopGroup: EventLoopGroup! 13 | private var request: Request! 14 | private var routeRequest: Request! 15 | private var abortRequest: Request! 16 | private var fileRequest: Request! 17 | 18 | // MARK: - Overrides 19 | 20 | override func setUp() { 21 | eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 22 | application = Application(.testing, .shared(eventLoopGroup)) 23 | request = Request(application: application, method: .GET, url: URI(string: "/test/"), on: eventLoopGroup.next()) 24 | routeRequest = Request(application: application, method: .GET, url: URI(string: "/route/"), on: eventLoopGroup.next()) 25 | abortRequest = Request(application: application, method: .GET, url: URI(string: "/abort/"), on: eventLoopGroup.next()) 26 | fileRequest = Request(application: application, method: .GET, url: URI(string: "/file/"), on: eventLoopGroup.next()) 27 | } 28 | 29 | override func tearDownWithError() throws { 30 | application.shutdown() 31 | try eventLoopGroup.syncShutdownGracefully() 32 | } 33 | 34 | // MARK: - Tests 35 | 36 | func testDefaultHeaders() throws { 37 | let expectedXCTOHeaderValue = "nosniff" 38 | let expectedCSPHeaderValue = "default-src 'self'" 39 | let expectedXFOHeaderValue = "DENY" 40 | let expectedXSSProtectionHeaderValue = "0" 41 | 42 | let response = try makeTestResponse(for: request, securityHeadersToAdd: SecurityHeadersFactory()) 43 | 44 | XCTAssertEqual(expectedXCTOHeaderValue, response.headers[.xContentTypeOptions].first) 45 | XCTAssertEqual(expectedCSPHeaderValue, response.headers[.contentSecurityPolicy].first) 46 | XCTAssertEqual(expectedXFOHeaderValue, response.headers[.xFrameOptions].first) 47 | XCTAssertEqual(expectedXSSProtectionHeaderValue, response.headers[.xssProtection].first) 48 | } 49 | 50 | func testDefaultHeadersWithHSTS() throws { 51 | let expectedXCTOHeaderValue = "nosniff" 52 | let expectedCSPHeaderValue = "default-src 'self'" 53 | let expectedXFOHeaderValue = "DENY" 54 | let expectedXSSProtectionHeaderValue = "0" 55 | let expectedHSTSHeaderValue = "max-age=31536000; includeSubDomains; preload" 56 | 57 | let response = try makeTestResponse(for: request, securityHeadersToAdd: SecurityHeadersFactory().with(strictTransportSecurity: StrictTransportSecurityConfiguration())) 58 | 59 | XCTAssertEqual(expectedXCTOHeaderValue, response.headers[.xContentTypeOptions].first) 60 | XCTAssertEqual(expectedCSPHeaderValue, response.headers[.contentSecurityPolicy].first) 61 | XCTAssertEqual(expectedXFOHeaderValue, response.headers[.xFrameOptions].first) 62 | XCTAssertEqual(expectedXSSProtectionHeaderValue, response.headers[.xssProtection].first) 63 | XCTAssertEqual(expectedHSTSHeaderValue, response.headers[.strictTransportSecurity].first) 64 | } 65 | 66 | func testAllHeadersForApi() throws { 67 | let expectedXCTOHeaderValue = "nosniff" 68 | let expectedCSPHeaderValue = "default-src 'none'" 69 | let expectedXFOHeaderValue = "DENY" 70 | let expectedXSSProtectionHeaderValue = "0" 71 | 72 | let response = try makeTestResponse(for: request, securityHeadersToAdd: SecurityHeadersFactory.api()) 73 | 74 | XCTAssertEqual(expectedXCTOHeaderValue, response.headers[.xContentTypeOptions].first) 75 | XCTAssertEqual(expectedCSPHeaderValue, response.headers[.contentSecurityPolicy].first) 76 | XCTAssertEqual(expectedXFOHeaderValue, response.headers[.xFrameOptions].first) 77 | XCTAssertEqual(expectedXSSProtectionHeaderValue, response.headers[.xssProtection].first) 78 | } 79 | 80 | func testAPIHeadersWithHSTS() throws { 81 | let expectedXCTOHeaderValue = "nosniff" 82 | let expectedCSPHeaderValue = "default-src 'none'" 83 | let expectedXFOHeaderValue = "DENY" 84 | let expectedXSSProtectionHeaderValue = "0" 85 | let expectedHSTSHeaderValue = "max-age=31536000; includeSubDomains; preload" 86 | 87 | let response = try makeTestResponse(for: request, securityHeadersToAdd: SecurityHeadersFactory.api().with(strictTransportSecurity: StrictTransportSecurityConfiguration())) 88 | 89 | XCTAssertEqual(expectedXCTOHeaderValue, response.headers[.xContentTypeOptions].first) 90 | XCTAssertEqual(expectedCSPHeaderValue, response.headers[.contentSecurityPolicy].first) 91 | XCTAssertEqual(expectedXFOHeaderValue, response.headers[.xFrameOptions].first) 92 | XCTAssertEqual(expectedXSSProtectionHeaderValue, response.headers[.xssProtection].first) 93 | XCTAssertEqual(expectedHSTSHeaderValue, response.headers[.strictTransportSecurity].first) 94 | } 95 | 96 | func testHeadersWithContentTypeOptionsTurnedOff() throws { 97 | let contentTypeConfig = ContentTypeOptionsConfiguration(option: .none) 98 | let factory = SecurityHeadersFactory().with(contentTypeOptions: contentTypeConfig) 99 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 100 | 101 | XCTAssertNil(response.headers[.xContentTypeOptions].first) 102 | } 103 | 104 | func testHeadersWithContentTypeOptionsNosniff() throws { 105 | let contentTypeConfig = ContentTypeOptionsConfiguration(option: .nosniff) 106 | let factory = SecurityHeadersFactory().with(contentTypeOptions: contentTypeConfig) 107 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 108 | 109 | XCTAssertEqual("nosniff", response.headers[.xContentTypeOptions].first) 110 | } 111 | 112 | func testHeaderWithFrameOptionsDeny() throws { 113 | let frameOptionsConfig = FrameOptionsConfiguration(option: .deny) 114 | let factory = SecurityHeadersFactory().with(frameOptions: frameOptionsConfig) 115 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 116 | 117 | XCTAssertEqual("DENY", response.headers[.xFrameOptions].first) 118 | } 119 | 120 | func testHeaderWithFrameOptionsSameOrigin() throws { 121 | let frameOptionsConfig = FrameOptionsConfiguration(option: .sameOrigin) 122 | let factory = SecurityHeadersFactory().with(frameOptions: frameOptionsConfig) 123 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 124 | 125 | XCTAssertEqual("SAMEORIGIN", response.headers[.xFrameOptions].first) 126 | } 127 | 128 | func testHeaderWithFrameOptionsAllowFrom() throws { 129 | let frameOptionsConfig = FrameOptionsConfiguration(option: .allow(from: "https://test.com")) 130 | let factory = SecurityHeadersFactory().with(frameOptions: frameOptionsConfig) 131 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 132 | 133 | XCTAssertEqual("ALLOW-FROM https://test.com", response.headers[.xFrameOptions].first) 134 | } 135 | 136 | func testHeaderWithXssProtection() throws { 137 | let xssProtectionConfig = XSSProtectionConfiguration() 138 | let factory = SecurityHeadersFactory().with(XSSProtection: xssProtectionConfig) 139 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 140 | 141 | XCTAssertEqual("0", response.headers[.xssProtection].first) 142 | } 143 | 144 | func testHeaderWithXssProtectionDisabled() throws { 145 | let factory = SecurityHeadersFactory().with(XSSProtection: nil) 146 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 147 | 148 | XCTAssertNil(response.headers[.xssProtection].first) 149 | } 150 | 151 | func testHeaderWithHSTSwithMaxAge() throws { 152 | let hstsConfig = StrictTransportSecurityConfiguration(maxAge: 30) 153 | let factory = SecurityHeadersFactory().with(strictTransportSecurity: hstsConfig) 154 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 155 | 156 | XCTAssertEqual("max-age=30; includeSubDomains; preload", response.headers[.strictTransportSecurity].first) 157 | } 158 | 159 | func testHeadersWithHSTSwithSubdomains() throws { 160 | let hstsConfig = StrictTransportSecurityConfiguration(maxAge: 30, includeSubdomains: true) 161 | let factory = SecurityHeadersFactory().with(strictTransportSecurity: hstsConfig) 162 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 163 | 164 | XCTAssertEqual("max-age=30; includeSubDomains; preload", response.headers[.strictTransportSecurity].first) 165 | } 166 | 167 | func testHeadersWithHSTSwithPreload() throws { 168 | let hstsConfig = StrictTransportSecurityConfiguration(maxAge: 30, preload: true) 169 | let factory = SecurityHeadersFactory().with(strictTransportSecurity: hstsConfig) 170 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 171 | 172 | XCTAssertEqual("max-age=30; includeSubDomains; preload", response.headers[.strictTransportSecurity].first) 173 | } 174 | 175 | func testHeadersWithHSTSwithPreloadAndSubdomain() throws { 176 | let hstsConfig = StrictTransportSecurityConfiguration(maxAge: 30, includeSubdomains: true, preload: true) 177 | let factory = SecurityHeadersFactory().with(strictTransportSecurity: hstsConfig) 178 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 179 | 180 | XCTAssertEqual("max-age=30; includeSubDomains; preload", response.headers[.strictTransportSecurity].first) 181 | } 182 | 183 | func testHeadersWithHSTSwithSubdomainsFalse() throws { 184 | let hstsConfig = StrictTransportSecurityConfiguration(maxAge: 30, includeSubdomains: false) 185 | let factory = SecurityHeadersFactory().with(strictTransportSecurity: hstsConfig) 186 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 187 | 188 | XCTAssertEqual("max-age=30; preload", response.headers[.strictTransportSecurity].first) 189 | } 190 | 191 | func testHeadersWithHSTSwithPreloadFalse() throws { 192 | let hstsConfig = StrictTransportSecurityConfiguration(maxAge: 30, preload: false) 193 | let factory = SecurityHeadersFactory().with(strictTransportSecurity: hstsConfig) 194 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 195 | 196 | XCTAssertEqual("max-age=30; includeSubDomains;", response.headers[.strictTransportSecurity].first) 197 | } 198 | 199 | func testHeadersWithHSTSwithSubdomainAndPreloadFalse() throws { 200 | let hstsConfig = StrictTransportSecurityConfiguration(maxAge: 30, includeSubdomains: false, preload: false) 201 | let factory = SecurityHeadersFactory().with(strictTransportSecurity: hstsConfig) 202 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 203 | 204 | XCTAssertEqual("max-age=30;", response.headers[.strictTransportSecurity].first) 205 | } 206 | 207 | func testHeadersWithServerValue() throws { 208 | let serverConfig = ServerConfiguration(value: "brokenhands.io") 209 | let factory = SecurityHeadersFactory().with(server: serverConfig) 210 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 211 | 212 | XCTAssertEqual("brokenhands.io", response.headers[.server].first) 213 | } 214 | 215 | func testHeadersWithCSP() throws { 216 | let csp = "default-src 'none'; script-src https://static.brokenhands.io; style-src https://static.brokenhands.io; img-src https://static.brokenhands.io; font-src https://static.brokenhands.io; connect-src https://*.brokenhands.io; form-action 'self'; upgrade-insecure-requests; block-all-mixed-content; require-sri-for script style" 217 | let cspBuilder = ContentSecurityPolicy() 218 | .defaultSrc(sources: CSPKeywords.none) 219 | .scriptSrc(sources: "https://static.brokenhands.io") 220 | .styleSrc(sources: "https://static.brokenhands.io") 221 | .imgSrc(sources: "https://static.brokenhands.io") 222 | .fontSrc(sources: "https://static.brokenhands.io") 223 | .connectSrc(sources: "https://*.brokenhands.io") 224 | .formAction(sources: CSPKeywords.`self`) 225 | .upgradeInsecureRequests() 226 | .blockAllMixedContent() 227 | .requireSriFor(values: "script", "style") 228 | let cspConfig = ContentSecurityPolicyConfiguration(value: cspBuilder) 229 | let factory = SecurityHeadersFactory().with(contentSecurityPolicy: cspConfig) 230 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 231 | 232 | XCTAssertEqual(csp, response.headers[.contentSecurityPolicy].first) 233 | } 234 | 235 | func testNonVariadicHeadersWithCSP() throws { 236 | let csp = "default-src 'none'; script-src https://static.brokenhands.io; style-src https://static.brokenhands.io; img-src https://static.brokenhands.io; font-src https://static.brokenhands.io; connect-src https://*.brokenhands.io; form-action 'self'; upgrade-insecure-requests; block-all-mixed-content; require-sri-for script style" 237 | let cspBuilder = ContentSecurityPolicy() 238 | .defaultSrc(sources: CSPKeywords.none) 239 | .scriptSrc(sources: ["https://static.brokenhands.io"]) 240 | .styleSrc(sources: ["https://static.brokenhands.io"]) 241 | .imgSrc(sources: ["https://static.brokenhands.io"]) 242 | .fontSrc(sources: ["https://static.brokenhands.io"]) 243 | .connectSrc(sources: ["https://*.brokenhands.io"]) 244 | .formAction(sources: CSPKeywords.`self`) 245 | .upgradeInsecureRequests() 246 | .blockAllMixedContent() 247 | .requireSriFor(values: "script", "style") 248 | let cspConfig = ContentSecurityPolicyConfiguration(value: cspBuilder) 249 | let factory = SecurityHeadersFactory().with(contentSecurityPolicy: cspConfig) 250 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 251 | 252 | XCTAssertEqual(csp, response.headers[.contentSecurityPolicy].first) 253 | } 254 | 255 | func testHeadersWithStringCSP() throws { 256 | let csp = "default-src 'none'; script-src https://static.brokenhands.io; style-src https://static.brokenhands.io; img-src https://static.brokenhands.io; font-src https://static.brokenhands.io; connect-src https://*.brokenhands.io; form-action 'self'; upgrade-insecure-requests; block-all-mixed-content; require-sri-for script style" 257 | let cspConfig = ContentSecurityPolicyConfiguration(value: csp) 258 | let factory = SecurityHeadersFactory().with(contentSecurityPolicy: cspConfig) 259 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 260 | 261 | XCTAssertEqual(csp, response.headers[.contentSecurityPolicy].first) 262 | } 263 | 264 | func testHeadersWithSetCSP() throws { 265 | let csp = "default-src 'none'; script-src https://static.brokenhands.io; style-src https://static.brokenhands.io; img-src https://static.brokenhands.io; font-src https://static.brokenhands.io; connect-src https://*.brokenhands.io; form-action 'self'; upgrade-insecure-requests; block-all-mixed-content; require-sri-for script style" 266 | let cspBuilder = ContentSecurityPolicy().set(value: csp) 267 | let cspConfig = ContentSecurityPolicyConfiguration(value: cspBuilder) 268 | let factory = SecurityHeadersFactory().with(contentSecurityPolicy: cspConfig) 269 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 270 | 271 | XCTAssertEqual(csp, response.headers[.contentSecurityPolicy].first) 272 | } 273 | 274 | func testHeadersWithReportToCSP() throws { 275 | let reportToEndpoint = CSPReportToEndpoint(url: "https://csp-report.brokenhands.io/csp-reports") 276 | let reportToValue = CSPReportTo(group: "vapor-csp", max_age: 10886400, endpoints: [reportToEndpoint], include_subdomains: true) 277 | let cspValue = ContentSecurityPolicy() 278 | .defaultSrc(sources: CSPKeywords.none) 279 | .scriptSrc(sources: "https://static.brokenhands.io") 280 | .reportTo(reportToObject: reportToValue) 281 | let cspConfig = ContentSecurityPolicyConfiguration(value: cspValue) 282 | let factory = SecurityHeadersFactory().with(contentSecurityPolicy: cspConfig) 283 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 284 | guard let cspResponseHeader = response.headers[.contentSecurityPolicy].first else { 285 | XCTFail("Expected a CSP Response Header") 286 | return 287 | } 288 | let replacedCSPHeader = cspResponseHeader.replacingOccurrences(of: "default-src 'none'; script-src https://static.brokenhands.io; report-to", with: "") 289 | guard let reportToJson = replacedCSPHeader.data(using: .utf8) else { 290 | XCTFail("Expected String CSP Response Header") 291 | return 292 | } 293 | let decoder = JSONDecoder() 294 | guard let reportToData = try? decoder.decode(CSPReportTo.self, from: reportToJson) else { 295 | XCTFail("Expected JSON CSP Response Header") 296 | return 297 | } 298 | 299 | XCTAssertEqual(reportToValue, reportToData) 300 | } 301 | 302 | func testHeadersWithExhaustiveCSP() throws { 303 | let csp = "base-uri 'self'; frame-ancestors 'none'; frame-src 'self'; manifest-src https://brokenhands.io; object-src 'self'; plugin-types application/pdf; report-uri https://csp-report.brokenhands.io; sandbox allow-forms allow-scripts; worker-src https://brokenhands.io; media-src https://brokenhands.io" 304 | let cspBuilder = ContentSecurityPolicy() 305 | .baseUri(sources: CSPKeywords.`self`) 306 | .frameAncestors(sources: CSPKeywords.none) 307 | .frameSrc(sources: CSPKeywords.`self`) 308 | .manifestSrc(sources: "https://brokenhands.io") 309 | .objectSrc(sources: CSPKeywords.`self`) 310 | .pluginTypes(types: "application/pdf") 311 | .reportUri(uri: "https://csp-report.brokenhands.io") 312 | .sandbox(values: "allow-forms", "allow-scripts") 313 | .workerSrc(sources: "https://brokenhands.io") 314 | .mediaSrc(sources: "https://brokenhands.io") 315 | let cspConfig = ContentSecurityPolicyConfiguration(value: cspBuilder) 316 | let factory = SecurityHeadersFactory().with(contentSecurityPolicy: cspConfig) 317 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 318 | 319 | XCTAssertEqual(csp, response.headers[.contentSecurityPolicy].first) 320 | } 321 | 322 | func testNonVariadicHeadersWithExhaustiveCSP() throws { 323 | let csp = "base-uri 'self'; frame-ancestors 'none'; frame-src 'self'; manifest-src https://brokenhands.io; object-src 'self'; plugin-types application/pdf; report-uri https://csp-report.brokenhands.io; sandbox allow-forms allow-scripts; worker-src https://brokenhands.io; media-src https://brokenhands.io" 324 | let cspBuilder = ContentSecurityPolicy() 325 | .baseUri(sources: [CSPKeywords.`self`]) 326 | .frameAncestors(sources: [CSPKeywords.none]) 327 | .frameSrc(sources: [CSPKeywords.`self`]) 328 | .manifestSrc(sources: ["https://brokenhands.io"]) 329 | .objectSrc(sources: [CSPKeywords.`self`]) 330 | .pluginTypes(types: ["application/pdf"]) 331 | .reportUri(uri: "https://csp-report.brokenhands.io") 332 | .sandbox(values: ["allow-forms", "allow-scripts"]) 333 | .workerSrc(sources: ["https://brokenhands.io"]) 334 | .mediaSrc(sources: ["https://brokenhands.io"]) 335 | let cspConfig = ContentSecurityPolicyConfiguration(value: cspBuilder) 336 | let factory = SecurityHeadersFactory().with(contentSecurityPolicy: cspConfig) 337 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 338 | 339 | XCTAssertEqual(csp, response.headers[.contentSecurityPolicy].first) 340 | } 341 | 342 | func testCombineVariadicNonVariadicHeadersWithExhaustiveCSP() throws { 343 | let csp = "base-uri 'self'; frame-ancestors 'none'; frame-src 'self'; manifest-src https://brokenhands.io; object-src 'self'; plugin-types application/pdf; report-uri https://csp-report.brokenhands.io; sandbox allow-forms allow-scripts; worker-src https://brokenhands.io; media-src https://brokenhands.io" 344 | let cspBuilder = ContentSecurityPolicy() 345 | .baseUri(sources: CSPKeywords.`self`) 346 | .frameAncestors(sources: [CSPKeywords.none]) 347 | .frameSrc(sources: [CSPKeywords.`self`]) 348 | .manifestSrc(sources: ["https://brokenhands.io"]) 349 | .objectSrc(sources: CSPKeywords.`self`) 350 | .pluginTypes(types: ["application/pdf"]) 351 | .reportUri(uri: "https://csp-report.brokenhands.io") 352 | .sandbox(values: ["allow-forms", "allow-scripts"]) 353 | .workerSrc(sources: "https://brokenhands.io") 354 | .mediaSrc(sources: ["https://brokenhands.io"]) 355 | let cspConfig = ContentSecurityPolicyConfiguration(value: cspBuilder) 356 | let factory = SecurityHeadersFactory().with(contentSecurityPolicy: cspConfig) 357 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 358 | 359 | XCTAssertEqual(csp, response.headers[.contentSecurityPolicy].first) 360 | } 361 | 362 | func testHeadersWithReportOnlyCSP() throws { 363 | let csp = "default-src https:; report-uri https://csp-report.brokenhands.io" 364 | let cspConfig = ContentSecurityPolicyReportOnlyConfiguration(value: csp) 365 | let factory = SecurityHeadersFactory().with(contentSecurityPolicyReportOnly: cspConfig) 366 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 367 | 368 | XCTAssertEqual(csp, response.headers[.contentSecurityPolicyReportOnly].first) 369 | } 370 | 371 | func testHeadersWithReferrerPolicyEmpty() throws { 372 | let expected = "" 373 | let referrerConfig = ReferrerPolicyConfiguration(.empty) 374 | let factory = SecurityHeadersFactory().with(referrerPolicy: referrerConfig) 375 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 376 | XCTAssertEqual(expected, response.headers[.referrerPolicy].first) 377 | } 378 | 379 | func testHeadersWithReferrerPolicyNoReferrer() throws { 380 | let expected = "no-referrer" 381 | let referrerConfig = ReferrerPolicyConfiguration(.noReferrer) 382 | let factory = SecurityHeadersFactory().with(referrerPolicy: referrerConfig) 383 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 384 | XCTAssertEqual(expected, response.headers[.referrerPolicy].first) 385 | } 386 | 387 | func testHeadersWithReferrerPolicyNoReferrerWhenDowngrade() throws { 388 | let expected = "no-referrer-when-downgrade" 389 | let referrerConfig = ReferrerPolicyConfiguration(.noReferrerWhenDowngrade) 390 | let factory = SecurityHeadersFactory().with(referrerPolicy: referrerConfig) 391 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 392 | XCTAssertEqual(expected, response.headers[.referrerPolicy].first) 393 | } 394 | 395 | func testHeadersWithReferrerPolicySameOrigin() throws { 396 | let expected = "same-origin" 397 | let referrerConfig = ReferrerPolicyConfiguration(.sameOrigin) 398 | let factory = SecurityHeadersFactory().with(referrerPolicy: referrerConfig) 399 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 400 | XCTAssertEqual(expected, response.headers[.referrerPolicy].first) 401 | } 402 | 403 | func testHeadersWithReferrerPolicyOrigin() throws { 404 | let expected = "origin" 405 | let referrerConfig = ReferrerPolicyConfiguration(.origin) 406 | let factory = SecurityHeadersFactory().with(referrerPolicy: referrerConfig) 407 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 408 | XCTAssertEqual(expected, response.headers[.referrerPolicy].first) 409 | } 410 | 411 | func testHeadersWithReferrerPolicyStrictOrigin() throws { 412 | let expected = "strict-origin" 413 | let referrerConfig = ReferrerPolicyConfiguration(.strictOrigin) 414 | let factory = SecurityHeadersFactory().with(referrerPolicy: referrerConfig) 415 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 416 | XCTAssertEqual(expected, response.headers[.referrerPolicy].first) 417 | } 418 | 419 | func testHeadersWithReferrerPolicyOriginWhenCrossOrigin() throws { 420 | let expected = "origin-when-cross-origin" 421 | let referrerConfig = ReferrerPolicyConfiguration(.originWhenCrossOrigin) 422 | let factory = SecurityHeadersFactory().with(referrerPolicy: referrerConfig) 423 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 424 | XCTAssertEqual(expected, response.headers[.referrerPolicy].first) 425 | } 426 | 427 | func testHeadersWithReferrerPolicyStrictOriginWhenCrossOrigin() throws { 428 | let expected = "strict-origin-when-cross-origin" 429 | let referrerConfig = ReferrerPolicyConfiguration(.strictOriginWhenCrossOrigin) 430 | let factory = SecurityHeadersFactory().with(referrerPolicy: referrerConfig) 431 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 432 | XCTAssertEqual(expected, response.headers[.referrerPolicy].first) 433 | } 434 | 435 | func testHeadersWithReferrerPolicyUnsafeUrl() throws { 436 | let expected = "unsafe-url" 437 | let referrerConfig = ReferrerPolicyConfiguration(.unsafeUrl) 438 | let factory = SecurityHeadersFactory().with(referrerPolicy: referrerConfig) 439 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 440 | XCTAssertEqual(expected, response.headers[.referrerPolicy].first) 441 | } 442 | 443 | func testHeadersWithReferrerPolicyFallbacks() throws { 444 | let expected = "no-referrer, strict-origin-when-cross-origin" 445 | let referrerConfig = ReferrerPolicyConfiguration([.noReferrer, .strictOriginWhenCrossOrigin]) 446 | let factory = SecurityHeadersFactory().with(referrerPolicy: referrerConfig) 447 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 448 | XCTAssertEqual(expected, response.headers[.referrerPolicy].first) 449 | } 450 | 451 | func testApiPolicyWithAddedReferrerPolicy() throws { 452 | let expected = "strict-origin" 453 | let referrerConfig = ReferrerPolicyConfiguration(.strictOrigin) 454 | let factory = SecurityHeadersFactory.api().with(referrerPolicy: referrerConfig) 455 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory) 456 | XCTAssertEqual(expected, response.headers[.referrerPolicy].first) 457 | } 458 | 459 | func testCustomCSPOnSingleRoute() throws { 460 | let expectedCsp = "default-src 'none'; script-src https://static.brokenhands.io; style-src https://static.brokenhands.io; img-src https://static.brokenhands.io; font-src https://static.brokenhands.io; connect-src https://*.brokenhands.io; child-src 'self'; form-action 'self'; upgrade-insecure-requests; block-all-mixed-content; require-sri-for script style" 461 | let cspBuilder = ContentSecurityPolicy() 462 | .defaultSrc(sources: CSPKeywords.none) 463 | .scriptSrc(sources: "https://static.brokenhands.io") 464 | .styleSrc(sources: "https://static.brokenhands.io") 465 | .imgSrc(sources: "https://static.brokenhands.io") 466 | .fontSrc(sources: "https://static.brokenhands.io") 467 | .connectSrc(sources: "https://*.brokenhands.io") 468 | .childSrc(sources: CSPKeywords.`self`) 469 | .formAction(sources: CSPKeywords.`self`) 470 | .upgradeInsecureRequests() 471 | .blockAllMixedContent() 472 | .requireSriFor(values: "script", "style") 473 | let factory = SecurityHeadersFactory.api() 474 | let cspSettingRouteHandler: (Request) throws -> String = { req in 475 | req.contentSecurityPolicy = ContentSecurityPolicyConfiguration(value: cspBuilder) 476 | return "Different CSP!" 477 | } 478 | let response = try makeTestResponse(for: routeRequest, securityHeadersToAdd: factory, routeHandler: cspSettingRouteHandler) 479 | 480 | XCTAssertEqual(expectedCsp, response.headers[.contentSecurityPolicy].first) 481 | } 482 | 483 | func testNonVariadicCustomCSPOnSingleRoute() throws { 484 | let expectedCsp = "default-src 'none'; script-src https://static.brokenhands.io; style-src https://static.brokenhands.io; img-src https://static.brokenhands.io; font-src https://static.brokenhands.io; connect-src https://*.brokenhands.io; child-src 'self'; form-action 'self'; upgrade-insecure-requests; block-all-mixed-content; require-sri-for script style" 485 | let cspBuilder = ContentSecurityPolicy() 486 | .defaultSrc(sources: [CSPKeywords.none]) 487 | .scriptSrc(sources: ["https://static.brokenhands.io"]) 488 | .styleSrc(sources: ["https://static.brokenhands.io"]) 489 | .imgSrc(sources: ["https://static.brokenhands.io"]) 490 | .fontSrc(sources: ["https://static.brokenhands.io"]) 491 | .connectSrc(sources: ["https://*.brokenhands.io"]) 492 | .childSrc(sources: [CSPKeywords.`self`]) 493 | .formAction(sources: [CSPKeywords.`self`]) 494 | .upgradeInsecureRequests() 495 | .blockAllMixedContent() 496 | .requireSriFor(values: ["script", "style"]) 497 | let factory = SecurityHeadersFactory.api() 498 | let cspSettingRouteHandler: (Request) throws -> String = { req in 499 | req.contentSecurityPolicy = ContentSecurityPolicyConfiguration(value: cspBuilder) 500 | return "Different CSP!" 501 | } 502 | let response = try makeTestResponse(for: routeRequest, securityHeadersToAdd: factory, routeHandler: cspSettingRouteHandler) 503 | 504 | XCTAssertEqual(expectedCsp, response.headers[.contentSecurityPolicy].first) 505 | } 506 | 507 | func testCustomCSPDoesntAffectSecondRoute() throws { 508 | let customCSP = ContentSecurityPolicy() 509 | .defaultSrc(sources: CSPKeywords.none) 510 | .scriptSrc(sources: "https://static.brokenhands.io") 511 | .styleSrc(sources: "https://static.brokenhands.io") 512 | .imgSrc(sources: "https://static.brokenhands.io") 513 | .fontSrc(sources: "https://static.brokenhands.io") 514 | .connectSrc(sources: "https://*.brokenhands.io") 515 | .formAction(sources: CSPKeywords.`self`) 516 | .upgradeInsecureRequests() 517 | .blockAllMixedContent() 518 | .requireSriFor(values: "script", "style") 519 | let factory = SecurityHeadersFactory.api() 520 | let cspSettingRouteHandler: (Request) throws -> String = { req in 521 | req.contentSecurityPolicy = ContentSecurityPolicyConfiguration(value: customCSP) 522 | return "Different CSP!" 523 | } 524 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory, routeHandler: cspSettingRouteHandler, initialRequest: routeRequest) 525 | let expectedCSPHeaderValue = "default-src 'none'" 526 | 527 | XCTAssertEqual(expectedCSPHeaderValue, response.headers[.contentSecurityPolicy].first) 528 | } 529 | 530 | func testNonVariadicCustomCSPDoesntAffectSecondRoute() throws { 531 | let customCSP = ContentSecurityPolicy() 532 | .defaultSrc(sources: [CSPKeywords.none]) 533 | .scriptSrc(sources: ["https://static.brokenhands.io"]) 534 | .styleSrc(sources: ["https://static.brokenhands.io"]) 535 | .imgSrc(sources: ["https://static.brokenhands.io"]) 536 | .fontSrc(sources: ["https://static.brokenhands.io"]) 537 | .connectSrc(sources: ["https://*.brokenhands.io"]) 538 | .formAction(sources: [CSPKeywords.`self`]) 539 | .upgradeInsecureRequests() 540 | .blockAllMixedContent() 541 | .requireSriFor(values: ["script", "style"]) 542 | let factory = SecurityHeadersFactory.api() 543 | let cspSettingRouteHandler: (Request) throws -> String = { req in 544 | req.contentSecurityPolicy = ContentSecurityPolicyConfiguration(value: customCSP) 545 | return "Different CSP!" 546 | } 547 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory, routeHandler: cspSettingRouteHandler, initialRequest: routeRequest) 548 | let expectedCSPHeaderValue = "default-src 'none'" 549 | 550 | XCTAssertEqual(expectedCSPHeaderValue, response.headers[.contentSecurityPolicy].first) 551 | } 552 | 553 | func testDifferentRequestReturnsDefaultCSPWhenSettingCustomCSPOnRoute() throws { 554 | let differentCsp = ContentSecurityPolicy() 555 | .defaultSrc(sources: CSPKeywords.none) 556 | .scriptSrc(sources: "test") 557 | let factory = SecurityHeadersFactory.api() 558 | let cspSettingRouteHandler: (Request) throws -> String = { req in 559 | req.contentSecurityPolicy = ContentSecurityPolicyConfiguration(value: differentCsp) 560 | return "Different CSP!" 561 | } 562 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory, routeHandler: cspSettingRouteHandler) 563 | 564 | XCTAssertEqual("default-src 'none'", response.headers[.contentSecurityPolicy].first) 565 | } 566 | 567 | func testNonVariadicDifferentRequestReturnsDefaultCSPWhenSettingCustomCSPOnRoute() throws { 568 | let differentCsp = ContentSecurityPolicy() 569 | .defaultSrc(sources: [CSPKeywords.none]) 570 | .scriptSrc(sources: ["test"]) 571 | let factory = SecurityHeadersFactory.api() 572 | let cspSettingRouteHandler: (Request) throws -> String = { req in 573 | req.contentSecurityPolicy = ContentSecurityPolicyConfiguration(value: differentCsp) 574 | return "Different CSP!" 575 | } 576 | let response = try makeTestResponse(for: request, securityHeadersToAdd: factory, routeHandler: cspSettingRouteHandler) 577 | 578 | XCTAssertEqual("default-src 'none'", response.headers[.contentSecurityPolicy].first) 579 | } 580 | 581 | func testAbortMiddleware() throws { 582 | let expectedXCTOHeaderValue = "nosniff" 583 | let expectedCSPHeaderValue = "default-src 'none'" 584 | let expectedXFOHeaderValue = "DENY" 585 | let expectedXSSProtectionHeaderValue = "0" 586 | 587 | let response = try makeTestResponse(for: abortRequest, securityHeadersToAdd: SecurityHeadersFactory.api()) 588 | 589 | XCTAssertEqual(expectedXCTOHeaderValue, response.headers[.xContentTypeOptions].first) 590 | XCTAssertEqual(expectedCSPHeaderValue, response.headers[.contentSecurityPolicy].first) 591 | XCTAssertEqual(expectedXFOHeaderValue, response.headers[.xFrameOptions].first) 592 | XCTAssertEqual(expectedXSSProtectionHeaderValue, response.headers[.xssProtection].first) 593 | } 594 | 595 | func testStubFileMiddleware() throws { 596 | let expectedXCTOHeaderValue = "nosniff" 597 | let expectedCSPHeaderValue = "default-src 'none'" 598 | let expectedXFOHeaderValue = "DENY" 599 | let expectedXSSProtectionHeaderValue = "0" 600 | let response = try makeTestResponse(for: fileRequest, securityHeadersToAdd: SecurityHeadersFactory.api(), fileMiddleware: StubFileMiddleware()) 601 | 602 | XCTAssertEqual("Hello World!", String(data: response.body.data!, encoding: String.Encoding.utf8)) 603 | XCTAssertEqual(expectedXCTOHeaderValue, response.headers[.xContentTypeOptions].first) 604 | XCTAssertEqual(expectedCSPHeaderValue, response.headers[.contentSecurityPolicy].first) 605 | XCTAssertEqual(expectedXFOHeaderValue, response.headers[.xFrameOptions].first) 606 | XCTAssertEqual(expectedXSSProtectionHeaderValue, response.headers[.xssProtection].first) 607 | } 608 | 609 | func testStubFileMiddlewareDifferentRequestReturnsDefaultCSPWhenSettingCustomCSPOnRoute() throws { 610 | let expectedXCTOHeaderValue = "nosniff" 611 | let expectedCSPHeaderValue = "default-src 'none'; script-src test" 612 | let csp = ContentSecurityPolicy() 613 | .defaultSrc(sources: CSPKeywords.none) 614 | .scriptSrc(sources: "test") 615 | let expectedXFOHeaderValue = "DENY" 616 | let expectedXSSProtectionHeaderValue = "0" 617 | 618 | let response = try makeTestResponse(for: fileRequest, securityHeadersToAdd: SecurityHeadersFactory.api(), fileMiddleware: StubFileMiddleware(cspConfig: ContentSecurityPolicyConfiguration(value: csp))) 619 | 620 | XCTAssertEqual("Hello World!", String(data: response.body.data!, encoding: String.Encoding.utf8)) 621 | XCTAssertEqual(expectedXCTOHeaderValue, response.headers[.xContentTypeOptions].first) 622 | XCTAssertEqual(expectedCSPHeaderValue, response.headers[.contentSecurityPolicy].first) 623 | XCTAssertEqual(expectedXFOHeaderValue, response.headers[.xFrameOptions].first) 624 | XCTAssertEqual(expectedXSSProtectionHeaderValue, response.headers[.xssProtection].first) 625 | } 626 | 627 | // MARK: - Private functions 628 | 629 | private func makeTestResponse(for request: Request, securityHeadersToAdd: SecurityHeadersFactory, routeHandler: ((Request) throws -> String)? = nil, fileMiddleware: StubFileMiddleware? = nil, initialRequest: Request? = nil) throws -> Response { 630 | 631 | application.middleware = Middlewares() 632 | application.middleware.use(securityHeadersToAdd.build()) 633 | application.middleware.use(ErrorMiddleware.default(environment: request.application.environment)) 634 | 635 | if let fileMiddleware = fileMiddleware { 636 | application.middleware.use(fileMiddleware) 637 | } 638 | 639 | application.routes.get("test") { req in 640 | return "TEST" 641 | } 642 | 643 | if let routeHandler = routeHandler { 644 | application.routes.get("route", use: routeHandler) 645 | } 646 | 647 | application.routes.get("abort") { req -> EventLoopFuture in 648 | throw Abort(.badRequest) 649 | } 650 | 651 | if let dummyRequest = initialRequest { 652 | _ = try application.responder.respond(to: dummyRequest).wait() 653 | } 654 | 655 | return try application.responder.respond(to: request).wait() 656 | } 657 | 658 | } 659 | 660 | struct ResponseData: Content { 661 | let string: String 662 | } 663 | -------------------------------------------------------------------------------- /Tests/VaporSecurityHeadersTests/RedirectionTest.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Vapor 4 | 5 | import VaporSecurityHeaders 6 | 7 | class RedirectionTest: XCTestCase { 8 | 9 | // MARK: - Properties 10 | 11 | private var application: Application! 12 | private var eventLoopGroup: EventLoopGroup! 13 | private var request: Request! 14 | 15 | override func setUp() { 16 | eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) 17 | application = Application(.testing, .shared(eventLoopGroup)) 18 | request = Request(application: application, method: .GET, on: eventLoopGroup.next()) 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | application.shutdown() 23 | try eventLoopGroup.syncShutdownGracefully() 24 | } 25 | 26 | func testWithRedirectionMiddleware() throws { 27 | let expectedRedirectStatus: HTTPStatus = HTTPResponseStatus(statusCode: 301, reasonPhrase: "Moved permanently") 28 | request.headers.add(name: .host, value: "localhost:8080") 29 | let responseRedirected = try makeTestResponse(for: request, withRedirection: true) 30 | XCTAssertEqual(expectedRedirectStatus, responseRedirected.status) 31 | } 32 | 33 | func testWithRedirectMiddlewareWithAllowedHost() throws { 34 | let expectedRedirectStatus: HTTPStatus = HTTPResponseStatus(statusCode: 301, reasonPhrase: "Moved permanently") 35 | request.headers.add(name: .host, value: "localhost:8080") 36 | let responseRedirected = try makeTestResponse(for: request, withRedirection: true, allowedHosts: ["localhost:8080", "example.com"]) 37 | XCTAssertEqual(expectedRedirectStatus, responseRedirected.status) 38 | } 39 | 40 | func testWithRedirectMiddlewareWithDisallowedHost() throws { 41 | let expectedOutcome: String = "Abort.400: Bad Request" 42 | do { 43 | request.headers.add(name: .host, value: "localhost:8080") 44 | _ = try makeTestResponse(for: request, withRedirection: true, allowedHosts: ["localhost:8081", "example.com"]) 45 | } catch (let error) { 46 | XCTAssertEqual(expectedOutcome, error.localizedDescription) 47 | } 48 | } 49 | 50 | func testWithoutRedirectionMiddleware() throws { 51 | let expectedNoRedirectStatus: HTTPStatus = HTTPResponseStatus(statusCode: 200, reasonPhrase: "Ok") 52 | request.headers.add(name: .host, value: "localhost:8080") 53 | let response = try makeTestResponse(for: request, withRedirection: false) 54 | XCTAssertEqual(expectedNoRedirectStatus, response.status) 55 | } 56 | 57 | func testOnDevelopmentEnvironment() throws { 58 | let expectedStatus: HTTPStatus = HTTPResponseStatus(statusCode: 200, reasonPhrase: "Ok") 59 | request.headers.add(name: .host, value: "localhost:8080") 60 | let response = try makeTestResponse(for: request, withRedirection: true, environment: .development) 61 | XCTAssertEqual(expectedStatus, response.status) 62 | } 63 | 64 | func testWithoutHost() throws { 65 | let expectedOutcome: String = "Abort.400: Bad Request" 66 | do { 67 | _ = try makeTestResponse(for: request, withRedirection: true) 68 | } catch (let error) { 69 | XCTAssertEqual(expectedOutcome, error.localizedDescription) 70 | } 71 | } 72 | 73 | func testWithProtoSet() throws { 74 | let expectedStatus: HTTPStatus = HTTPResponseStatus(statusCode: 200, reasonPhrase: "Ok") 75 | request.headers.add(name: .xForwardedProto, value: "https") 76 | let response = try makeTestResponse(for: request, withRedirection: true) 77 | XCTAssertEqual(expectedStatus, response.status) 78 | } 79 | 80 | private func makeTestResponse(for request: Request, withRedirection: Bool, environment: Environment? = nil, allowedHosts: [String]? = nil) throws -> Response { 81 | application.middleware = Middlewares() 82 | if let environment = environment { 83 | application.environment = environment 84 | } 85 | if withRedirection == true { 86 | application.middleware.use(HTTPSRedirectMiddleware(allowedHosts: allowedHosts)) 87 | } 88 | try routes(application) 89 | return try application.responder.respond(to: request).wait() 90 | } 91 | 92 | func routes(_ app: Application) throws { 93 | try app.register(collection: RouteController()) 94 | } 95 | 96 | struct RouteController: RouteCollection { 97 | func boot(routes: RoutesBuilder) throws { 98 | routes.get(use: testing) 99 | } 100 | func testing(req: Request) throws -> String { 101 | return "Test" 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: "0...100" 3 | ignore: 4 | - "Tests/" 5 | - ".build/" 6 | --------------------------------------------------------------------------------