├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── build.yml │ └── github-release.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-version ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── Guardfile ├── LICENSE ├── README.md ├── Rakefile ├── docs ├── cookies.md ├── hashes.md ├── named_overrides_and_appends.md ├── per_action_configuration.md ├── sinatra.md ├── upgrading-to-3-0.md ├── upgrading-to-4-0.md ├── upgrading-to-5-0.md ├── upgrading-to-6-0.md └── upgrading-to-7-0.md ├── lib ├── secure_headers.rb ├── secure_headers │ ├── configuration.rb │ ├── hash_helper.rb │ ├── headers │ │ ├── clear_site_data.rb │ │ ├── content_security_policy.rb │ │ ├── content_security_policy_config.rb │ │ ├── cookie.rb │ │ ├── expect_certificate_transparency.rb │ │ ├── policy_management.rb │ │ ├── referrer_policy.rb │ │ ├── strict_transport_security.rb │ │ ├── x_content_type_options.rb │ │ ├── x_download_options.rb │ │ ├── x_frame_options.rb │ │ ├── x_permitted_cross_domain_policies.rb │ │ └── x_xss_protection.rb │ ├── middleware.rb │ ├── railtie.rb │ ├── utils │ │ └── cookies_config.rb │ ├── version.rb │ └── view_helper.rb └── tasks │ └── tasks.rake ├── secure_headers.gemspec └── spec ├── lib ├── secure_headers │ ├── configuration_spec.rb │ ├── headers │ │ ├── clear_site_data_spec.rb │ │ ├── content_security_policy_spec.rb │ │ ├── cookie_spec.rb │ │ ├── expect_certificate_transparency_spec.rb │ │ ├── policy_management_spec.rb │ │ ├── referrer_policy_spec.rb │ │ ├── strict_transport_security_spec.rb │ │ ├── x_content_type_options_spec.rb │ │ ├── x_download_options_spec.rb │ │ ├── x_frame_options_spec.rb │ │ ├── x_permitted_cross_domain_policies_spec.rb │ │ └── x_xss_protection_spec.rb │ ├── middleware_spec.rb │ └── view_helpers_spec.rb └── secure_headers_spec.rb └── spec_helper.rb /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Feature Requests 2 | 3 | ## Adding a new header 4 | 5 | Generally, adding a new header is always OK. 6 | 7 | * Is the header supported by any user agent? If so, which? 8 | * What does it do? 9 | * What are the valid values for the header? 10 | * Where does the specification live? 11 | 12 | ## Adding a new CSP directive 13 | 14 | * Is the directive supported by any user agent? If so, which? 15 | * What does it do? 16 | * What are the valid values for the directive? 17 | 18 | --- 19 | 20 | # Bugs 21 | 22 | Console errors and deprecation warnings are considered bugs that should be addressed with more precise UA sniffing. Bugs caused by incorrect or invalid UA sniffing are also bugs. 23 | 24 | ### Expected outcome 25 | 26 | Describe what you expected to happen 27 | 28 | 1. I configure CSP to do X 29 | 1. When I inspect the response headers, the CSP should have included X 30 | 31 | ### Actual outcome 32 | 33 | 1. The generated policy did not include X 34 | 35 | ### Config 36 | 37 | Please provide the configuration (`SecureHeaders::Configuration.default`) you are using including any overrides (`SecureHeaders::Configuration.override`). 38 | 39 | ### Generated headers 40 | 41 | Provide a sample response containing the headers 42 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## All PRs: 2 | 3 | * [ ] Has tests 4 | * [ ] Documentation updated 5 | 6 | ## Adding a new header 7 | 8 | Generally, adding a new header is always OK. 9 | 10 | * Is the header supported by any user agent? If so, which? 11 | * What does it do? 12 | * What are the valid values for the header? 13 | * Where does the specification live? 14 | 15 | ## Adding a new CSP directive 16 | 17 | * Is the directive supported by any user agent? If so, which? 18 | * What does it do? 19 | * What are the valid values for the directive? 20 | 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build + Test 2 | on: [pull_request, push] 3 | 4 | permissions: 5 | contents: read 6 | 7 | jobs: 8 | build: 9 | name: Build + Test 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | ruby: [ '2.6', '2.7', '3.0', '3.1', '3.2' ] 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Ruby ${{ matrix.ruby }} 18 | uses: ruby/setup-ruby@4a9ddd6f338a97768b8006bf671dfbad383215f4 #v1.190.0 tag 19 | with: 20 | ruby-version: ${{ matrix.ruby }} 21 | bundler-cache: true 22 | - name: Build and test with Rake 23 | run: | 24 | bundle exec rubocop 25 | bundle exec rspec spec 26 | -------------------------------------------------------------------------------- /.github/workflows/github-release.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | Publish: 10 | permissions: 11 | contents: write 12 | runs-on: ubuntu-latest 13 | if: startsWith(github.ref, 'refs/tags/v') 14 | steps: 15 | - name: Calculate release name 16 | run: | 17 | GITHUB_REF=${{ github.ref }} 18 | RELEASE_NAME=${GITHUB_REF#"refs/tags/"} 19 | echo "RELEASE_NAME=${RELEASE_NAME}" >> $GITHUB_ENV 20 | - name: Publish release 21 | uses: actions/create-release@v1 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | with: 25 | tag_name: ${{ github.ref }} 26 | release_name: ${{ env.RELEASE_NAME }} 27 | draft: false 28 | prerelease: false 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.DS_STORE 3 | *.rbc 4 | .bundle 5 | .config 6 | .yardoc 7 | *.log 8 | Gemfile.lock 9 | _yardoc 10 | coverage 11 | pkg 12 | rdoc 13 | spec/reports 14 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --order rand 2 | --warnings 3 | --format progress 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-github: 3 | - config/default.yml 4 | require: rubocop-performance 5 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.1.6 -------------------------------------------------------------------------------- /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 neil.matatall@gmail.com. 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 2 | 3 | [fork]: https://github.com/twitter/secureheaders/fork 4 | [pr]: https://github.com/twitter/secureheaders/compare 5 | [style]: https://github.com/styleguide/ruby 6 | [code-of-conduct]: CODE_OF_CONDUCT.md 7 | 8 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 9 | 10 | Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. 11 | 12 | ## Submitting a pull request 13 | 14 | 0. [Fork][fork] and clone the repository 15 | 0. Configure and install the dependencies: `bundle install` 16 | 0. Make sure the tests pass on your machine: `bundle exec rspec spec` 17 | 0. Create a new branch: `git checkout -b my-branch-name` 18 | 0. Make your change, add tests, and make sure the tests still pass and that no warnings are raised 19 | 0. Push to your fork and [submit a pull request][pr] 20 | 0. Pat your self on the back and wait for your pull request to be reviewed and merged. 21 | 22 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 23 | 24 | - Write tests. 25 | - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 26 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 27 | 28 | ## Releasing 29 | 30 | 0. Ensure CI is green 31 | 0. Pull the latest code 32 | 0. Increment the version 33 | 0. Run `gem build secure_headers.gemspec` 34 | 0. Bump the Gemfile and Gemfile.lock versions for an app which relies on this gem 35 | 0. Test behavior locally, branch deploy, whatever needs to happen 36 | 0. Run `bundle exec rake release` 37 | 38 | ## Resources 39 | 40 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 41 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 42 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source "https://rubygems.org" 3 | 4 | gemspec 5 | 6 | gem "benchmark-ips" 7 | 8 | group :test do 9 | gem "coveralls" 10 | gem "json" 11 | gem "pry-nav" 12 | gem "rack" 13 | gem "rspec" 14 | gem "rubocop" 15 | gem "rubocop-github" 16 | gem "rubocop-performance" 17 | gem "term-ansicolor" 18 | gem "tins" 19 | end 20 | 21 | group :guard do 22 | gem "growl" 23 | gem "guard-rspec", platforms: [:ruby] 24 | gem "rb-fsevent" 25 | gem "terminal-notifier-guard" 26 | end 27 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | guard :rspec, cmd: "bundle exec rspec", all_on_start: true, all_after_pass: true do 3 | require "guard/rspec/dsl" 4 | dsl = Guard::RSpec::Dsl.new(self) 5 | 6 | # RSpec files 7 | rspec = dsl.rspec 8 | watch(rspec.spec_helper) { rspec.spec_dir } 9 | watch(rspec.spec_support) { rspec.spec_dir } 10 | watch(rspec.spec_files) 11 | 12 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } 13 | end 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Twitter, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Secure Headers ![Build + Test](https://github.com/github/secure_headers/workflows/Build%20+%20Test/badge.svg?branch=main) 2 | 3 | **main branch represents 7.x line**. See the [upgrading to 4.x doc](docs/upgrading-to-4-0.md), [upgrading to 5.x doc](docs/upgrading-to-5-0.md), [upgrading to 6.x doc](docs/upgrading-to-6-0.md) or [upgrading to 7.x doc](docs/upgrading-to-7-0.md) for instructions on how to upgrade. Bug fixes should go in the `6.x` branch for now. 4 | 5 | The gem will automatically apply several headers that are related to security. This includes: 6 | - Content Security Policy (CSP) - Helps detect/prevent XSS, mixed-content, and other classes of attack. [CSP 2 Specification](https://www.w3.org/TR/CSP2/) 7 | - https://csp.withgoogle.com 8 | - https://csp.withgoogle.com/docs/strict-csp.html 9 | - https://csp-evaluator.withgoogle.com 10 | - HTTP Strict Transport Security (HSTS) - Ensures the browser never visits the http version of a website. Protects from SSLStrip/Firesheep attacks. [HSTS Specification](https://tools.ietf.org/html/rfc6797) 11 | - X-Frame-Options (XFO) - Prevents your content from being framed and potentially clickjacked. [X-Frame-Options Specification](https://tools.ietf.org/html/rfc7034) 12 | - X-XSS-Protection - [Cross site scripting heuristic filter for IE/Chrome](https://msdn.microsoft.com/en-us/library/dd565647\(v=vs.85\).aspx) 13 | - X-Content-Type-Options - [Prevent content type sniffing](https://msdn.microsoft.com/library/gg622941\(v=vs.85\).aspx) 14 | - x-download-options - [Prevent file downloads opening](https://msdn.microsoft.com/library/jj542450(v=vs.85).aspx) 15 | - x-permitted-cross-domain-policies - [Restrict Adobe Flash Player's access to data](https://www.adobe.com/devnet/adobe-media-server/articles/cross-domain-xml-for-streaming.html) 16 | - referrer-policy - [Referrer Policy draft](https://w3c.github.io/webappsec-referrer-policy/) 17 | - expect-ct - Only use certificates that are present in the certificate transparency logs. [expect-ct draft specification](https://datatracker.ietf.org/doc/draft-stark-expect-ct/). 18 | - clear-site-data - Clearing browser data for origin. [clear-site-data specification](https://w3c.github.io/webappsec-clear-site-data/). 19 | 20 | It can also mark all http cookies with the Secure, HttpOnly and SameSite attributes. This is on default but can be turned off by using `config.cookies = SecureHeaders::OPT_OUT`. 21 | 22 | `secure_headers` is a library with a global config, per request overrides, and rack middleware that enables you customize your application settings. 23 | 24 | ## Documentation 25 | 26 | - [Named overrides and appends](docs/named_overrides_and_appends.md) 27 | - [Per action configuration](docs/per_action_configuration.md) 28 | - [Cookies](docs/cookies.md) 29 | - [Hashes](docs/hashes.md) 30 | - [Sinatra Config](docs/sinatra.md) 31 | 32 | ## Configuration 33 | 34 | If you do not supply a `default` configuration, exceptions will be raised. If you would like to use a default configuration (which is fairly locked down), just call `SecureHeaders::Configuration.default` without any arguments or block. 35 | 36 | All `nil` values will fallback to their default values. `SecureHeaders::OPT_OUT` will disable the header entirely. 37 | 38 | **Word of caution:** The following is not a default configuration per se. It serves as a sample implementation of the configuration. You should read more about these headers and determine what is appropriate for your requirements. 39 | 40 | ```ruby 41 | SecureHeaders::Configuration.default do |config| 42 | config.cookies = { 43 | secure: true, # mark all cookies as "Secure" 44 | httponly: true, # mark all cookies as "HttpOnly" 45 | samesite: { 46 | lax: true # mark all cookies as SameSite=lax 47 | } 48 | } 49 | # Add "; preload" and submit the site to hstspreload.org for best protection. 50 | config.hsts = "max-age=#{1.week.to_i}" 51 | config.x_frame_options = "DENY" 52 | config.x_content_type_options = "nosniff" 53 | config.x_xss_protection = "1; mode=block" 54 | config.x_download_options = "noopen" 55 | config.x_permitted_cross_domain_policies = "none" 56 | config.referrer_policy = %w(origin-when-cross-origin strict-origin-when-cross-origin) 57 | config.csp = { 58 | # "meta" values. these will shape the header, but the values are not included in the header. 59 | preserve_schemes: true, # default: false. Schemes are removed from host sources to save bytes and discourage mixed content. 60 | disable_nonce_backwards_compatibility: true, # default: false. If false, `unsafe-inline` will be added automatically when using nonces. If true, it won't. See #403 for why you'd want this. 61 | 62 | # directive values: these values will directly translate into source directives 63 | default_src: %w('none'), 64 | base_uri: %w('self'), 65 | child_src: %w('self'), # if child-src isn't supported, the value for frame-src will be set. 66 | connect_src: %w(wss:), 67 | font_src: %w('self' data:), 68 | form_action: %w('self' github.com), 69 | frame_ancestors: %w('none'), 70 | img_src: %w(mycdn.com data:), 71 | manifest_src: %w('self'), 72 | media_src: %w(utoob.com), 73 | object_src: %w('self'), 74 | sandbox: true, # true and [] will set a maximally restrictive setting 75 | plugin_types: %w(application/x-shockwave-flash), 76 | script_src: %w('self'), 77 | script_src_elem: %w('self'), 78 | script_src_attr: %w('self'), 79 | style_src: %w('unsafe-inline'), 80 | style_src_elem: %w('unsafe-inline'), 81 | style_src_attr: %w('unsafe-inline'), 82 | worker_src: %w('self'), 83 | upgrade_insecure_requests: true, # see https://www.w3.org/TR/upgrade-insecure-requests/ 84 | report_uri: %w(https://report-uri.io/example-csp) 85 | } 86 | # This is available only from 3.5.0; use the `report_only: true` setting for 3.4.1 and below. 87 | config.csp_report_only = config.csp.merge({ 88 | img_src: %w(somewhereelse.com), 89 | report_uri: %w(https://report-uri.io/example-csp-report-only) 90 | }) 91 | end 92 | ``` 93 | 94 | ### Deprecated Configuration Values 95 | * `block_all_mixed_content` - this value is deprecated in favor of `upgrade_insecure_requests`. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/block-all-mixed-content for more information. 96 | 97 | ## Default values 98 | 99 | All headers except for PublicKeyPins and ClearSiteData have a default value. The default set of headers is: 100 | 101 | ``` 102 | content-security-policy: default-src 'self' https:; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'; script-src https:; style-src 'self' https: 'unsafe-inline' 103 | strict-transport-security: max-age=631138519 104 | x-content-type-options: nosniff 105 | x-download-options: noopen 106 | x-frame-options: sameorigin 107 | x-permitted-cross-domain-policies: none 108 | x-xss-protection: 0 109 | ``` 110 | 111 | ## API configurations 112 | 113 | Which headers you decide to use for API responses is entirely a personal choice. Things like X-Frame-Options seem to have no place in an API response and would be wasting bytes. While this is true, browsers can do funky things with non-html responses. At the minimum, we suggest CSP: 114 | 115 | ```ruby 116 | SecureHeaders::Configuration.override(:api) do |config| 117 | config.csp = { default_src: 'none' } 118 | config.hsts = SecureHeaders::OPT_OUT 119 | config.x_frame_options = SecureHeaders::OPT_OUT 120 | config.x_content_type_options = SecureHeaders::OPT_OUT 121 | config.x_xss_protection = SecureHeaders::OPT_OUT 122 | config.x_permitted_cross_domain_policies = SecureHeaders::OPT_OUT 123 | end 124 | ``` 125 | 126 | However, I would consider these headers anyways depending on your load and bandwidth requirements. 127 | 128 | ## Acknowledgements 129 | 130 | This project originated within the Security team at Twitter. An archived fork from the point of transition is here: https://github.com/twitter-archive/secure_headers. 131 | 132 | Contributors include: 133 | * Neil Matatall @oreoshake 134 | * Chris Aniszczyk 135 | * Artur Dryomov 136 | * Bjørn Mæland 137 | * Arthur Chiu 138 | * Jonathan Viney 139 | * Jeffrey Horn 140 | * David Collazo 141 | * Brendon Murphy 142 | * William Makley 143 | * Reed Loden 144 | * Noah Kantrowitz 145 | * Wyatt Anderson 146 | * Salimane Adjao Moustapha 147 | * Francois Chagnon 148 | * Jeff Hodges 149 | * Ian Melven 150 | * Darío Javier Cravero 151 | * Logan Hasson 152 | * Raul E Rangel 153 | * Steve Agalloco 154 | * Nate Collings 155 | * Josh Kalderimis 156 | * Alex Kwiatkowski 157 | * Julich Mera 158 | * Jesse Storimer 159 | * Tom Daniels 160 | * Kolja Dummann 161 | * Jean-Philippe Doyle 162 | * Blake Hitchcock 163 | * vanderhoorn 164 | * orthographic-pedant 165 | * Narsimham Chelluri 166 | 167 | If you've made a contribution and see your name missing from the list, make a PR and add it! 168 | 169 | ## Similar libraries 170 | 171 | * Rack [rack-secure_headers](https://github.com/frodsan/rack-secure_headers) 172 | * Node.js (express) [helmet](https://github.com/helmetjs/helmet) and [hood](https://github.com/seanmonstar/hood) 173 | * Node.js (hapi) [blankie](https://github.com/nlf/blankie) 174 | * ASP.NET - [NWebsec](https://github.com/NWebsec/NWebsec/wiki) 175 | * Python - [django-csp](https://github.com/mozilla/django-csp) + [commonware](https://github.com/jsocol/commonware/); [django-security](https://github.com/sdelements/django-security), [secure](https://github.com/TypeError/secure) 176 | * Go - [secureheader](https://github.com/kr/secureheader) 177 | * Elixir [secure_headers](https://github.com/anotherhale/secure_headers) 178 | * Dropwizard [dropwizard-web-security](https://github.com/palantir/dropwizard-web-security) 179 | * Ember.js [ember-cli-content-security-policy](https://github.com/rwjblue/ember-cli-content-security-policy/) 180 | * PHP [secure-headers](https://github.com/BePsvPT/secure-headers) 181 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | # frozen_string_literal: true 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | require "net/http" 6 | require "net/https" 7 | 8 | RSpec::Core::RakeTask.new 9 | 10 | begin 11 | require "rdoc/task" 12 | rescue LoadError 13 | require "rdoc/rdoc" 14 | require "rake/rdoctask" 15 | RDoc::Task = Rake::RDocTask 16 | end 17 | 18 | begin 19 | require "rubocop/rake_task" 20 | RuboCop::RakeTask.new 21 | rescue LoadError 22 | task(:rubocop) { $stderr.puts "RuboCop is disabled" } 23 | end 24 | 25 | RDoc::Task.new(:rdoc) do |rdoc| 26 | rdoc.rdoc_dir = "rdoc" 27 | rdoc.title = "SecureHeaders" 28 | rdoc.options << "--line-numbers" 29 | rdoc.rdoc_files.include("lib/**/*.rb") 30 | end 31 | 32 | task default: [:spec, :rubocop] 33 | -------------------------------------------------------------------------------- /docs/cookies.md: -------------------------------------------------------------------------------- 1 | ## Cookies 2 | 3 | SecureHeaders supports `Secure`, `HttpOnly` and [`SameSite`](https://tools.ietf.org/html/draft-west-first-party-cookies-07) cookies. These can be defined in the form of a boolean, or as a Hash for more refined configuration. 4 | 5 | __Note__: Regardless of the configuration specified, Secure cookies are only enabled for HTTPS requests. 6 | 7 | #### Defaults 8 | 9 | By default, all cookies will get both `Secure`, `HttpOnly`, and `SameSite=Lax`. 10 | 11 | ```ruby 12 | config.cookies = { 13 | secure: true, # defaults to true but will be a no op on non-HTTPS requests 14 | httponly: true, # defaults to true 15 | samesite: { # defaults to set `SameSite=Lax` 16 | lax: true 17 | } 18 | } 19 | ``` 20 | 21 | #### Boolean-based configuration 22 | 23 | Boolean-based configuration is intended to globally enable or disable a specific cookie attribute. *Note: As of 4.0, you must use OPT_OUT rather than false to opt out of the defaults.* 24 | 25 | ```ruby 26 | config.cookies = { 27 | secure: true, # mark all cookies as Secure 28 | httponly: SecureHeaders::OPT_OUT, # do not mark any cookies as HttpOnly 29 | } 30 | ``` 31 | 32 | #### Hash-based configuration 33 | 34 | Hash-based configuration allows for fine-grained control. 35 | 36 | ```ruby 37 | config.cookies = { 38 | secure: { except: ['_guest'] }, # mark all but the `_guest` cookie as Secure 39 | httponly: { only: ['_rails_session'] }, # only mark the `_rails_session` cookie as HttpOnly 40 | } 41 | ``` 42 | 43 | #### SameSite cookie configuration 44 | 45 | SameSite cookies permit either `Strict` or `Lax` enforcement mode options. 46 | 47 | ```ruby 48 | config.cookies = { 49 | samesite: { 50 | strict: true # mark all cookies as SameSite=Strict 51 | } 52 | } 53 | ``` 54 | 55 | `Strict`, `Lax`, and `None` enforcement modes can also be specified using a Hash. 56 | 57 | ```ruby 58 | config.cookies = { 59 | samesite: { 60 | strict: { only: ['session_id_duplicate'] }, 61 | lax: { only: ['_guest', '_rails_session', 'device_id'] }, 62 | none: { only: ['_tracking', 'saml_cookie', 'session_id'] }, 63 | } 64 | } 65 | ``` 66 | -------------------------------------------------------------------------------- /docs/hashes.md: -------------------------------------------------------------------------------- 1 | ## Hash 2 | 3 | `script`/`style-src` hashes can be used to whitelist inline content that is static. This has the benefit of allowing inline content without opening up the possibility of dynamic javascript like you would with a `nonce`. 4 | 5 | You can add hash sources directly to your policy : 6 | 7 | ```ruby 8 | ::SecureHeaders::Configuration.default do |config| 9 | config.csp = { 10 | default_src: %w('self') 11 | 12 | # this is a made up value but browsers will show the expected hash in the console. 13 | script_src: %w(sha256-123456) 14 | } 15 | end 16 | ``` 17 | 18 | You can also use the automated inline script detection/collection/computation of hash source values in your app. 19 | 20 | ```bash 21 | rake secure_headers:generate_hashes 22 | ``` 23 | 24 | This will generate a file (`config/secure_headers_generated_hashes.yml` by default, you can override by setting `ENV["secure_headers_generated_hashes_file"]`) containing a mapping of file names with the array of hash values found on that page. When ActionView renders a given file, we check if there are any known hashes for that given file. If so, they are added as values to the header. 25 | 26 | ```yaml 27 | --- 28 | scripts: 29 | app/views/asdfs/index.html.erb: 30 | - "'sha256-yktKiAsZWmc8WpOyhnmhQoDf9G2dAZvuBBC+V0LGQhg='" 31 | styles: 32 | app/views/asdfs/index.html.erb: 33 | - "'sha256-SLp6LO3rrKDJwsG9uJUxZapb4Wp2Zhj6Bu3l+d9rnAY='" 34 | - "'sha256-HSGHqlRoKmHAGTAJ2Rq0piXX4CnEbOl1ArNd6ejp2TE='" 35 | ``` 36 | 37 | ##### Helpers 38 | 39 | **This will not compute dynamic hashes** by design. The output of both helpers will be a plain `script`/`style` tag without modification and the known hashes for a given file will be added to `script-src`/`style-src` when `hashed_javascript_tag` and `hashed_style_tag` are used. You can use `raise_error_on_unrecognized_hash = true` to be extra paranoid that you have precomputed hash values for all of your inline content. By default, this will raise an error in non-production environments. 40 | 41 | ```erb 42 | <%= hashed_style_tag do %> 43 | body { 44 | background-color: black; 45 | } 46 | <% end %> 47 | 48 | <%= hashed_style_tag do %> 49 | body { 50 | font-size: 30px; 51 | font-color: green; 52 | } 53 | <% end %> 54 | 55 | <%= hashed_javascript_tag do %> 56 | console.log(1) 57 | <% end %> 58 | ``` 59 | 60 | ``` 61 | content-security-policy: ... 62 | script-src 'sha256-yktKiAsZWmc8WpOyhnmhQoDf9G2dAZvuBBC+V0LGQhg=' ... ; 63 | style-src 'sha256-SLp6LO3rrKDJwsG9uJUxZapb4Wp2Zhj6Bu3l+d9rnAY=' 'sha256-HSGHqlRoKmHAGTAJ2Rq0piXX4CnEbOl1ArNd6ejp2TE=' ...; 64 | ``` 65 | -------------------------------------------------------------------------------- /docs/named_overrides_and_appends.md: -------------------------------------------------------------------------------- 1 | ## Named Appends 2 | 3 | Named Appends are blocks of code that can be reused and composed during requests. e.g. If a certain partial is rendered conditionally, and the csp needs to be adjusted for that partial, you can create a named append for that situation. The value returned by the block will be passed into `append_content_security_policy_directives`. The current request object is passed as an argument to the block for even more flexibility. Reusing a configuration name is not allowed and will throw an exception. 4 | 5 | ```ruby 6 | def show 7 | if include_widget? 8 | @widget = widget.render 9 | use_content_security_policy_named_append(:widget_partial) 10 | end 11 | end 12 | 13 | 14 | SecureHeaders::Configuration.named_append(:widget_partial) do |request| 15 | SecureHeaders.override_x_frame_options(request, "DENY") 16 | if request.controller_instance.current_user.in_test_bucket? 17 | { child_src: %w(beta.thirdpartyhost.com) } 18 | else 19 | { child_src: %w(thirdpartyhost.com) } 20 | end 21 | end 22 | ``` 23 | 24 | You can use as many named appends as you would like per request, but be careful because order of inclusion matters. Consider the following: 25 | 26 | ```ruby 27 | SecureHeader::Configuration.default do |config| 28 | config.csp = { default_src: %w('self')} 29 | end 30 | 31 | SecureHeaders::Configuration.named_append(:A) do |request| 32 | { default_src: %w(myhost.com) } 33 | end 34 | 35 | SecureHeaders::Configuration.named_append(:B) do |request| 36 | { script_src: %w('unsafe-eval') } 37 | end 38 | ``` 39 | 40 | The following code will produce different policies due to the way policies are normalized (e.g. providing a previously undefined directive that inherits from `default-src`, removing host source values when `*` is provided. Removing `'none'` when additional values are present, etc.): 41 | 42 | ```ruby 43 | def index 44 | use_content_security_policy_named_append(:A) 45 | use_content_security_policy_named_append(:B) 46 | # produces default-src 'self' myhost.com; script-src 'self' myhost.com 'unsafe-eval'; 47 | end 48 | 49 | def show 50 | use_content_security_policy_named_append(:B) 51 | use_content_security_policy_named_append(:A) 52 | # produces default-src 'self' myhost.com; script-src 'self' 'unsafe-eval'; 53 | end 54 | ``` 55 | 56 | 57 | ## Named overrides 58 | 59 | Named overrides serve two purposes: 60 | 61 | * To be able to refer to a configuration by simple name. 62 | * By precomputing the headers for a named configuration, the headers generated once and reused over every request. 63 | 64 | To use a named override, drop a `SecureHeaders::Configuration.override` block **outside** of method definitions and then declare which named override you'd like to use. You can even override an override. 65 | 66 | ```ruby 67 | class ApplicationController < ActionController::Base 68 | SecureHeaders::Configuration.default do |config| 69 | config.csp = { 70 | default_src: %w('self'), 71 | script_src: %w(example.org) 72 | } 73 | end 74 | 75 | # override default configuration 76 | SecureHeaders::Configuration.override(:script_from_otherdomain_com) do |config| 77 | config.csp[:script_src] << "otherdomain.com" 78 | end 79 | end 80 | 81 | class MyController < ApplicationController 82 | def index 83 | # Produces default-src 'self'; script-src example.org otherdomain.com 84 | use_secure_headers_override(:script_from_otherdomain_com) 85 | end 86 | 87 | def show 88 | # Produces default-src 'self'; script-src example.org otherdomain.org evenanotherdomain.com 89 | use_secure_headers_override(:another_config) 90 | end 91 | end 92 | ``` 93 | 94 | Reusing a configuration name is not allowed and will throw an exception. 95 | 96 | By default, a no-op configuration is provided. No headers will be set when this default override is used. 97 | 98 | ```ruby 99 | class MyController < ApplicationController 100 | def index 101 | SecureHeaders.opt_out_of_all_protection(request) 102 | end 103 | end 104 | ``` 105 | -------------------------------------------------------------------------------- /docs/per_action_configuration.md: -------------------------------------------------------------------------------- 1 | ## Per-action configuration 2 | 3 | You can override the settings for a given action by producing a temporary override. Be aware that because of the dynamic nature of the value, the header values will be computed per request. 4 | 5 | ```ruby 6 | # Given a config of: 7 | ::SecureHeaders::Configuration.default do |config| 8 | config.csp = { 9 | default_src: %w('self'), 10 | script_src: %w('self') 11 | } 12 | end 13 | 14 | class MyController < ApplicationController 15 | def index 16 | # Append value to the source list, override 'none' values 17 | # Produces: default-src 'self'; script-src 'self' s3.amazonaws.com; object-src 'self' www.youtube.com 18 | append_content_security_policy_directives(script_src: %w(s3.amazonaws.com), object_src: %w('self' www.youtube.com)) 19 | 20 | # Overrides the previously set source list, override 'none' values 21 | # Produces: default-src 'self'; script-src s3.amazonaws.com; object-src 'self' 22 | override_content_security_policy_directives(script_src: %w(s3.amazonaws.com), object_src: %w('self')) 23 | 24 | # Global settings default to "sameorigin" 25 | override_x_frame_options("DENY") 26 | end 27 | ``` 28 | 29 | The following methods are available as controller instance methods. They are also available as class methods, but require you to pass in the `request` object. 30 | * `append_content_security_policy_directives(hash)`: appends each value to the corresponding CSP app-wide configuration. 31 | * `override_content_security_policy_directives(hash)`: merges the hash into the app-wide configuration, overwriting any previous config 32 | * `override_x_frame_options(value)`: sets the `X-Frame-Options header` to `value` 33 | 34 | ## Appending / overriding Content Security Policy 35 | 36 | When manipulating content security policy, there are a few things to consider. The default header value is `default-src https:` which corresponds to a default configuration of `{ default_src: %w(https:)}`. 37 | 38 | #### Append to the policy with a directive other than `default_src` 39 | 40 | The value of `default_src` is joined with the addition if the it is a [fetch directive](https://w3c.github.io/webappsec-csp/#directives-fetch). Note the `https:` is carried over from the `default-src` config. If you do not want this, use `override_content_security_policy_directives` instead. To illustrate: 41 | 42 | ```ruby 43 | ::SecureHeaders::Configuration.default do |config| 44 | config.csp = { 45 | default_src: %w('self') 46 | } 47 | end 48 | ``` 49 | 50 | Code | Result 51 | ------------- | ------------- 52 | `append_content_security_policy_directives(script_src: %w(mycdn.com))` | `default-src 'self'; script-src 'self' mycdn.com` 53 | `override_content_security_policy_directives(script_src: %w(mycdn.com))` | `default-src 'self'; script-src mycdn.com` 54 | 55 | #### Nonce 56 | 57 | You can use a view helper to automatically add nonces to script tags. Currently, using a nonce helper or calling `content_security_policy_nonce` will populate all configured CSP headers, including report-only and enforced policies. 58 | 59 | ```erb 60 | <%= nonced_javascript_tag do %> 61 | console.log("nonced!"); 62 | <% end %> 63 | 64 | <%= nonced_style_tag do %> 65 | body { 66 | background-color: black; 67 | } 68 | <% end %> 69 | 70 | <%= nonced_javascript_include_tag "include.js" %> 71 | 72 | <%= nonced_javascript_pack_tag "pack.js" %> 73 | 74 | <%= nonced_stylesheet_link_tag "link.css" %> 75 | 76 | <%= nonced_stylesheet_pack_tag "pack.css" %> 77 | ``` 78 | 79 | becomes: 80 | 81 | ```html 82 | 85 | 90 | ``` 91 | 92 | ``` 93 | 94 | content-security-policy: ... 95 | script-src 'nonce-/jRAxuLJsDXAxqhNBB7gg7h55KETtDQBXe4ZL+xIXwI=' ...; 96 | style-src 'nonce-/jRAxuLJsDXAxqhNBB7gg7h55KETtDQBXe4ZL+xIXwI=' ...; 97 | ``` 98 | 99 | `script`/`style-nonce` can be used to whitelist inline content. To do this, call the `content_security_policy_script_nonce` or `content_security_policy_style_nonce` then set the nonce attributes on the various tags. 100 | 101 | ```erb 102 | 105 | 106 | 109 | 110 | 113 | ``` 114 | 115 | ## Clearing browser cache 116 | 117 | You can clear the browser cache after the logout request by using the following. 118 | 119 | ``` ruby 120 | class ApplicationController < ActionController::Base 121 | # Configuration override to send the clear-site-data header. 122 | SecureHeaders::Configuration.override(:clear_browser_cache) do |config| 123 | config.clear_site_data = SecureHeaders::ClearSiteData::ALL_TYPES 124 | end 125 | 126 | 127 | # Clears the browser's cache for browsers supporting the clear-site-data 128 | # header. 129 | # 130 | # Returns nothing. 131 | def clear_browser_cache 132 | SecureHeaders.use_secure_headers_override(request, :clear_browser_cache) 133 | end 134 | end 135 | 136 | class SessionsController < ApplicationController 137 | after_action :clear_browser_cache, only: :destroy 138 | end 139 | ``` 140 | -------------------------------------------------------------------------------- /docs/sinatra.md: -------------------------------------------------------------------------------- 1 | ## Sinatra 2 | 3 | Here's an example using SecureHeaders for Sinatra applications: 4 | 5 | ```ruby 6 | require 'rubygems' 7 | require 'sinatra' 8 | require 'haml' 9 | require 'secure_headers' 10 | 11 | use SecureHeaders::Middleware 12 | 13 | SecureHeaders::Configuration.default do |config| 14 | ... 15 | end 16 | 17 | class Donkey < Sinatra::Application 18 | set :root, APP_ROOT 19 | 20 | get '/' do 21 | SecureHeaders.override_x_frame_options(request, SecureHeaders::OPT_OUT) 22 | haml :index 23 | end 24 | end 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/upgrading-to-3-0.md: -------------------------------------------------------------------------------- 1 | `secure_headers` 3.0 is a near-complete rewrite. It includes breaking changes and removes a lot of features that were either leftover from the days when the CSP standard was not fully adopted or were just downright confusing. 2 | 3 | Changes 4 | == 5 | 6 | | What | < = 2.x | >= 3.0 | 7 | | ---------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 8 | | Global configuration | `SecureHeaders::Configuration.configure` block | `SecureHeaders::Configuration.default` block | 9 | | All headers besides HPKP and CSP | Accept hashes as config values | Must be strings (validated during configuration) | 10 | | CSP directive values | Accepted space delimited strings OR arrays of strings | Must be arrays of strings | 11 | | CSP Nonce values in views | `@content_security_policy_nonce` | `content_security_policy_nonce(:script)` or `content_security_policy_nonce(:style)` | 12 | | nonce is no longer a source expression | `config.csp = "'self' 'nonce'"` | Remove `'nonce'` from source expression and use [nonce helpers](https://github.com/twitter/secureheaders#nonce). | 13 | | `self`/`none` source expressions | Could be `self` / `none` / `'self'` / `'none'` | Must be `'self'` or `'none'` | 14 | | `inline` / `eval` source expressions | Could be `inline`, `eval`, `'unsafe-inline'`, or `'unsafe-eval'` | Must be `'unsafe-eval'` or `'unsafe-inline'` | 15 | | Per-action configuration | Override [`def secure_header_options_for(header, options)`](https://github.com/twitter/secureheaders/commit/bb9ebc6c12a677aad29af8e0f08ffd1def56efec#diff-04c6e90faac2675aa89e2176d2eec7d8R111) | Use [named overrides](https://github.com/twitter/secureheaders#named-overrides) or [per-action helpers](https://github.com/twitter/secureheaders#per-action-configuration) | 16 | | CSP/HPKP use `report_only` config that defaults to false | `enforce: false` | `report_only: false` | 17 | | Schemes in source expressions | Schemes were not stripped | Schemes are stripped by default to discourage mixed content. Setting `preserve_schemes: true` will revert to previous behavior | 18 | | Opting out of default configuration | `skip_before_filter :set_x_download_options_header` or `config.x_download_options = false` | Within default block: `config.x_download_options = SecureHeaders::OPT_OUT` | 19 | 20 | Migrating to 3.x from <= 2.x 21 | == 22 | 23 | 1. Convert all headers except for CSP/HPKP using hashes to string values. The values are validated at runtime and will provide guidance on misconfigured headers. 24 | 1. Convert all instances of `self`/`none`/`eval`/`inline` to the corresponding values in the above table. 25 | 1. Convert all CSP space-delimited directives to an array of strings. 26 | 1. Convert all `enforce: true|false` to `report_only: true|false`. 27 | 1. Remove `ensure_security_headers` from controllers (3.x uses a middleware instead). 28 | 29 | Everything is terrible, why should I upgrade? 30 | == 31 | 32 | `secure_headers` <= 2.x built every header per request using a series of automatically included `before_filters`. This is horribly inefficient because: 33 | 34 | 1. `before_filters` are slow and adding 8 per request isn't great 35 | 1. We are rebuilding strings that may never change for every request 36 | 1. Errors in the request may mean that the headers never get set in the first place 37 | 38 | `secure_headers` 3.x sets headers in rack middleware that runs once per request and uses configuration values passed via `request.env`. This is much more efficient and somewhat guarantees that headers will always be set. **The values for the headers are cached and reused per request**. 39 | 40 | Also, there is a more flexible API for customizing content security policies / X-Frame-Options. In practice, none of the other headers need granular controls. One way of customizing headers per request is to use the helper methods. The only downside of this technique is that headers will be computed from scratch. 41 | 42 | See the [README](README.md) for more information. 43 | -------------------------------------------------------------------------------- /docs/upgrading-to-4-0.md: -------------------------------------------------------------------------------- 1 | ## script_src must be set 2 | 3 | Not setting a `script_src` value means your policy falls back to whatever `default_src` (also required) is set to. This can be very dangerous and indicates the policy is too loose. 4 | 5 | However, sometimes you really don't need a `script-src` e.g. API responses (`default-src 'none'`) so you can set `script_src: SecureHeaders::OPT_OUT` to work around this. 6 | 7 | ## Default Content Security Policy 8 | 9 | The default CSP has changed to be more universal without sacrificing too much security. 10 | 11 | * Flash/Java disabled by default 12 | * `img-src` allows data: images and favicons (among others) 13 | * `style-src` allows inline CSS by default (most find it impossible/impractical to remove inline content today) 14 | * `form-action` (not governed by `default-src`, practically treated as `*`) is set to `'self'` 15 | 16 | Previously, the default CSP was: 17 | 18 | `content-security-policy: default-src 'self'` 19 | 20 | The new default policy is: 21 | 22 | `default-src https:; form-action 'self'; img-src https: data: 'self'; object-src 'none'; script-src https:; style-src 'self' 'unsafe-inline' https:` 23 | 24 | ## CSP configuration 25 | 26 | * Setting `report_only: true` in a CSP config will raise an error. Instead, set `csp_report_only`. 27 | * Setting `frame_src` and `child_src` when values don't match will raise an error. Just use `frame_src`. 28 | 29 | ## config.secure_cookies removed 30 | 31 | Use `config.cookies` instead. 32 | 33 | ## Supported ruby versions 34 | 35 | We've dropped support for ruby versions <= 2.2. Sorry. 36 | -------------------------------------------------------------------------------- /docs/upgrading-to-5-0.md: -------------------------------------------------------------------------------- 1 | ## All cookies default to secure/httponly/SameSite=Lax 2 | 3 | By default, *all* cookies will be marked as `SameSite=lax`,`secure`, and `httponly`. To opt-out, supply `SecureHeaders::OPT_OUT` as the value for `SecureHeaders.cookies` or the individual configs. Setting these values to `false` will raise an error. 4 | 5 | ```ruby 6 | # specific opt outs 7 | config.cookies = { 8 | secure: SecureHeaders::OPT_OUT, 9 | httponly: SecureHeaders::OPT_OUT, 10 | samesite: SecureHeaders::OPT_OUT, 11 | } 12 | 13 | # nuclear option, just make things work again 14 | config.cookies = SecureHeaders::OPT_OUT 15 | ``` 16 | -------------------------------------------------------------------------------- /docs/upgrading-to-6-0.md: -------------------------------------------------------------------------------- 1 | ## Named overrides are now dynamically applied 2 | 3 | The original implementation of name overrides worked by making a copy of the default policy, applying the overrides, and storing the result for later use. But, this lead to unexpected results if named overrides were combined with a dynamic policy change. If a change was made to the default configuration during a request, followed by a named override, the dynamic changes would be lost. To keep things consistent named overrides have been rewritten to work the same as named appends in that they always operate on the configuration for the current request. As an example: 4 | 5 | ```ruby 6 | class ApplicationController < ActionController::Base 7 | Configuration.default do |config| 8 | config.x_frame_options = SecureHeaders::OPT_OUT 9 | end 10 | 11 | SecureHeaders::Configuration.override(:dynamic_override) do |config| 12 | config.x_content_type_options = "nosniff" 13 | end 14 | end 15 | 16 | class FooController < ApplicationController 17 | def bar 18 | # Dynamically update the default config for this request 19 | override_x_frame_options("DENY") 20 | append_content_security_policy_directives(frame_src: "3rdpartyprovider.com") 21 | 22 | # Override everything, discard modifications above 23 | use_secure_headers_override(:dynamic_override) 24 | end 25 | end 26 | ``` 27 | 28 | Prior to 6.0.0, the response would NOT include a `X-Frame-Options` header since the named override would be a copy of the default configuration, but with `X-Content-Type-Options` set to `nosniff`. As of 6.0.0, the above code results in both `X-Frame-Options` set to `DENY` AND `X-Content-Type-Options` set to `nosniff`. 29 | 30 | ## `ContentSecurityPolicyConfig#merge` and `ContentSecurityPolicyReportOnlyConfig#merge` work more like `Hash#merge` 31 | 32 | These classes are typically not directly instantiated by users of SecureHeaders. But, if you access `config.csp` you end up accessing one of these objects. Prior to 6.0.0, `#merge` worked more like `#append` in that it would combine policies (i.e. if both policies contained the same key the values would be combined rather than overwritten). This was not consistent with `#merge!`, which worked more like Ruby's `Hash#merge!` (overwriting duplicate keys). As of 6.0.0, `#merge` works the same as `#merge!`, but returns a new object instead of mutating `self`. 33 | 34 | ## `Configuration#get` has been removed 35 | 36 | This method is not typically directly called by users of SecureHeaders. Given that named overrides are no longer statically stored, fetching them no longer makes sense. 37 | 38 | ## Configuration headers are no longer cached 39 | 40 | Prior to 6.0.0 SecureHeaders pre-built and cached the headers that corresponded to the default configuration. The same was also done for named overrides. However, now that named overrides are applied dynamically, those can no longer be cached. As a result, caching has been removed in the name of simplicity. Some micro-benchmarks indicate this shouldn't be a performance problem and will help to eliminate a class of bugs entirely. 41 | 42 | ## Calling the default configuration more than once will result in an Exception 43 | 44 | Prior to 6.0.0 you could conceivably, though unlikely, have `Configure#default` called more than once. Because configurations are dynamic, configuring more than once could result in unexpected behavior. So, as of 6.0.0 we raise `AlreadyConfiguredError` if the default configuration is setup more than once. 45 | 46 | ## All user agent sniffing has been removed 47 | 48 | The policy configured is the policy that is delivered in terms of which directives are sent. We still dedup, strip schemes, and look for other optimizations but we will not e.g. conditionally send `frame-src` / `child-src` or apply `nonce`s / `unsafe-inline`. 49 | 50 | The primary reason for these per-browser customization was to reduce console warnings. This has lead to many bugs and results in confusing behavior. Also, console logs are incredibly noisy today and increasingly warn you about perfectly valid things (like sending `X-Frame-Options` and `frame-ancestors` together). 51 | -------------------------------------------------------------------------------- /docs/upgrading-to-7-0.md: -------------------------------------------------------------------------------- 1 | ## X-Xss-Protection is set to 0 by default 2 | 3 | Version 6 and below of `secure_headers` set the `X-Xss-Protection` to `1; mode=block` by default. This was done to protect against reflected XSS attacks. However, this header is no longer recommended (see https://github.com/github/secure_headers/issues/439 for more information). 4 | 5 | If any functionality in your app depended on this header being set to the previous value, you will need to set it explicitly in your configuration. 6 | 7 | ```ruby 8 | # config/initializers/secure_headers.rb 9 | SecureHeaders::Configuration.default do |config| 10 | config.x_xss_protection = "1; mode=block" 11 | end 12 | ``` -------------------------------------------------------------------------------- /lib/secure_headers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "secure_headers/hash_helper" 3 | require "secure_headers/headers/cookie" 4 | require "secure_headers/headers/content_security_policy" 5 | require "secure_headers/headers/x_frame_options" 6 | require "secure_headers/headers/strict_transport_security" 7 | require "secure_headers/headers/x_xss_protection" 8 | require "secure_headers/headers/x_content_type_options" 9 | require "secure_headers/headers/x_download_options" 10 | require "secure_headers/headers/x_permitted_cross_domain_policies" 11 | require "secure_headers/headers/referrer_policy" 12 | require "secure_headers/headers/clear_site_data" 13 | require "secure_headers/headers/expect_certificate_transparency" 14 | require "secure_headers/middleware" 15 | require "secure_headers/railtie" 16 | require "secure_headers/view_helper" 17 | require "singleton" 18 | require "secure_headers/configuration" 19 | 20 | # Provide SecureHeaders::OPT_OUT as a config value to disable a given header 21 | module SecureHeaders 22 | class NoOpHeaderConfig 23 | include Singleton 24 | 25 | def boom(*args) 26 | raise "Illegal State: attempted to modify NoOpHeaderConfig. Create a new config instead." 27 | end 28 | 29 | def to_h 30 | {} 31 | end 32 | 33 | def dup 34 | self.class.instance 35 | end 36 | 37 | def opt_out? 38 | true 39 | end 40 | 41 | alias_method :[], :boom 42 | alias_method :[]=, :boom 43 | alias_method :keys, :boom 44 | end 45 | 46 | OPT_OUT = NoOpHeaderConfig.instance 47 | SECURE_HEADERS_CONFIG = "secure_headers_request_config".freeze 48 | NONCE_KEY = "secure_headers_content_security_policy_nonce".freeze 49 | HTTPS = "https".freeze 50 | CSP = ContentSecurityPolicy 51 | 52 | class << self 53 | # Public: override a given set of directives for the current request. If a 54 | # value already exists for a given directive, it will be overridden. 55 | # 56 | # If CSP was previously OPT_OUT, a new blank policy is used. 57 | # 58 | # additions - a hash containing directives. e.g. 59 | # script_src: %w(another-host.com) 60 | def override_content_security_policy_directives(request, additions, target = nil) 61 | config, target = config_and_target(request, target) 62 | 63 | if [:both, :enforced].include?(target) 64 | if config.csp.opt_out? 65 | config.csp = ContentSecurityPolicyConfig.new({}) 66 | end 67 | 68 | config.csp.merge!(additions) 69 | end 70 | 71 | if [:both, :report_only].include?(target) 72 | if config.csp_report_only.opt_out? 73 | config.csp_report_only = ContentSecurityPolicyReportOnlyConfig.new({}) 74 | end 75 | 76 | config.csp_report_only.merge!(additions) 77 | end 78 | 79 | override_secure_headers_request_config(request, config) 80 | end 81 | 82 | # Public: appends source values to the current configuration. If no value 83 | # is set for a given directive, the value will be merged with the default-src 84 | # value. If a value exists for the given directive, the values will be combined. 85 | # 86 | # additions - a hash containing directives. e.g. 87 | # script_src: %w(another-host.com) 88 | def append_content_security_policy_directives(request, additions, target = nil) 89 | config, target = config_and_target(request, target) 90 | 91 | if [:both, :enforced].include?(target) && !config.csp.opt_out? 92 | config.csp.append(additions) 93 | end 94 | 95 | if [:both, :report_only].include?(target) && !config.csp_report_only.opt_out? 96 | config.csp_report_only.append(additions) 97 | end 98 | 99 | override_secure_headers_request_config(request, config) 100 | end 101 | 102 | def use_content_security_policy_named_append(request, name) 103 | additions = SecureHeaders::Configuration.named_appends(name).call(request) 104 | append_content_security_policy_directives(request, additions) 105 | end 106 | 107 | # Public: override X-Frame-Options settings for this request. 108 | # 109 | # value - deny, sameorigin, or allowall 110 | # 111 | # Returns the current config 112 | def override_x_frame_options(request, value) 113 | config = config_for(request) 114 | config.update_x_frame_options(value) 115 | override_secure_headers_request_config(request, config) 116 | end 117 | 118 | # Public: opts out of setting a given header by creating a temporary config 119 | # and setting the given headers config to OPT_OUT. 120 | def opt_out_of_header(request, header_key) 121 | config = config_for(request) 122 | config.opt_out(header_key) 123 | override_secure_headers_request_config(request, config) 124 | end 125 | 126 | # Public: opts out of setting all headers by telling secure_headers to use 127 | # the NOOP configuration. 128 | def opt_out_of_all_protection(request) 129 | use_secure_headers_override(request, Configuration::NOOP_OVERRIDE) 130 | end 131 | 132 | # Public: Builds the hash of headers that should be applied base on the 133 | # request. 134 | # 135 | # StrictTransportSecurity is not applied to http requests. 136 | # See #config_for to determine which config is used for a given request. 137 | # 138 | # Returns a hash of header names => header values. The value 139 | # returned is meant to be merged into the header value from `@app.call(env)` 140 | # in Rack middleware. 141 | def header_hash_for(request) 142 | prevent_dup = true 143 | config = config_for(request, prevent_dup) 144 | config.validate_config! 145 | headers = config.generate_headers 146 | 147 | if request.scheme != HTTPS 148 | headers.delete(StrictTransportSecurity::HEADER_NAME) 149 | end 150 | headers 151 | end 152 | 153 | # Public: specify which named override will be used for this request. 154 | # Raises an argument error if no named override exists. 155 | # 156 | # name - the name of the previously configured override. 157 | def use_secure_headers_override(request, name) 158 | config = config_for(request) 159 | config.override(name) 160 | override_secure_headers_request_config(request, config) 161 | end 162 | 163 | # Public: gets or creates a nonce for CSP. 164 | # 165 | # The nonce will be added to script_src 166 | # 167 | # Returns the nonce 168 | def content_security_policy_script_nonce(request) 169 | content_security_policy_nonce(request, ContentSecurityPolicy::SCRIPT_SRC) 170 | end 171 | 172 | # Public: gets or creates a nonce for CSP. 173 | # 174 | # The nonce will be added to style_src 175 | # 176 | # Returns the nonce 177 | def content_security_policy_style_nonce(request) 178 | content_security_policy_nonce(request, ContentSecurityPolicy::STYLE_SRC) 179 | end 180 | 181 | # Public: Retreives the config for a given header type: 182 | # 183 | # Checks to see if there is an override for this request, then 184 | # Checks to see if a named override is used for this request, then 185 | # Falls back to the global config 186 | def config_for(request, prevent_dup = false) 187 | config = request.env[SECURE_HEADERS_CONFIG] || 188 | Configuration.send(:default_config) 189 | 190 | 191 | # Global configs are frozen, per-request configs are not. When we're not 192 | # making modifications to the config, prevent_dup ensures we don't dup 193 | # the object unnecessarily. It's not necessarily frozen to begin with. 194 | if config.frozen? && !prevent_dup 195 | config.dup 196 | else 197 | config 198 | end 199 | end 200 | 201 | private 202 | TARGETS = [:both, :enforced, :report_only] 203 | def raise_on_unknown_target(target) 204 | unless TARGETS.include?(target) 205 | raise "Unrecognized target: #{target}. Must be [:both, :enforced, :report_only]" 206 | end 207 | end 208 | 209 | def config_and_target(request, target) 210 | config = config_for(request) 211 | target = guess_target(config) unless target 212 | raise_on_unknown_target(target) 213 | [config, target] 214 | end 215 | 216 | def guess_target(config) 217 | if !config.csp.opt_out? && !config.csp_report_only.opt_out? 218 | :both 219 | elsif !config.csp.opt_out? 220 | :enforced 221 | elsif !config.csp_report_only.opt_out? 222 | :report_only 223 | else 224 | :both 225 | end 226 | end 227 | 228 | # Private: gets or creates a nonce for CSP. 229 | # 230 | # Returns the nonce 231 | def content_security_policy_nonce(request, script_or_style) 232 | request.env[NONCE_KEY] ||= SecureRandom.base64(32).chomp 233 | nonce_key = script_or_style == ContentSecurityPolicy::SCRIPT_SRC ? :script_nonce : :style_nonce 234 | append_content_security_policy_directives(request, nonce_key => request.env[NONCE_KEY]) 235 | request.env[NONCE_KEY] 236 | end 237 | 238 | # Private: convenience method for specifying which configuration object should 239 | # be used for this request. 240 | # 241 | # Returns the config. 242 | def override_secure_headers_request_config(request, config) 243 | request.env[SECURE_HEADERS_CONFIG] = config 244 | end 245 | end 246 | 247 | # These methods are mixed into controllers and delegate to the class method 248 | # with the same name. 249 | def use_secure_headers_override(name) 250 | SecureHeaders.use_secure_headers_override(request, name) 251 | end 252 | 253 | def content_security_policy_script_nonce 254 | SecureHeaders.content_security_policy_script_nonce(request) 255 | end 256 | 257 | def content_security_policy_style_nonce 258 | SecureHeaders.content_security_policy_style_nonce(request) 259 | end 260 | 261 | def opt_out_of_header(header_key) 262 | SecureHeaders.opt_out_of_header(request, header_key) 263 | end 264 | 265 | def append_content_security_policy_directives(additions) 266 | SecureHeaders.append_content_security_policy_directives(request, additions) 267 | end 268 | 269 | def override_content_security_policy_directives(additions) 270 | SecureHeaders.override_content_security_policy_directives(request, additions) 271 | end 272 | 273 | def override_x_frame_options(value) 274 | SecureHeaders.override_x_frame_options(request, value) 275 | end 276 | 277 | def use_content_security_policy_named_append(name) 278 | SecureHeaders.use_content_security_policy_named_append(request, name) 279 | end 280 | end 281 | -------------------------------------------------------------------------------- /lib/secure_headers/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "yaml" 3 | 4 | module SecureHeaders 5 | class Configuration 6 | DEFAULT_CONFIG = :default 7 | NOOP_OVERRIDE = "secure_headers_noop_override" 8 | class AlreadyConfiguredError < StandardError; end 9 | class NotYetConfiguredError < StandardError; end 10 | class IllegalPolicyModificationError < StandardError; end 11 | class << self 12 | # Public: Set the global default configuration. 13 | # 14 | # Optionally supply a block to override the defaults set by this library. 15 | # 16 | # Returns the newly created config. 17 | def default(&block) 18 | if defined?(@default_config) 19 | raise AlreadyConfiguredError, "Policy already configured" 20 | end 21 | 22 | # Define a built-in override that clears all configuration options and 23 | # results in no security headers being set. 24 | override(NOOP_OVERRIDE) do |config| 25 | CONFIG_ATTRIBUTES.each do |attr| 26 | config.instance_variable_set("@#{attr}", OPT_OUT) 27 | end 28 | end 29 | 30 | new_config = new(&block).freeze 31 | new_config.validate_config! 32 | @default_config = new_config 33 | end 34 | alias_method :configure, :default 35 | 36 | # Public: create a named configuration that overrides the default config. 37 | # 38 | # name - use an idenfier for the override config. 39 | # base - override another existing config, or override the default config 40 | # if no value is supplied. 41 | # 42 | # Returns: the newly created config 43 | def override(name, &block) 44 | @overrides ||= {} 45 | raise "Provide a configuration block" unless block_given? 46 | if named_append_or_override_exists?(name) 47 | raise AlreadyConfiguredError, "Configuration already exists" 48 | end 49 | @overrides[name] = block 50 | end 51 | 52 | def overrides(name) 53 | @overrides ||= {} 54 | @overrides[name] 55 | end 56 | 57 | def named_appends(name) 58 | @appends ||= {} 59 | @appends[name] 60 | end 61 | 62 | def named_append(name, &block) 63 | @appends ||= {} 64 | raise "Provide a configuration block" unless block_given? 65 | if named_append_or_override_exists?(name) 66 | raise AlreadyConfiguredError, "Configuration already exists" 67 | end 68 | @appends[name] = block 69 | end 70 | 71 | def dup 72 | default_config.dup 73 | end 74 | 75 | private 76 | 77 | def named_append_or_override_exists?(name) 78 | (defined?(@appends) && @appends.key?(name)) || 79 | (defined?(@overrides) && @overrides.key?(name)) 80 | end 81 | 82 | # Public: perform a basic deep dup. The shallow copy provided by dup/clone 83 | # can lead to modifying parent objects. 84 | def deep_copy(config) 85 | return unless config 86 | result = {} 87 | config.each_pair do |key, value| 88 | result[key] = 89 | case value 90 | when Array 91 | value.dup 92 | else 93 | value 94 | end 95 | end 96 | result 97 | end 98 | 99 | # Private: Returns the internal default configuration. This should only 100 | # ever be called by internal callers (or tests) that know the semantics 101 | # of ensuring that the default config is never mutated and is dup(ed) 102 | # before it is used in a request. 103 | def default_config 104 | unless defined?(@default_config) 105 | raise NotYetConfiguredError, "Default policy not yet configured" 106 | end 107 | @default_config 108 | end 109 | 110 | # Private: convenience method purely DRY things up. The value may not be a 111 | # hash (e.g. OPT_OUT, nil) 112 | def deep_copy_if_hash(value) 113 | if value.is_a?(Hash) 114 | deep_copy(value) 115 | else 116 | value 117 | end 118 | end 119 | end 120 | 121 | CONFIG_ATTRIBUTES_TO_HEADER_CLASSES = { 122 | hsts: StrictTransportSecurity, 123 | x_frame_options: XFrameOptions, 124 | x_content_type_options: XContentTypeOptions, 125 | x_xss_protection: XXssProtection, 126 | x_download_options: XDownloadOptions, 127 | x_permitted_cross_domain_policies: XPermittedCrossDomainPolicies, 128 | referrer_policy: ReferrerPolicy, 129 | clear_site_data: ClearSiteData, 130 | expect_certificate_transparency: ExpectCertificateTransparency, 131 | csp: ContentSecurityPolicy, 132 | csp_report_only: ContentSecurityPolicy, 133 | cookies: Cookie, 134 | }.freeze 135 | 136 | CONFIG_ATTRIBUTES = CONFIG_ATTRIBUTES_TO_HEADER_CLASSES.keys.freeze 137 | 138 | # The list of attributes that must respond to a `validate_config!` method 139 | VALIDATABLE_ATTRIBUTES = CONFIG_ATTRIBUTES 140 | 141 | # The list of attributes that must respond to a `make_header` method 142 | HEADERABLE_ATTRIBUTES = (CONFIG_ATTRIBUTES - [:cookies]).freeze 143 | 144 | attr_writer(*(CONFIG_ATTRIBUTES_TO_HEADER_CLASSES.reject { |key| [:csp, :csp_report_only].include?(key) }.keys)) 145 | 146 | attr_reader(*(CONFIG_ATTRIBUTES_TO_HEADER_CLASSES.keys)) 147 | 148 | @script_hashes = nil 149 | @style_hashes = nil 150 | 151 | HASH_CONFIG_FILE = ENV["secure_headers_generated_hashes_file"] || "config/secure_headers_generated_hashes.yml" 152 | if File.exist?(HASH_CONFIG_FILE) 153 | config = YAML.safe_load(File.open(HASH_CONFIG_FILE)) 154 | @script_hashes = config["scripts"] 155 | @style_hashes = config["styles"] 156 | end 157 | 158 | def initialize(&block) 159 | @cookies = self.class.send(:deep_copy_if_hash, Cookie::COOKIE_DEFAULTS) 160 | @clear_site_data = nil 161 | @csp = nil 162 | @csp_report_only = nil 163 | @hsts = nil 164 | @x_content_type_options = nil 165 | @x_download_options = nil 166 | @x_frame_options = nil 167 | @x_permitted_cross_domain_policies = nil 168 | @x_xss_protection = nil 169 | @expect_certificate_transparency = nil 170 | 171 | self.referrer_policy = OPT_OUT 172 | self.csp = ContentSecurityPolicyConfig.new(ContentSecurityPolicyConfig::DEFAULT) 173 | self.csp_report_only = OPT_OUT 174 | 175 | instance_eval(&block) if block_given? 176 | end 177 | 178 | # Public: copy everything 179 | # 180 | # Returns a deep-dup'd copy of this configuration. 181 | def dup 182 | copy = self.class.new 183 | copy.cookies = self.class.send(:deep_copy_if_hash, @cookies) 184 | copy.csp = @csp.dup if @csp 185 | copy.csp_report_only = @csp_report_only.dup if @csp_report_only 186 | copy.x_content_type_options = @x_content_type_options 187 | copy.hsts = @hsts 188 | copy.x_frame_options = @x_frame_options 189 | copy.x_xss_protection = @x_xss_protection 190 | copy.x_download_options = @x_download_options 191 | copy.x_permitted_cross_domain_policies = @x_permitted_cross_domain_policies 192 | copy.clear_site_data = @clear_site_data 193 | copy.expect_certificate_transparency = @expect_certificate_transparency 194 | copy.referrer_policy = @referrer_policy 195 | copy 196 | end 197 | 198 | # Public: Apply a named override to the current config 199 | # 200 | # Returns self 201 | def override(name = nil, &block) 202 | if override = self.class.overrides(name) 203 | instance_eval(&override) 204 | else 205 | raise ArgumentError.new("no override by the name of #{name} has been configured") 206 | end 207 | self 208 | end 209 | 210 | def generate_headers 211 | headers = {} 212 | HEADERABLE_ATTRIBUTES.each do |attr| 213 | klass = CONFIG_ATTRIBUTES_TO_HEADER_CLASSES[attr] 214 | header_name, value = klass.make_header(instance_variable_get("@#{attr}")) 215 | if header_name && value 216 | headers[header_name] = value 217 | end 218 | end 219 | headers 220 | end 221 | 222 | def opt_out(header) 223 | send("#{header}=", OPT_OUT) 224 | end 225 | 226 | def update_x_frame_options(value) 227 | @x_frame_options = value 228 | end 229 | 230 | # Public: validates all configurations values. 231 | # 232 | # Raises various configuration errors if any invalid config is detected. 233 | # 234 | # Returns nothing 235 | def validate_config! 236 | VALIDATABLE_ATTRIBUTES.each do |attr| 237 | klass = CONFIG_ATTRIBUTES_TO_HEADER_CLASSES[attr] 238 | klass.validate_config!(instance_variable_get("@#{attr}")) 239 | end 240 | end 241 | 242 | def secure_cookies=(secure_cookies) 243 | raise ArgumentError, "#{Kernel.caller.first}: `#secure_cookies=` is no longer supported. Please use `#cookies=` to configure secure cookies instead." 244 | end 245 | 246 | def csp=(new_csp) 247 | case new_csp 248 | when OPT_OUT 249 | @csp = new_csp 250 | when ContentSecurityPolicyConfig 251 | @csp = new_csp 252 | when Hash 253 | @csp = ContentSecurityPolicyConfig.new(new_csp) 254 | else 255 | raise ArgumentError, "Must provide either an existing CSP config or a CSP config hash" 256 | end 257 | end 258 | 259 | # Configures the content-security-policy-report-only header. `new_csp` cannot 260 | # contain `report_only: false` or an error will be raised. 261 | # 262 | # NOTE: if csp has not been configured/has the default value when 263 | # configuring csp_report_only, the code will assume you mean to only use 264 | # report-only mode and you will be opted-out of enforce mode. 265 | def csp_report_only=(new_csp) 266 | case new_csp 267 | when OPT_OUT 268 | @csp_report_only = new_csp 269 | when ContentSecurityPolicyReportOnlyConfig 270 | @csp_report_only = new_csp.dup 271 | when ContentSecurityPolicyConfig 272 | @csp_report_only = new_csp.make_report_only 273 | when Hash 274 | @csp_report_only = ContentSecurityPolicyReportOnlyConfig.new(new_csp) 275 | else 276 | raise ArgumentError, "Must provide either an existing CSP config or a CSP config hash" 277 | end 278 | end 279 | end 280 | end 281 | -------------------------------------------------------------------------------- /lib/secure_headers/hash_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "base64" 3 | 4 | module SecureHeaders 5 | module HashHelper 6 | def hash_source(inline_script, digest = :SHA256) 7 | base64_hashed_content = Base64.encode64(Digest.const_get(digest).digest(inline_script)).chomp 8 | "'#{digest.to_s.downcase}-#{base64_hashed_content}'" 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/secure_headers/headers/clear_site_data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SecureHeaders 3 | class ClearSiteDataConfigError < StandardError; end 4 | class ClearSiteData 5 | HEADER_NAME = "clear-site-data".freeze 6 | 7 | # Valid `types` 8 | CACHE = "cache".freeze 9 | COOKIES = "cookies".freeze 10 | STORAGE = "storage".freeze 11 | EXECUTION_CONTEXTS = "executionContexts".freeze 12 | ALL_TYPES = [CACHE, COOKIES, STORAGE, EXECUTION_CONTEXTS] 13 | 14 | class << self 15 | # Public: make an clear-site-data header name, value pair 16 | # 17 | # Returns nil if not configured, returns header name and value if configured. 18 | def make_header(config = nil, user_agent = nil) 19 | case config 20 | when nil, OPT_OUT, [] 21 | # noop 22 | when Array 23 | [HEADER_NAME, make_header_value(config)] 24 | when true 25 | [HEADER_NAME, make_header_value(ALL_TYPES)] 26 | end 27 | end 28 | 29 | def validate_config!(config) 30 | case config 31 | when nil, OPT_OUT, true 32 | # valid 33 | when Array 34 | unless config.all? { |t| t.is_a?(String) } 35 | raise ClearSiteDataConfigError.new("types must be Strings") 36 | end 37 | else 38 | raise ClearSiteDataConfigError.new("config must be an Array of Strings or `true`") 39 | end 40 | end 41 | 42 | # Public: Transform a clear-site-data config (an Array of Strings) into a 43 | # String that can be used as the value for the clear-site-data header. 44 | # 45 | # types - An Array of String of types of data to clear. 46 | # 47 | # Returns a String of quoted values that are comma separated. 48 | def make_header_value(types) 49 | types.map { |t| %("#{t}") }.join(", ") 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/secure_headers/headers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "policy_management" 3 | require_relative "content_security_policy_config" 4 | 5 | module SecureHeaders 6 | class ContentSecurityPolicy 7 | include PolicyManagement 8 | 9 | def initialize(config = nil) 10 | @config = 11 | if config.is_a?(Hash) 12 | if config[:report_only] 13 | ContentSecurityPolicyReportOnlyConfig.new(config || DEFAULT_CONFIG) 14 | else 15 | ContentSecurityPolicyConfig.new(config || DEFAULT_CONFIG) 16 | end 17 | elsif config.nil? 18 | ContentSecurityPolicyConfig.new(DEFAULT_CONFIG) 19 | else 20 | config 21 | end 22 | 23 | @preserve_schemes = @config[:preserve_schemes] 24 | @script_nonce = @config[:script_nonce] 25 | @style_nonce = @config[:style_nonce] 26 | end 27 | 28 | ## 29 | # Returns the name to use for the header. Either "content-security-policy" or 30 | # "content-security-policy-report-only" 31 | def name 32 | @config.class.const_get(:HEADER_NAME) 33 | end 34 | 35 | ## 36 | # Return the value of the CSP header 37 | def value 38 | @value ||= 39 | if @config 40 | build_value 41 | else 42 | DEFAULT_VALUE 43 | end 44 | end 45 | 46 | private 47 | 48 | # Private: converts the config object into a string representing a policy. 49 | # Places default-src at the first directive and report-uri as the last. All 50 | # others are presented in alphabetical order. 51 | # 52 | # Returns a content security policy header value. 53 | def build_value 54 | directives.map do |directive_name| 55 | case DIRECTIVE_VALUE_TYPES[directive_name] 56 | when :source_list, 57 | :require_sri_for_list, # require_sri is a simple set of strings that don't need to deal with symbol casing 58 | :require_trusted_types_for_list 59 | build_source_list_directive(directive_name) 60 | when :boolean 61 | symbol_to_hyphen_case(directive_name) if @config.directive_value(directive_name) 62 | when :sandbox_list 63 | build_sandbox_list_directive(directive_name) 64 | when :media_type_list 65 | build_media_type_list_directive(directive_name) 66 | end 67 | end.compact.join("; ") 68 | end 69 | 70 | def build_sandbox_list_directive(directive) 71 | return unless sandbox_list = @config.directive_value(directive) 72 | max_strict_policy = case sandbox_list 73 | when Array 74 | sandbox_list.empty? 75 | when true 76 | true 77 | else 78 | false 79 | end 80 | 81 | # A maximally strict sandbox policy is just the `sandbox` directive, 82 | # whith no configuraiton values. 83 | if max_strict_policy 84 | symbol_to_hyphen_case(directive) 85 | elsif sandbox_list && sandbox_list.any? 86 | [ 87 | symbol_to_hyphen_case(directive), 88 | sandbox_list.uniq 89 | ].join(" ") 90 | end 91 | end 92 | 93 | def build_media_type_list_directive(directive) 94 | return unless media_type_list = @config.directive_value(directive) 95 | if media_type_list && media_type_list.any? 96 | [ 97 | symbol_to_hyphen_case(directive), 98 | media_type_list.uniq 99 | ].join(" ") 100 | end 101 | end 102 | 103 | # Private: builds a string that represents one directive in a minified form. 104 | # 105 | # directive_name - a symbol representing the various ALL_DIRECTIVES 106 | # 107 | # Returns a string representing a directive. 108 | def build_source_list_directive(directive) 109 | source_list = @config.directive_value(directive) 110 | if source_list != OPT_OUT && source_list && source_list.any? 111 | minified_source_list = minify_source_list(directive, source_list).join(" ") 112 | 113 | if minified_source_list =~ /(\n|;)/ 114 | Kernel.warn("#{directive} contains a #{$1} in #{minified_source_list.inspect} which will raise an error in future versions. It has been replaced with a blank space.") 115 | end 116 | 117 | escaped_source_list = minified_source_list.gsub(/[\n;]/, " ") 118 | [symbol_to_hyphen_case(directive), escaped_source_list].join(" ").strip 119 | end 120 | end 121 | 122 | # If a directive contains *, all other values are omitted. 123 | # If a directive contains 'none' but has other values, 'none' is ommitted. 124 | # Schemes are stripped (see http://www.w3.org/TR/CSP2/#match-source-expression) 125 | def minify_source_list(directive, source_list) 126 | source_list = source_list.compact 127 | if source_list.include?(STAR) 128 | keep_wildcard_sources(source_list) 129 | else 130 | source_list = populate_nonces(directive, source_list) 131 | source_list = reject_all_values_if_none(source_list) 132 | 133 | unless directive == REPORT_URI || @preserve_schemes 134 | source_list = strip_source_schemes(source_list) 135 | end 136 | source_list.uniq 137 | end 138 | end 139 | 140 | # Discard trailing entries (excluding unsafe-*) since * accomplishes the same. 141 | def keep_wildcard_sources(source_list) 142 | source_list.select { |value| WILDCARD_SOURCES.include?(value) } 143 | end 144 | 145 | # Discard any 'none' values if more directives are supplied since none may override values. 146 | def reject_all_values_if_none(source_list) 147 | if source_list.length > 1 148 | source_list.reject { |value| value == NONE } 149 | else 150 | source_list 151 | end 152 | end 153 | 154 | # Private: append a nonce to the script/style directories if script_nonce 155 | # or style_nonce are provided. 156 | def populate_nonces(directive, source_list) 157 | case directive 158 | when SCRIPT_SRC 159 | append_nonce(source_list, @script_nonce) 160 | when STYLE_SRC 161 | append_nonce(source_list, @style_nonce) 162 | else 163 | source_list 164 | end 165 | end 166 | 167 | # Private: adds a nonce or 'unsafe-inline' depending on browser support. 168 | # If a nonce is populated, inline content is assumed. 169 | # 170 | # While CSP is backward compatible in that a policy with a nonce will ignore 171 | # unsafe-inline, this is more concise. 172 | def append_nonce(source_list, nonce) 173 | if nonce 174 | source_list.push("'nonce-#{nonce}'") 175 | source_list.push(UNSAFE_INLINE) unless @config[:disable_nonce_backwards_compatibility] 176 | end 177 | 178 | source_list 179 | end 180 | 181 | # Private: return the list of directives, 182 | # starting with default-src and ending with report-uri. 183 | def directives 184 | [ 185 | DEFAULT_SRC, 186 | BODY_DIRECTIVES, 187 | REPORT_URI, 188 | ].flatten 189 | end 190 | 191 | # Private: Remove scheme from source expressions. 192 | def strip_source_schemes(source_list) 193 | source_list.map { |source_expression| source_expression.sub(HTTP_SCHEME_REGEX, "") } 194 | end 195 | 196 | def symbol_to_hyphen_case(sym) 197 | sym.to_s.tr("_", "-") 198 | end 199 | end 200 | end 201 | -------------------------------------------------------------------------------- /lib/secure_headers/headers/content_security_policy_config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SecureHeaders 3 | module DynamicConfig 4 | def initialize(hash) 5 | @config = {} 6 | 7 | from_hash(hash) 8 | end 9 | 10 | def initialize_copy(hash) 11 | @config = hash.to_h 12 | end 13 | 14 | def update_directive(directive, value) 15 | @config[directive] = value 16 | end 17 | 18 | def directive_value(directive) 19 | # No need to check attrs, as we only assign valid keys 20 | @config[directive] 21 | end 22 | 23 | def merge(new_hash) 24 | new_config = self.dup 25 | new_config.send(:from_hash, new_hash) 26 | new_config 27 | end 28 | 29 | def merge!(new_hash) 30 | from_hash(new_hash) 31 | end 32 | 33 | def append(new_hash) 34 | from_hash(ContentSecurityPolicy.combine_policies(self.to_h, new_hash)) 35 | end 36 | 37 | def to_h 38 | @config.dup 39 | end 40 | 41 | def dup 42 | self.class.new(self.to_h) 43 | end 44 | 45 | def opt_out? 46 | false 47 | end 48 | 49 | def ==(o) 50 | self.class == o.class && self.to_h == o.to_h 51 | end 52 | 53 | alias_method :[], :directive_value 54 | alias_method :[]=, :update_directive 55 | 56 | private 57 | def from_hash(hash) 58 | hash.each_pair do |k, v| 59 | next if v.nil? 60 | 61 | if self.class.attrs.include?(k) 62 | write_attribute(k, v) 63 | else 64 | raise ContentSecurityPolicyConfigError, "Unknown config directive: #{k}=#{v}" 65 | end 66 | end 67 | end 68 | 69 | def write_attribute(attr, value) 70 | value = value.dup if PolicyManagement::DIRECTIVE_VALUE_TYPES[attr] == :source_list 71 | if value.nil? 72 | @config.delete(attr) 73 | else 74 | @config[attr] = value 75 | end 76 | end 77 | end 78 | 79 | class ContentSecurityPolicyConfigError < StandardError; end 80 | class ContentSecurityPolicyConfig 81 | HEADER_NAME = "content-security-policy".freeze 82 | 83 | ATTRS = Set.new(PolicyManagement::ALL_DIRECTIVES + PolicyManagement::META_CONFIGS + PolicyManagement::NONCES) 84 | def self.attrs 85 | ATTRS 86 | end 87 | 88 | include DynamicConfig 89 | 90 | # based on what was suggested in https://github.com/rails/rails/pull/24961/files 91 | DEFAULT = { 92 | default_src: %w('self' https:), 93 | font_src: %w('self' https: data:), 94 | img_src: %w('self' https: data:), 95 | object_src: %w('none'), 96 | script_src: %w(https:), 97 | style_src: %w('self' https: 'unsafe-inline') 98 | } 99 | 100 | def report_only? 101 | false 102 | end 103 | 104 | def make_report_only 105 | ContentSecurityPolicyReportOnlyConfig.new(self.to_h) 106 | end 107 | end 108 | 109 | class ContentSecurityPolicyReportOnlyConfig < ContentSecurityPolicyConfig 110 | HEADER_NAME = "content-security-policy-report-only".freeze 111 | 112 | def report_only? 113 | true 114 | end 115 | 116 | def make_report_only 117 | self 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/secure_headers/headers/cookie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "cgi" 3 | require "secure_headers/utils/cookies_config" 4 | 5 | 6 | module SecureHeaders 7 | class CookiesConfigError < StandardError; end 8 | class Cookie 9 | 10 | class << self 11 | def validate_config!(config) 12 | CookiesConfig.new(config).validate! 13 | end 14 | end 15 | 16 | attr_reader :raw_cookie, :config 17 | 18 | COOKIE_DEFAULTS = { 19 | httponly: true, 20 | secure: true, 21 | samesite: { lax: true }, 22 | }.freeze 23 | 24 | def initialize(cookie, config) 25 | @raw_cookie = cookie 26 | unless config == OPT_OUT 27 | config ||= {} 28 | config = COOKIE_DEFAULTS.merge(config) 29 | end 30 | @config = config 31 | @attributes = { 32 | httponly: nil, 33 | samesite: nil, 34 | secure: nil, 35 | } 36 | 37 | parse(cookie) 38 | end 39 | 40 | def to_s 41 | @raw_cookie.dup.tap do |c| 42 | c << "; secure" if secure? 43 | c << "; HttpOnly" if httponly? 44 | c << "; #{samesite_cookie}" if samesite? 45 | end 46 | end 47 | 48 | def secure? 49 | flag_cookie?(:secure) && !already_flagged?(:secure) 50 | end 51 | 52 | def httponly? 53 | flag_cookie?(:httponly) && !already_flagged?(:httponly) 54 | end 55 | 56 | def samesite? 57 | flag_samesite? && !already_flagged?(:samesite) 58 | end 59 | 60 | private 61 | 62 | def parsed_cookie 63 | @parsed_cookie ||= CGI::Cookie.parse(raw_cookie) 64 | end 65 | 66 | def already_flagged?(attribute) 67 | @attributes[attribute] 68 | end 69 | 70 | def flag_cookie?(attribute) 71 | return false if config == OPT_OUT 72 | case config[attribute] 73 | when TrueClass 74 | true 75 | when Hash 76 | conditionally_flag?(config[attribute]) 77 | else 78 | false 79 | end 80 | end 81 | 82 | def conditionally_flag?(configuration) 83 | if (Array(configuration[:only]).any? && (Array(configuration[:only]) & parsed_cookie.keys).any?) 84 | true 85 | elsif (Array(configuration[:except]).any? && (Array(configuration[:except]) & parsed_cookie.keys).none?) 86 | true 87 | else 88 | false 89 | end 90 | end 91 | 92 | def samesite_cookie 93 | if flag_samesite_lax? 94 | "SameSite=Lax" 95 | elsif flag_samesite_strict? 96 | "SameSite=Strict" 97 | elsif flag_samesite_none? 98 | "SameSite=None" 99 | end 100 | end 101 | 102 | def flag_samesite? 103 | return false if config == OPT_OUT || config[:samesite] == OPT_OUT 104 | flag_samesite_lax? || flag_samesite_strict? || flag_samesite_none? 105 | end 106 | 107 | def flag_samesite_lax? 108 | flag_samesite_enforcement?(:lax) 109 | end 110 | 111 | def flag_samesite_strict? 112 | flag_samesite_enforcement?(:strict) 113 | end 114 | 115 | def flag_samesite_none? 116 | flag_samesite_enforcement?(:none) 117 | end 118 | 119 | def flag_samesite_enforcement?(mode) 120 | return unless config[:samesite] 121 | 122 | if config[:samesite].is_a?(TrueClass) && mode == :lax 123 | return true 124 | end 125 | 126 | case config[:samesite][mode] 127 | when Hash 128 | conditionally_flag?(config[:samesite][mode]) 129 | when TrueClass 130 | true 131 | else 132 | false 133 | end 134 | end 135 | 136 | def parse(cookie) 137 | return unless cookie 138 | 139 | cookie.split(/[;,]\s?/).each do |pairs| 140 | name, values = pairs.split("=", 2) 141 | name = CGI.unescape(name) 142 | 143 | attribute = name.downcase.to_sym 144 | if @attributes.has_key?(attribute) 145 | @attributes[attribute] = values || true 146 | end 147 | end 148 | end 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /lib/secure_headers/headers/expect_certificate_transparency.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SecureHeaders 3 | class ExpectCertificateTransparencyConfigError < StandardError; end 4 | 5 | class ExpectCertificateTransparency 6 | HEADER_NAME = "expect-ct".freeze 7 | INVALID_CONFIGURATION_ERROR = "config must be a hash.".freeze 8 | INVALID_ENFORCE_VALUE_ERROR = "enforce must be a boolean".freeze 9 | REQUIRED_MAX_AGE_ERROR = "max-age is a required directive.".freeze 10 | INVALID_MAX_AGE_ERROR = "max-age must be a number.".freeze 11 | 12 | class << self 13 | # Public: Generate a expect-ct header. 14 | # 15 | # Returns nil if not configured, returns header name and value if 16 | # configured. 17 | def make_header(config, use_agent = nil) 18 | return if config.nil? || config == OPT_OUT 19 | 20 | header = new(config) 21 | [HEADER_NAME, header.value] 22 | end 23 | 24 | def validate_config!(config) 25 | return if config.nil? || config == OPT_OUT 26 | raise ExpectCertificateTransparencyConfigError.new(INVALID_CONFIGURATION_ERROR) unless config.is_a? Hash 27 | 28 | unless [true, false, nil].include?(config[:enforce]) 29 | raise ExpectCertificateTransparencyConfigError.new(INVALID_ENFORCE_VALUE_ERROR) 30 | end 31 | 32 | if !config[:max_age] 33 | raise ExpectCertificateTransparencyConfigError.new(REQUIRED_MAX_AGE_ERROR) 34 | elsif config[:max_age].to_s !~ /\A\d+\z/ 35 | raise ExpectCertificateTransparencyConfigError.new(INVALID_MAX_AGE_ERROR) 36 | end 37 | end 38 | end 39 | 40 | def initialize(config) 41 | @enforced = config.fetch(:enforce, nil) 42 | @max_age = config.fetch(:max_age, nil) 43 | @report_uri = config.fetch(:report_uri, nil) 44 | end 45 | 46 | def value 47 | [ 48 | enforced_directive, 49 | max_age_directive, 50 | report_uri_directive 51 | ].compact.join(", ").strip 52 | end 53 | 54 | def enforced_directive 55 | # Unfortunately `if @enforced` isn't enough here in case someone 56 | # passes in a random string so let's be specific with it to prevent 57 | # accidental enforcement. 58 | "enforce" if @enforced == true 59 | end 60 | 61 | def max_age_directive 62 | "max-age=#{@max_age}" if @max_age 63 | end 64 | 65 | def report_uri_directive 66 | "report-uri=\"#{@report_uri}\"" if @report_uri 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/secure_headers/headers/policy_management.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "set" 4 | 5 | module SecureHeaders 6 | module PolicyManagement 7 | def self.included(base) 8 | base.extend(ClassMethods) 9 | end 10 | 11 | DEFAULT_CONFIG = { 12 | default_src: %w(https:), 13 | img_src: %w(https: data: 'self'), 14 | object_src: %w('none'), 15 | script_src: %w(https:), 16 | style_src: %w('self' 'unsafe-inline' https:), 17 | form_action: %w('self') 18 | }.freeze 19 | DATA_PROTOCOL = "data:".freeze 20 | BLOB_PROTOCOL = "blob:".freeze 21 | SELF = "'self'".freeze 22 | NONE = "'none'".freeze 23 | STAR = "*".freeze 24 | UNSAFE_INLINE = "'unsafe-inline'".freeze 25 | UNSAFE_EVAL = "'unsafe-eval'".freeze 26 | STRICT_DYNAMIC = "'strict-dynamic'".freeze 27 | 28 | # leftover deprecated values that will be in common use upon upgrading. 29 | DEPRECATED_SOURCE_VALUES = [SELF, NONE, UNSAFE_EVAL, UNSAFE_INLINE, "inline", "eval"].map { |value| value.delete("'") }.freeze 30 | 31 | DEFAULT_SRC = :default_src 32 | CONNECT_SRC = :connect_src 33 | FONT_SRC = :font_src 34 | FRAME_SRC = :frame_src 35 | IMG_SRC = :img_src 36 | MEDIA_SRC = :media_src 37 | OBJECT_SRC = :object_src 38 | SANDBOX = :sandbox 39 | SCRIPT_SRC = :script_src 40 | STYLE_SRC = :style_src 41 | REPORT_URI = :report_uri 42 | 43 | DIRECTIVES_1_0 = [ 44 | DEFAULT_SRC, 45 | CONNECT_SRC, 46 | FONT_SRC, 47 | FRAME_SRC, 48 | IMG_SRC, 49 | MEDIA_SRC, 50 | OBJECT_SRC, 51 | SANDBOX, 52 | SCRIPT_SRC, 53 | STYLE_SRC, 54 | REPORT_URI 55 | ].freeze 56 | 57 | BASE_URI = :base_uri 58 | CHILD_SRC = :child_src 59 | FORM_ACTION = :form_action 60 | FRAME_ANCESTORS = :frame_ancestors 61 | PLUGIN_TYPES = :plugin_types 62 | 63 | DIRECTIVES_2_0 = [ 64 | DIRECTIVES_1_0, 65 | BASE_URI, 66 | CHILD_SRC, 67 | FORM_ACTION, 68 | FRAME_ANCESTORS, 69 | PLUGIN_TYPES 70 | ].flatten.freeze 71 | 72 | # All the directives currently under consideration for CSP level 3. 73 | # https://w3c.github.io/webappsec/specs/CSP2/ 74 | MANIFEST_SRC = :manifest_src 75 | NAVIGATE_TO = :navigate_to 76 | PREFETCH_SRC = :prefetch_src 77 | REQUIRE_SRI_FOR = :require_sri_for 78 | UPGRADE_INSECURE_REQUESTS = :upgrade_insecure_requests 79 | WORKER_SRC = :worker_src 80 | SCRIPT_SRC_ELEM = :script_src_elem 81 | SCRIPT_SRC_ATTR = :script_src_attr 82 | STYLE_SRC_ELEM = :style_src_elem 83 | STYLE_SRC_ATTR = :style_src_attr 84 | 85 | DIRECTIVES_3_0 = [ 86 | DIRECTIVES_2_0, 87 | MANIFEST_SRC, 88 | NAVIGATE_TO, 89 | PREFETCH_SRC, 90 | REQUIRE_SRI_FOR, 91 | WORKER_SRC, 92 | UPGRADE_INSECURE_REQUESTS, 93 | SCRIPT_SRC_ELEM, 94 | SCRIPT_SRC_ATTR, 95 | STYLE_SRC_ELEM, 96 | STYLE_SRC_ATTR 97 | ].flatten.freeze 98 | 99 | # Experimental directives - these vary greatly in support 100 | # See MDN for details. 101 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/content-security-policy/trusted-types 102 | TRUSTED_TYPES = :trusted_types 103 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/content-security-policy/require-trusted-types-for 104 | REQUIRE_TRUSTED_TYPES_FOR = :require_trusted_types_for 105 | 106 | DIRECTIVES_EXPERIMENTAL = [ 107 | TRUSTED_TYPES, 108 | REQUIRE_TRUSTED_TYPES_FOR, 109 | ].flatten.freeze 110 | 111 | ALL_DIRECTIVES = (DIRECTIVES_1_0 + DIRECTIVES_2_0 + DIRECTIVES_3_0 + DIRECTIVES_EXPERIMENTAL).uniq.sort 112 | 113 | # Think of default-src and report-uri as the beginning and end respectively, 114 | # everything else is in between. 115 | BODY_DIRECTIVES = ALL_DIRECTIVES - [DEFAULT_SRC, REPORT_URI] 116 | 117 | DIRECTIVE_VALUE_TYPES = { 118 | BASE_URI => :source_list, 119 | CHILD_SRC => :source_list, 120 | CONNECT_SRC => :source_list, 121 | DEFAULT_SRC => :source_list, 122 | FONT_SRC => :source_list, 123 | FORM_ACTION => :source_list, 124 | FRAME_ANCESTORS => :source_list, 125 | FRAME_SRC => :source_list, 126 | IMG_SRC => :source_list, 127 | MANIFEST_SRC => :source_list, 128 | MEDIA_SRC => :source_list, 129 | NAVIGATE_TO => :source_list, 130 | OBJECT_SRC => :source_list, 131 | PLUGIN_TYPES => :media_type_list, 132 | REQUIRE_SRI_FOR => :require_sri_for_list, 133 | REQUIRE_TRUSTED_TYPES_FOR => :require_trusted_types_for_list, 134 | REPORT_URI => :source_list, 135 | PREFETCH_SRC => :source_list, 136 | SANDBOX => :sandbox_list, 137 | SCRIPT_SRC => :source_list, 138 | SCRIPT_SRC_ELEM => :source_list, 139 | SCRIPT_SRC_ATTR => :source_list, 140 | STYLE_SRC => :source_list, 141 | STYLE_SRC_ELEM => :source_list, 142 | STYLE_SRC_ATTR => :source_list, 143 | TRUSTED_TYPES => :source_list, 144 | WORKER_SRC => :source_list, 145 | UPGRADE_INSECURE_REQUESTS => :boolean, 146 | }.freeze 147 | 148 | # These are directives that don't have use a source list, and hence do not 149 | # inherit the default-src value. 150 | NON_SOURCE_LIST_SOURCES = DIRECTIVE_VALUE_TYPES.select do |_, type| 151 | type != :source_list 152 | end.keys.freeze 153 | 154 | # These are directives that take a source list, but that do not inherit 155 | # the default-src value. 156 | NON_FETCH_SOURCES = [ 157 | BASE_URI, 158 | FORM_ACTION, 159 | FRAME_ANCESTORS, 160 | NAVIGATE_TO, 161 | REPORT_URI, 162 | ] 163 | 164 | FETCH_SOURCES = ALL_DIRECTIVES - NON_FETCH_SOURCES - NON_SOURCE_LIST_SOURCES 165 | 166 | STAR_REGEXP = Regexp.new(Regexp.escape(STAR)) 167 | HTTP_SCHEME_REGEX = %r{\Ahttps?://} 168 | 169 | WILDCARD_SOURCES = [ 170 | UNSAFE_EVAL, 171 | UNSAFE_INLINE, 172 | STAR, 173 | DATA_PROTOCOL, 174 | BLOB_PROTOCOL 175 | ].freeze 176 | 177 | META_CONFIGS = [ 178 | :report_only, 179 | :preserve_schemes, 180 | :disable_nonce_backwards_compatibility 181 | ].freeze 182 | 183 | NONCES = [ 184 | :script_nonce, 185 | :style_nonce 186 | ].freeze 187 | 188 | REQUIRE_SRI_FOR_VALUES = Set.new(%w(script style)) 189 | REQUIRE_TRUSTED_TYPES_FOR_VALUES = Set.new(%w('script')) 190 | 191 | module ClassMethods 192 | # Public: generate a header name, value array that is user-agent-aware. 193 | # 194 | # Returns a default policy if no configuration is provided, or a 195 | # header name and value based on the config. 196 | def make_header(config) 197 | return if config.nil? || config == OPT_OUT 198 | header = new(config) 199 | [header.name, header.value] 200 | end 201 | 202 | # Public: Validates each source expression. 203 | # 204 | # Does not validate the invididual values of the source expression (e.g. 205 | # script_src => h*t*t*p: will not raise an exception) 206 | def validate_config!(config) 207 | return if config.nil? || config.opt_out? 208 | raise ContentSecurityPolicyConfigError.new(":default_src is required") unless config.directive_value(:default_src) 209 | if config.directive_value(:script_src).nil? 210 | raise ContentSecurityPolicyConfigError.new(":script_src is required, falling back to default-src is too dangerous. Use `script_src: OPT_OUT` to override") 211 | end 212 | if !config.report_only? && config.directive_value(:report_only) 213 | raise ContentSecurityPolicyConfigError.new("Only the csp_report_only config should set :report_only to true") 214 | end 215 | 216 | if config.report_only? && config.directive_value(:report_only) == false 217 | raise ContentSecurityPolicyConfigError.new("csp_report_only config must have :report_only set to true") 218 | end 219 | 220 | ContentSecurityPolicyConfig.attrs.each do |key| 221 | value = config.directive_value(key) 222 | next unless value 223 | 224 | if META_CONFIGS.include?(key) 225 | raise ContentSecurityPolicyConfigError.new("#{key} must be a boolean value") unless boolean?(value) || value.nil? 226 | elsif NONCES.include?(key) 227 | raise ContentSecurityPolicyConfigError.new("#{key} must be a non-nil value") if value.nil? 228 | else 229 | validate_directive!(key, value) 230 | end 231 | end 232 | end 233 | 234 | # Public: combine the values from two different configs. 235 | # 236 | # original - the main config 237 | # additions - values to be merged in 238 | # 239 | # raises an error if the original config is OPT_OUT 240 | # 241 | # 1. for non-source-list values (report_only, upgrade_insecure_requests), 242 | # additions will overwrite the original value. 243 | # 2. if a value in additions does not exist in the original config, the 244 | # default-src value is included to match original behavior. 245 | # 3. if a value in additions does exist in the original config, the two 246 | # values are joined. 247 | def combine_policies(original, additions) 248 | if original == {} 249 | raise ContentSecurityPolicyConfigError.new("Attempted to override an opt-out CSP config.") 250 | end 251 | 252 | original = Configuration.send(:deep_copy, original) 253 | populate_fetch_source_with_default!(original, additions) 254 | merge_policy_additions(original, additions) 255 | end 256 | 257 | def ua_to_variation(user_agent) 258 | family = user_agent.browser 259 | if family && VARIATIONS.key?(family) 260 | family 261 | else 262 | OTHER 263 | end 264 | end 265 | 266 | private 267 | 268 | # merge the two hashes. combine (instead of overwrite) the array values 269 | # when each hash contains a value for a given key. 270 | def merge_policy_additions(original, additions) 271 | original.merge(additions) do |directive, lhs, rhs| 272 | if list_directive?(directive) 273 | (lhs.to_a + rhs.to_a).compact.uniq 274 | else 275 | rhs 276 | end 277 | end.reject { |_, value| value.nil? || value == [] } # this mess prevents us from adding empty directives. 278 | end 279 | 280 | # Returns True if a directive expects a list of values and False otherwise. 281 | def list_directive?(directive) 282 | source_list?(directive) || 283 | sandbox_list?(directive) || 284 | media_type_list?(directive) || 285 | require_sri_for_list?(directive) || 286 | require_trusted_types_for_list?(directive) 287 | end 288 | 289 | # For each directive in additions that does not exist in the original config, 290 | # copy the default-src value to the original config. This modifies the original hash. 291 | def populate_fetch_source_with_default!(original, additions) 292 | # in case we would be appending to an empty directive, fill it with the default-src value 293 | additions.each_key do |directive| 294 | directive = 295 | if directive.to_s.end_with?("_nonce") 296 | directive.to_s.gsub(/_nonce/, "_src").to_sym 297 | else 298 | directive 299 | end 300 | # Don't set a default if directive has an existing value 301 | next if original[directive] 302 | if FETCH_SOURCES.include?(directive) 303 | original[directive] = original[DEFAULT_SRC] 304 | end 305 | end 306 | end 307 | 308 | def source_list?(directive) 309 | DIRECTIVE_VALUE_TYPES[directive] == :source_list 310 | end 311 | 312 | def sandbox_list?(directive) 313 | DIRECTIVE_VALUE_TYPES[directive] == :sandbox_list 314 | end 315 | 316 | def media_type_list?(directive) 317 | DIRECTIVE_VALUE_TYPES[directive] == :media_type_list 318 | end 319 | 320 | def require_sri_for_list?(directive) 321 | DIRECTIVE_VALUE_TYPES[directive] == :require_sri_for_list 322 | end 323 | 324 | def require_trusted_types_for_list?(directive) 325 | DIRECTIVE_VALUE_TYPES[directive] == :require_trusted_types_for_list 326 | end 327 | 328 | # Private: Validates that the configuration has a valid type, or that it is a valid 329 | # source expression. 330 | def validate_directive!(directive, value) 331 | ensure_valid_directive!(directive) 332 | case ContentSecurityPolicy::DIRECTIVE_VALUE_TYPES[directive] 333 | when :source_list 334 | validate_source_expression!(directive, value) 335 | when :boolean 336 | unless boolean?(value) 337 | raise ContentSecurityPolicyConfigError.new("#{directive} must be a boolean. Found #{value.class} value") 338 | end 339 | when :sandbox_list 340 | validate_sandbox_expression!(directive, value) 341 | when :media_type_list 342 | validate_media_type_expression!(directive, value) 343 | when :require_sri_for_list 344 | validate_require_sri_source_expression!(directive, value) 345 | when :require_trusted_types_for_list 346 | validate_require_trusted_types_for_source_expression!(directive, value) 347 | else 348 | raise ContentSecurityPolicyConfigError.new("Unknown directive #{directive}") 349 | end 350 | end 351 | 352 | # Private: validates that a sandbox token expression: 353 | # 1. is an array of strings or optionally `true` (to enable maximal sandboxing) 354 | # 2. For arrays, each element is of the form allow-* 355 | def validate_sandbox_expression!(directive, sandbox_token_expression) 356 | # We support sandbox: true to indicate a maximally secure sandbox. 357 | return if boolean?(sandbox_token_expression) && sandbox_token_expression == true 358 | ensure_array_of_strings!(directive, sandbox_token_expression) 359 | valid = sandbox_token_expression.compact.all? do |v| 360 | v.is_a?(String) && v.start_with?("allow-") 361 | end 362 | if !valid 363 | raise ContentSecurityPolicyConfigError.new("#{directive} must be True or an array of zero or more sandbox token strings (ex. allow-forms)") 364 | end 365 | end 366 | 367 | # Private: validates that a media type expression: 368 | # 1. is an array of strings 369 | # 2. each element is of the form type/subtype 370 | def validate_media_type_expression!(directive, media_type_expression) 371 | ensure_array_of_strings!(directive, media_type_expression) 372 | valid = media_type_expression.compact.all? do |v| 373 | # All media types are of the form: "/" . 374 | v =~ /\A.+\/.+\z/ 375 | end 376 | if !valid 377 | raise ContentSecurityPolicyConfigError.new("#{directive} must be an array of valid media types (ex. application/pdf)") 378 | end 379 | end 380 | 381 | # Private: validates that a require sri for expression: 382 | # 1. is an array of strings 383 | # 2. is a subset of ["string", "style"] 384 | def validate_require_sri_source_expression!(directive, require_sri_for_expression) 385 | ensure_array_of_strings!(directive, require_sri_for_expression) 386 | unless require_sri_for_expression.to_set.subset?(REQUIRE_SRI_FOR_VALUES) 387 | raise ContentSecurityPolicyConfigError.new(%(require-sri for must be a subset of #{REQUIRE_SRI_FOR_VALUES.to_a} but was #{require_sri_for_expression})) 388 | end 389 | end 390 | 391 | # Private: validates that a require trusted types for expression: 392 | # 1. is an array of strings 393 | # 2. is a subset of ["'script'"] 394 | def validate_require_trusted_types_for_source_expression!(directive, require_trusted_types_for_expression) 395 | ensure_array_of_strings!(directive, require_trusted_types_for_expression) 396 | unless require_trusted_types_for_expression.to_set.subset?(REQUIRE_TRUSTED_TYPES_FOR_VALUES) 397 | raise ContentSecurityPolicyConfigError.new(%(require-trusted-types-for for must be a subset of #{REQUIRE_TRUSTED_TYPES_FOR_VALUES.to_a} but was #{require_trusted_types_for_expression})) 398 | end 399 | end 400 | 401 | # Private: validates that a source expression: 402 | # 1. is an array of strings 403 | # 2. does not contain any deprecated, now invalid values (inline, eval, self, none) 404 | # 405 | # Does not validate the invididual values of the source expression (e.g. 406 | # script_src => h*t*t*p: will not raise an exception) 407 | def validate_source_expression!(directive, source_expression) 408 | if source_expression != OPT_OUT 409 | ensure_array_of_strings!(directive, source_expression) 410 | end 411 | ensure_valid_sources!(directive, source_expression) 412 | end 413 | 414 | def ensure_valid_directive!(directive) 415 | unless ContentSecurityPolicy::ALL_DIRECTIVES.include?(directive) 416 | raise ContentSecurityPolicyConfigError.new("Unknown directive #{directive}") 417 | end 418 | end 419 | 420 | def ensure_array_of_strings!(directive, value) 421 | if (!value.is_a?(Array) || !value.compact.all? { |v| v.is_a?(String) }) 422 | raise ContentSecurityPolicyConfigError.new("#{directive} must be an array of strings") 423 | end 424 | end 425 | 426 | def ensure_valid_sources!(directive, source_expression) 427 | return if source_expression == OPT_OUT 428 | source_expression.each do |expression| 429 | if ContentSecurityPolicy::DEPRECATED_SOURCE_VALUES.include?(expression) 430 | raise ContentSecurityPolicyConfigError.new("#{directive} contains an invalid keyword source (#{expression}). This value must be single quoted.") 431 | end 432 | end 433 | end 434 | 435 | def boolean?(source_expression) 436 | source_expression.is_a?(TrueClass) || source_expression.is_a?(FalseClass) 437 | end 438 | end 439 | end 440 | end 441 | -------------------------------------------------------------------------------- /lib/secure_headers/headers/referrer_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SecureHeaders 3 | class ReferrerPolicyConfigError < StandardError; end 4 | class ReferrerPolicy 5 | HEADER_NAME = "referrer-policy".freeze 6 | DEFAULT_VALUE = "origin-when-cross-origin" 7 | VALID_POLICIES = %w( 8 | no-referrer 9 | no-referrer-when-downgrade 10 | same-origin 11 | strict-origin 12 | strict-origin-when-cross-origin 13 | origin 14 | origin-when-cross-origin 15 | unsafe-url 16 | ) 17 | 18 | class << self 19 | # Public: generate an Referrer Policy header. 20 | # 21 | # Returns a default header if no configuration is provided, or a 22 | # header name and value based on the config. 23 | def make_header(config = nil, user_agent = nil) 24 | return if config == OPT_OUT 25 | config ||= DEFAULT_VALUE 26 | [HEADER_NAME, Array(config).join(", ")] 27 | end 28 | 29 | def validate_config!(config) 30 | case config 31 | when nil, OPT_OUT 32 | # valid 33 | when String, Array 34 | config = Array(config) 35 | unless config.all? { |t| t.is_a?(String) && VALID_POLICIES.include?(t.downcase) } 36 | raise ReferrerPolicyConfigError.new("Value can only be one or more of #{VALID_POLICIES.join(", ")}") 37 | end 38 | else 39 | raise TypeError.new("Must be a string or array of strings. Found #{config.class}: #{config}") 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/secure_headers/headers/strict_transport_security.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SecureHeaders 3 | class STSConfigError < StandardError; end 4 | 5 | class StrictTransportSecurity 6 | HEADER_NAME = "strict-transport-security".freeze 7 | HSTS_MAX_AGE = "631138519" 8 | DEFAULT_VALUE = "max-age=" + HSTS_MAX_AGE 9 | VALID_STS_HEADER = /\Amax-age=\d+(; includeSubdomains)?(; preload)?\z/i 10 | MESSAGE = "The config value supplied for the HSTS header was invalid. Must match #{VALID_STS_HEADER}" 11 | 12 | class << self 13 | # Public: generate an hsts header name, value pair. 14 | # 15 | # Returns a default header if no configuration is provided, or a 16 | # header name and value based on the config. 17 | def make_header(config = nil, user_agent = nil) 18 | return if config == OPT_OUT 19 | [HEADER_NAME, config || DEFAULT_VALUE] 20 | end 21 | 22 | def validate_config!(config) 23 | return if config.nil? || config == OPT_OUT 24 | raise TypeError.new("Must be a string. Found #{config.class}: #{config} #{config.class}") unless config.is_a?(String) 25 | raise STSConfigError.new(MESSAGE) unless config =~ VALID_STS_HEADER 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/secure_headers/headers/x_content_type_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SecureHeaders 3 | class XContentTypeOptionsConfigError < StandardError; end 4 | 5 | class XContentTypeOptions 6 | HEADER_NAME = "x-content-type-options".freeze 7 | DEFAULT_VALUE = "nosniff" 8 | 9 | class << self 10 | # Public: generate an X-Content-Type-Options header. 11 | # 12 | # Returns a default header if no configuration is provided, or a 13 | # header name and value based on the config. 14 | def make_header(config = nil, user_agent = nil) 15 | return if config == OPT_OUT 16 | [HEADER_NAME, config || DEFAULT_VALUE] 17 | end 18 | 19 | def validate_config!(config) 20 | return if config.nil? || config == OPT_OUT 21 | raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String) 22 | unless config.casecmp(DEFAULT_VALUE) == 0 23 | raise XContentTypeOptionsConfigError.new("Value can only be nil or 'nosniff'") 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/secure_headers/headers/x_download_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SecureHeaders 3 | class XDOConfigError < StandardError; end 4 | class XDownloadOptions 5 | HEADER_NAME = "x-download-options".freeze 6 | DEFAULT_VALUE = "noopen" 7 | 8 | class << self 9 | # Public: generate an x-download-options header. 10 | # 11 | # Returns a default header if no configuration is provided, or a 12 | # header name and value based on the config. 13 | def make_header(config = nil, user_agent = nil) 14 | return if config == OPT_OUT 15 | [HEADER_NAME, config || DEFAULT_VALUE] 16 | end 17 | 18 | def validate_config!(config) 19 | return if config.nil? || config == OPT_OUT 20 | raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String) 21 | unless config.casecmp(DEFAULT_VALUE) == 0 22 | raise XDOConfigError.new("Value can only be nil or 'noopen'") 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/secure_headers/headers/x_frame_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SecureHeaders 3 | class XFOConfigError < StandardError; end 4 | class XFrameOptions 5 | HEADER_NAME = "x-frame-options".freeze 6 | SAMEORIGIN = "sameorigin" 7 | DENY = "deny" 8 | ALLOW_FROM = "allow-from" 9 | ALLOW_ALL = "allowall" 10 | DEFAULT_VALUE = SAMEORIGIN 11 | VALID_XFO_HEADER = /\A(#{SAMEORIGIN}\z|#{DENY}\z|#{ALLOW_ALL}\z|#{ALLOW_FROM}[:\s])/i 12 | 13 | class << self 14 | # Public: generate an X-Frame-Options header. 15 | # 16 | # Returns a default header if no configuration is provided, or a 17 | # header name and value based on the config. 18 | def make_header(config = nil, user_agent = nil) 19 | return if config == OPT_OUT 20 | [HEADER_NAME, config || DEFAULT_VALUE] 21 | end 22 | 23 | def validate_config!(config) 24 | return if config.nil? || config == OPT_OUT 25 | raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String) 26 | unless config =~ VALID_XFO_HEADER 27 | raise XFOConfigError.new("Value must be SAMEORIGIN|DENY|ALLOW-FROM:|ALLOWALL") 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/secure_headers/headers/x_permitted_cross_domain_policies.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SecureHeaders 3 | class XPCDPConfigError < StandardError; end 4 | class XPermittedCrossDomainPolicies 5 | HEADER_NAME = "x-permitted-cross-domain-policies".freeze 6 | DEFAULT_VALUE = "none" 7 | VALID_POLICIES = %w(all none master-only by-content-type by-ftp-filename) 8 | 9 | class << self 10 | # Public: generate an x-permitted-cross-domain-policies header. 11 | # 12 | # Returns a default header if no configuration is provided, or a 13 | # header name and value based on the config. 14 | def make_header(config = nil, user_agent = nil) 15 | return if config == OPT_OUT 16 | [HEADER_NAME, config || DEFAULT_VALUE] 17 | end 18 | 19 | def validate_config!(config) 20 | return if config.nil? || config == OPT_OUT 21 | raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String) 22 | unless VALID_POLICIES.include?(config.downcase) 23 | raise XPCDPConfigError.new("Value can only be one of #{VALID_POLICIES.join(', ')}") 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/secure_headers/headers/x_xss_protection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SecureHeaders 3 | class XXssProtectionConfigError < StandardError; end 4 | class XXssProtection 5 | HEADER_NAME = "x-xss-protection".freeze 6 | DEFAULT_VALUE = "0".freeze 7 | VALID_X_XSS_HEADER = /\A[01](; mode=block)?(; report=.*)?\z/ 8 | 9 | class << self 10 | # Public: generate an X-Xss-Protection header. 11 | # 12 | # Returns a default header if no configuration is provided, or a 13 | # header name and value based on the config. 14 | def make_header(config = nil, user_agent = nil) 15 | return if config == OPT_OUT 16 | [HEADER_NAME, config || DEFAULT_VALUE] 17 | end 18 | 19 | def validate_config!(config) 20 | return if config.nil? || config == OPT_OUT 21 | raise TypeError.new("Must be a string. Found #{config.class}: #{config}") unless config.is_a?(String) 22 | raise XXssProtectionConfigError.new("Invalid format (see VALID_X_XSS_HEADER)") unless config.to_s =~ VALID_X_XSS_HEADER 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/secure_headers/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SecureHeaders 3 | class Middleware 4 | def initialize(app) 5 | @app = app 6 | end 7 | 8 | # merges the hash of headers into the current header set. 9 | def call(env) 10 | req = Rack::Request.new(env) 11 | status, headers, response = @app.call(env) 12 | 13 | config = SecureHeaders.config_for(req) 14 | flag_cookies!(headers, override_secure(env, config.cookies)) unless config.cookies == OPT_OUT 15 | headers.merge!(SecureHeaders.header_hash_for(req)) 16 | [status, headers, response] 17 | end 18 | 19 | private 20 | 21 | # inspired by https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L183-L194 22 | def flag_cookies!(headers, config) 23 | if cookies = headers["Set-Cookie"] 24 | # Support Rails 2.3 / Rack 1.1 arrays as headers 25 | cookies = cookies.split("\n") unless cookies.is_a?(Array) 26 | 27 | headers["Set-Cookie"] = cookies.map do |cookie| 28 | SecureHeaders::Cookie.new(cookie, config).to_s 29 | end.join("\n") 30 | end 31 | end 32 | 33 | # disable Secure cookies for non-https requests 34 | def override_secure(env, config = {}) 35 | if scheme(env) != "https" && config != OPT_OUT 36 | config[:secure] = OPT_OUT 37 | end 38 | 39 | config 40 | end 41 | 42 | # derived from https://github.com/tobmatth/rack-ssl-enforcer/blob/6c014/lib/rack/ssl-enforcer.rb#L119 43 | def scheme(env) 44 | if env["HTTPS"] == "on" || env["HTTP_X_SSL_REQUEST"] == "on" 45 | "https" 46 | elsif env["HTTP_X_FORWARDED_PROTO"] 47 | env["HTTP_X_FORWARDED_PROTO"].split(",")[0] 48 | else 49 | env["rack.url_scheme"] 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/secure_headers/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # rails 3.1+ 3 | if defined?(Rails::Railtie) 4 | module SecureHeaders 5 | class Railtie < Rails::Railtie 6 | isolate_namespace SecureHeaders if defined? isolate_namespace # rails 3.0 7 | conflicting_headers = ["x-frame-options", "x-xss-protection", 8 | "x-permitted-cross-domain-policies", "x-download-options", 9 | "x-content-type-options", "strict-transport-security", 10 | "content-security-policy", "content-security-policy-report-only", 11 | "public-key-pins", "public-key-pins-report-only", "referrer-policy"] 12 | 13 | initializer "secure_headers.middleware" do 14 | Rails.application.config.middleware.insert_before 0, SecureHeaders::Middleware 15 | end 16 | 17 | rake_tasks do 18 | load File.expand_path(File.join("..", "..", "lib", "tasks", "tasks.rake"), File.dirname(__FILE__)) 19 | end 20 | 21 | initializer "secure_headers.action_controller" do 22 | ActiveSupport.on_load(:action_controller) do 23 | include SecureHeaders 24 | 25 | unless Rails.application.config.action_dispatch.default_headers.nil? 26 | conflicting_headers.each do |header| 27 | Rails.application.config.action_dispatch.default_headers.delete(header) 28 | end 29 | end 30 | end 31 | end 32 | end 33 | end 34 | else 35 | module ActionController 36 | class Base 37 | include SecureHeaders 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/secure_headers/utils/cookies_config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SecureHeaders 3 | class CookiesConfig 4 | 5 | attr_reader :config 6 | 7 | def initialize(config) 8 | @config = config 9 | end 10 | 11 | def validate! 12 | return if config.nil? || config == SecureHeaders::OPT_OUT 13 | 14 | validate_config! 15 | validate_secure_config! unless config[:secure].nil? 16 | validate_httponly_config! unless config[:httponly].nil? 17 | validate_samesite_config! unless config[:samesite].nil? 18 | end 19 | 20 | private 21 | 22 | def validate_config! 23 | raise CookiesConfigError.new("config must be a hash.") unless is_hash?(config) 24 | end 25 | 26 | def validate_secure_config! 27 | validate_hash_or_true_or_opt_out!(:secure) 28 | validate_exclusive_use_of_hash_constraints!(config[:secure], :secure) 29 | end 30 | 31 | def validate_httponly_config! 32 | validate_hash_or_true_or_opt_out!(:httponly) 33 | validate_exclusive_use_of_hash_constraints!(config[:httponly], :httponly) 34 | end 35 | 36 | def validate_samesite_config! 37 | return if config[:samesite] == OPT_OUT 38 | raise CookiesConfigError.new("samesite cookie config must be a hash") unless is_hash?(config[:samesite]) 39 | 40 | validate_samesite_boolean_config! 41 | validate_samesite_hash_config! 42 | end 43 | 44 | # when configuring with booleans, only one enforcement is permitted 45 | def validate_samesite_boolean_config! 46 | if config[:samesite].key?(:lax) && config[:samesite][:lax].is_a?(TrueClass) && (config[:samesite].key?(:strict) || config[:samesite].key?(:none)) 47 | raise CookiesConfigError.new("samesite cookie config is invalid, combination use of booleans and Hash to configure lax with strict or no enforcement is not permitted.") 48 | elsif config[:samesite].key?(:strict) && config[:samesite][:strict].is_a?(TrueClass) && (config[:samesite].key?(:lax) || config[:samesite].key?(:none)) 49 | raise CookiesConfigError.new("samesite cookie config is invalid, combination use of booleans and Hash to configure strict with lax or no enforcement is not permitted.") 50 | elsif config[:samesite].key?(:none) && config[:samesite][:none].is_a?(TrueClass) && (config[:samesite].key?(:lax) || config[:samesite].key?(:strict)) 51 | raise CookiesConfigError.new("samesite cookie config is invalid, combination use of booleans and Hash to configure no enforcement with lax or strict is not permitted.") 52 | end 53 | end 54 | 55 | def validate_samesite_hash_config! 56 | # validate Hash-based samesite configuration 57 | if is_hash?(config[:samesite][:lax]) 58 | validate_exclusive_use_of_hash_constraints!(config[:samesite][:lax], "samesite lax") 59 | 60 | if is_hash?(config[:samesite][:strict]) 61 | validate_exclusive_use_of_hash_constraints!(config[:samesite][:strict], "samesite strict") 62 | validate_exclusive_use_of_samesite_enforcement!(:only) 63 | validate_exclusive_use_of_samesite_enforcement!(:except) 64 | end 65 | end 66 | end 67 | 68 | def validate_hash_or_true_or_opt_out!(attribute) 69 | if !(is_hash?(config[attribute]) || is_true_or_opt_out?(config[attribute])) 70 | raise CookiesConfigError.new("#{attribute} cookie config must be a hash, true, or SecureHeaders::OPT_OUT") 71 | end 72 | end 73 | 74 | # validate exclusive use of only or except but not both at the same time 75 | def validate_exclusive_use_of_hash_constraints!(conf, attribute) 76 | return unless is_hash?(conf) 77 | if conf.key?(:only) && conf.key?(:except) 78 | raise CookiesConfigError.new("#{attribute} cookie config is invalid, simultaneous use of conditional arguments `only` and `except` is not permitted.") 79 | end 80 | end 81 | 82 | # validate exclusivity of only and except members within strict and lax 83 | def validate_exclusive_use_of_samesite_enforcement!(attribute) 84 | if (intersection = (config[:samesite][:lax].fetch(attribute, []) & config[:samesite][:strict].fetch(attribute, []))).any? 85 | raise CookiesConfigError.new("samesite cookie config is invalid, cookie(s) #{intersection.join(', ')} cannot be enforced as lax and strict") 86 | end 87 | end 88 | 89 | def is_hash?(obj) 90 | obj && obj.is_a?(Hash) 91 | end 92 | 93 | def is_true_or_opt_out?(obj) 94 | obj && (obj.is_a?(TrueClass) || obj == OPT_OUT) 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/secure_headers/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SecureHeaders 4 | VERSION = "7.1.0" 5 | end 6 | -------------------------------------------------------------------------------- /lib/secure_headers/view_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module SecureHeaders 3 | module ViewHelpers 4 | include SecureHeaders::HashHelper 5 | SECURE_HEADERS_RAKE_TASK = "rake secure_headers:generate_hashes" 6 | 7 | class UnexpectedHashedScriptException < StandardError; end 8 | 9 | # Public: create a style tag using the content security policy nonce. 10 | # Instructs secure_headers to append a nonce to style-src directive. 11 | # 12 | # Returns an html-safe style tag with the nonce attribute. 13 | def nonced_style_tag(content_or_options = {}, &block) 14 | nonced_tag(:style, content_or_options, block) 15 | end 16 | 17 | # Public: create a stylesheet link tag using the content security policy nonce. 18 | # Instructs secure_headers to append a nonce to style-src directive. 19 | # 20 | # Returns an html-safe link tag with the nonce attribute. 21 | def nonced_stylesheet_link_tag(*args, &block) 22 | opts = extract_options(args).merge(nonce: _content_security_policy_nonce(:style)) 23 | 24 | stylesheet_link_tag(*args, **opts, &block) 25 | end 26 | 27 | # Public: create a script tag using the content security policy nonce. 28 | # Instructs secure_headers to append a nonce to script-src directive. 29 | # 30 | # Returns an html-safe script tag with the nonce attribute. 31 | def nonced_javascript_tag(content_or_options = {}, &block) 32 | nonced_tag(:script, content_or_options, block) 33 | end 34 | 35 | # Public: create a script src tag using the content security policy nonce. 36 | # Instructs secure_headers to append a nonce to script-src directive. 37 | # 38 | # Returns an html-safe script tag with the nonce attribute. 39 | def nonced_javascript_include_tag(*args, &block) 40 | opts = extract_options(args).merge(nonce: _content_security_policy_nonce(:script)) 41 | 42 | javascript_include_tag(*args, **opts, &block) 43 | end 44 | 45 | # Public: create a script Webpacker pack tag using the content security policy nonce. 46 | # Instructs secure_headers to append a nonce to script-src directive. 47 | # 48 | # Returns an html-safe script tag with the nonce attribute. 49 | def nonced_javascript_pack_tag(*args, &block) 50 | opts = extract_options(args).merge(nonce: _content_security_policy_nonce(:script)) 51 | 52 | javascript_pack_tag(*args, **opts, &block) 53 | end 54 | 55 | # Public: create a stylesheet Webpacker link tag using the content security policy nonce. 56 | # Instructs secure_headers to append a nonce to style-src directive. 57 | # 58 | # Returns an html-safe link tag with the nonce attribute. 59 | def nonced_stylesheet_pack_tag(*args, &block) 60 | opts = extract_options(args).merge(nonce: _content_security_policy_nonce(:style)) 61 | 62 | stylesheet_pack_tag(*args, **opts, &block) 63 | end 64 | 65 | # Public: use the content security policy nonce for this request directly. 66 | # Instructs secure_headers to append a nonce to style/script-src directives. 67 | # 68 | # Returns a non-html-safe nonce value. 69 | def _content_security_policy_nonce(type) 70 | case type 71 | when :script 72 | SecureHeaders.content_security_policy_script_nonce(@_request) 73 | when :style 74 | SecureHeaders.content_security_policy_style_nonce(@_request) 75 | end 76 | end 77 | alias_method :content_security_policy_nonce, :_content_security_policy_nonce 78 | 79 | def content_security_policy_script_nonce 80 | _content_security_policy_nonce(:script) 81 | end 82 | 83 | def content_security_policy_style_nonce 84 | _content_security_policy_nonce(:style) 85 | end 86 | 87 | ## 88 | # Checks to see if the hashed code is expected and adds the hash source 89 | # value to the current CSP. 90 | # 91 | # By default, in development/test/etc. an exception will be raised. 92 | def hashed_javascript_tag(raise_error_on_unrecognized_hash = nil, &block) 93 | hashed_tag( 94 | :script, 95 | :script_src, 96 | Configuration.instance_variable_get(:@script_hashes), 97 | raise_error_on_unrecognized_hash, 98 | block 99 | ) 100 | end 101 | 102 | def hashed_style_tag(raise_error_on_unrecognized_hash = nil, &block) 103 | hashed_tag( 104 | :style, 105 | :style_src, 106 | Configuration.instance_variable_get(:@style_hashes), 107 | raise_error_on_unrecognized_hash, 108 | block 109 | ) 110 | end 111 | 112 | private 113 | 114 | def hashed_tag(type, directive, hashes, raise_error_on_unrecognized_hash, block) 115 | if raise_error_on_unrecognized_hash.nil? 116 | raise_error_on_unrecognized_hash = ENV["RAILS_ENV"] != "production" 117 | end 118 | 119 | content = capture(&block) 120 | file_path = File.join("app", "views", self.instance_variable_get(:@virtual_path) + ".html.erb") 121 | 122 | if raise_error_on_unrecognized_hash 123 | hash_value = hash_source(content) 124 | message = unexpected_hash_error_message(file_path, content, hash_value) 125 | 126 | if hashes.nil? || hashes[file_path].nil? || !hashes[file_path].include?(hash_value) 127 | raise UnexpectedHashedScriptException.new(message) 128 | end 129 | end 130 | 131 | SecureHeaders.append_content_security_policy_directives(request, directive => hashes[file_path]) 132 | 133 | content_tag type, content 134 | end 135 | 136 | def unexpected_hash_error_message(file_path, content, hash_value) 137 | <<-EOF 138 | \n\n*** WARNING: Unrecognized hash in #{file_path}!!! Value: #{hash_value} *** 139 | #{content} 140 | *** Run #{SECURE_HEADERS_RAKE_TASK} or add the following to config/secure_headers_generated_hashes.yml:*** 141 | #{file_path}: 142 | - \"#{hash_value}\"\n\n 143 | NOTE: dynamic javascript is not supported using script hash integration 144 | on purpose. It defeats the point of using it in the first place. 145 | EOF 146 | end 147 | 148 | def nonced_tag(type, content_or_options, block) 149 | options = {} 150 | content = 151 | if block 152 | options = content_or_options 153 | capture(&block) 154 | else 155 | content_or_options.html_safe # :'( 156 | end 157 | content_tag type, content, options.merge(nonce: _content_security_policy_nonce(type)) 158 | end 159 | 160 | def extract_options(args) 161 | if args.last.is_a? Hash 162 | args.pop 163 | else 164 | {} 165 | end 166 | end 167 | end 168 | end 169 | 170 | ActiveSupport.on_load :action_view do 171 | include SecureHeaders::ViewHelpers 172 | end if defined?(ActiveSupport) 173 | -------------------------------------------------------------------------------- /lib/tasks/tasks.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | INLINE_SCRIPT_REGEX = /()(.*?)<\/script>/mx unless defined? INLINE_SCRIPT_REGEX 3 | INLINE_STYLE_REGEX = /(]*>)(.*?)<\/style>/mx unless defined? INLINE_STYLE_REGEX 4 | INLINE_HASH_SCRIPT_HELPER_REGEX = /<%=\s?hashed_javascript_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx unless defined? INLINE_HASH_SCRIPT_HELPER_REGEX 5 | INLINE_HASH_STYLE_HELPER_REGEX = /<%=\s?hashed_style_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx unless defined? INLINE_HASH_STYLE_HELPER_REGEX 6 | 7 | namespace :secure_headers do 8 | include SecureHeaders::HashHelper 9 | 10 | def is_erb?(filename) 11 | filename =~ /\.erb\Z/ 12 | end 13 | 14 | def is_mustache?(filename) 15 | filename =~ /\.mustache\Z/ 16 | end 17 | 18 | def dynamic_content?(filename, inline_script) 19 | (is_mustache?(filename) && inline_script =~ /\{\{.*\}\}/) || 20 | (is_erb?(filename) && inline_script =~ /<%.*%>/) 21 | end 22 | 23 | def find_inline_content(filename, regex, hashes, strip_trailing_whitespace) 24 | file = File.read(filename) 25 | file.scan(regex) do # TODO don't use gsub 26 | inline_script = Regexp.last_match.captures.last 27 | inline_script.gsub!(/(\r?\n)[\t ]+\z/, '\1') if strip_trailing_whitespace 28 | if dynamic_content?(filename, inline_script) 29 | puts "Looks like there's some dynamic content inside of a tag :-/" 30 | puts "That pretty much means the hash value will never match." 31 | puts "Code: " + inline_script 32 | puts "=" * 20 33 | end 34 | 35 | hashes << hash_source(inline_script) 36 | end 37 | end 38 | 39 | def generate_inline_script_hashes(filename) 40 | hashes = [] 41 | 42 | find_inline_content(filename, INLINE_SCRIPT_REGEX, hashes, false) 43 | find_inline_content(filename, INLINE_HASH_SCRIPT_HELPER_REGEX, hashes, true) 44 | 45 | hashes 46 | end 47 | 48 | def generate_inline_style_hashes(filename) 49 | hashes = [] 50 | 51 | find_inline_content(filename, INLINE_STYLE_REGEX, hashes, false) 52 | find_inline_content(filename, INLINE_HASH_STYLE_HELPER_REGEX, hashes, true) 53 | 54 | hashes 55 | end 56 | 57 | desc "Generate #{SecureHeaders::Configuration::HASH_CONFIG_FILE}" 58 | task :generate_hashes do |t, args| 59 | script_hashes = { 60 | "scripts" => {}, 61 | "styles" => {} 62 | } 63 | 64 | Dir.glob("app/{views,templates}/**/*.{erb,mustache}") do |filename| 65 | hashes = generate_inline_script_hashes(filename) 66 | if hashes.any? 67 | script_hashes["scripts"][filename] = hashes 68 | end 69 | 70 | hashes = generate_inline_style_hashes(filename) 71 | if hashes.any? 72 | script_hashes["styles"][filename] = hashes 73 | end 74 | end 75 | 76 | File.open(SecureHeaders::Configuration::HASH_CONFIG_FILE, "w") do |file| 77 | file.write(script_hashes.to_yaml) 78 | end 79 | 80 | puts "Script hashes from " + script_hashes.keys.size.to_s + " files added to #{SecureHeaders::Configuration::HASH_CONFIG_FILE}" 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /secure_headers.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("../lib", __FILE__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "secure_headers/version" 6 | 7 | Gem::Specification.new do |gem| 8 | gem.name = "secure_headers" 9 | gem.version = SecureHeaders::VERSION 10 | gem.authors = ["Neil Matatall"] 11 | gem.email = ["neil.matatall@gmail.com"] 12 | gem.summary = "Manages application of security headers with many safe defaults." 13 | gem.description = 'Add easily configured security headers to responses 14 | including content-security-policy, x-frame-options, 15 | strict-transport-security, etc.' 16 | gem.homepage = "https://github.com/github/secure_headers" 17 | gem.metadata = { 18 | "bug_tracker_uri" => "https://github.com/github/secure_headers/issues", 19 | "changelog_uri" => "https://github.com/github/secure_headers/blob/master/CHANGELOG.md", 20 | "documentation_uri" => "https://rubydoc.info/gems/secure_headers", 21 | "homepage_uri" => gem.homepage, 22 | "source_code_uri" => "https://github.com/github/secure_headers", 23 | "rubygems_mfa_required" => "true", 24 | } 25 | gem.license = "MIT" 26 | 27 | gem.files = Dir["bin/**/*", "lib/**/*", "README.md", "CHANGELOG.md", "LICENSE", "Gemfile", "secure_headers.gemspec"] 28 | gem.require_paths = ["lib"] 29 | 30 | gem.extra_rdoc_files = Dir["README.md", "CHANGELOG.md", "LICENSE"] 31 | 32 | gem.add_development_dependency "rake" 33 | end 34 | -------------------------------------------------------------------------------- /spec/lib/secure_headers/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "spec_helper" 3 | 4 | module SecureHeaders 5 | describe Configuration do 6 | before(:each) do 7 | reset_config 8 | end 9 | 10 | it "has a default config" do 11 | expect(Configuration.default).to_not be_nil 12 | end 13 | 14 | it "has an 'noop' override" do 15 | Configuration.default 16 | expect(Configuration.overrides(Configuration::NOOP_OVERRIDE)).to_not be_nil 17 | end 18 | 19 | it "dup results in a copy of the default config" do 20 | Configuration.default 21 | original_configuration = Configuration.send(:default_config) 22 | configuration = Configuration.dup 23 | expect(original_configuration).not_to be(configuration) 24 | Configuration::CONFIG_ATTRIBUTES.each do |attr| 25 | expect(original_configuration.send(attr)).to eq(configuration.send(attr)) 26 | end 27 | end 28 | 29 | it "stores an override" do 30 | Configuration.override(:test_override) do |config| 31 | config.x_frame_options = "DENY" 32 | end 33 | 34 | expect(Configuration.overrides(:test_override)).to_not be_nil 35 | end 36 | 37 | describe "#override" do 38 | it "raises on configuring an existing override" do 39 | set_override = Proc.new { 40 | Configuration.override(:test_override) do |config| 41 | config.x_frame_options = "DENY" 42 | end 43 | } 44 | 45 | set_override.call 46 | 47 | expect { set_override.call } 48 | .to raise_error(Configuration::AlreadyConfiguredError, "Configuration already exists") 49 | end 50 | 51 | it "raises when a named append with the given name exists" do 52 | Configuration.named_append(:test_override) do |config| 53 | config.x_frame_options = "DENY" 54 | end 55 | 56 | expect do 57 | Configuration.override(:test_override) do |config| 58 | config.x_frame_options = "SAMEORIGIN" 59 | end 60 | end.to raise_error(Configuration::AlreadyConfiguredError, "Configuration already exists") 61 | end 62 | end 63 | 64 | describe "#named_append" do 65 | it "raises on configuring an existing append" do 66 | set_override = Proc.new { 67 | Configuration.named_append(:test_override) do |config| 68 | config.x_frame_options = "DENY" 69 | end 70 | } 71 | 72 | set_override.call 73 | 74 | expect { set_override.call } 75 | .to raise_error(Configuration::AlreadyConfiguredError, "Configuration already exists") 76 | end 77 | 78 | it "raises when an override with the given name exists" do 79 | Configuration.override(:test_override) do |config| 80 | config.x_frame_options = "DENY" 81 | end 82 | 83 | expect do 84 | Configuration.named_append(:test_override) do |config| 85 | config.x_frame_options = "SAMEORIGIN" 86 | end 87 | end.to raise_error(Configuration::AlreadyConfiguredError, "Configuration already exists") 88 | end 89 | end 90 | 91 | it "deprecates the secure_cookies configuration" do 92 | expect { 93 | Configuration.default do |config| 94 | config.secure_cookies = true 95 | end 96 | }.to raise_error(ArgumentError) 97 | end 98 | 99 | it "gives cookies a default config" do 100 | expect(Configuration.default.cookies).to eq({httponly: true, secure: true, samesite: {lax: true}}) 101 | end 102 | 103 | it "allows OPT_OUT" do 104 | Configuration.default do |config| 105 | config.cookies = OPT_OUT 106 | end 107 | 108 | config = Configuration.dup 109 | expect(config.cookies).to eq(OPT_OUT) 110 | end 111 | 112 | it "allows me to be explicit too" do 113 | Configuration.default do |config| 114 | config.cookies = {httponly: true, secure: true, samesite: {lax: false}} 115 | end 116 | 117 | config = Configuration.dup 118 | expect(config.cookies).to eq({httponly: true, secure: true, samesite: {lax: false}}) 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /spec/lib/secure_headers/headers/clear_site_data_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "spec_helper" 3 | 4 | module SecureHeaders 5 | describe ClearSiteData do 6 | describe "make_header" do 7 | it "returns nil with nil config" do 8 | expect(described_class.make_header).to be_nil 9 | end 10 | 11 | it "returns nil with empty config" do 12 | expect(described_class.make_header([])).to be_nil 13 | end 14 | 15 | it "returns nil with opt-out config" do 16 | expect(described_class.make_header(OPT_OUT)).to be_nil 17 | end 18 | 19 | it "returns all types with `true` config" do 20 | name, value = described_class.make_header(true) 21 | 22 | expect(name).to eq(ClearSiteData::HEADER_NAME) 23 | expect(value).to eq( 24 | %("cache", "cookies", "storage", "executionContexts") 25 | ) 26 | end 27 | 28 | it "returns specified types" do 29 | name, value = described_class.make_header(["foo", "bar"]) 30 | 31 | expect(name).to eq(ClearSiteData::HEADER_NAME) 32 | expect(value).to eq(%("foo", "bar")) 33 | end 34 | end 35 | 36 | describe "validate_config!" do 37 | it "succeeds for `true` config" do 38 | expect do 39 | described_class.validate_config!(true) 40 | end.not_to raise_error 41 | end 42 | 43 | it "succeeds for `nil` config" do 44 | expect do 45 | described_class.validate_config!(nil) 46 | end.not_to raise_error 47 | end 48 | 49 | it "succeeds for opt-out config" do 50 | expect do 51 | described_class.validate_config!(OPT_OUT) 52 | end.not_to raise_error 53 | end 54 | 55 | it "succeeds for empty config" do 56 | expect do 57 | described_class.validate_config!([]) 58 | end.not_to raise_error 59 | end 60 | 61 | it "succeeds for Array of Strings config" do 62 | expect do 63 | described_class.validate_config!(["foo"]) 64 | end.not_to raise_error 65 | end 66 | 67 | it "fails for Array of non-String config" do 68 | expect do 69 | described_class.validate_config!([1]) 70 | end.to raise_error(ClearSiteDataConfigError) 71 | end 72 | 73 | it "fails for other types of config" do 74 | expect do 75 | described_class.validate_config!(:cookies) 76 | end.to raise_error(ClearSiteDataConfigError) 77 | end 78 | end 79 | 80 | describe "make_header_value" do 81 | it "returns a string of quoted values that are comma separated" do 82 | value = described_class.make_header_value(["foo", "bar"]) 83 | expect(value).to eq(%("foo", "bar")) 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/lib/secure_headers/headers/content_security_policy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "spec_helper" 3 | 4 | module SecureHeaders 5 | describe ContentSecurityPolicy do 6 | let (:default_opts) do 7 | { 8 | default_src: %w(https:), 9 | img_src: %w(https: data:), 10 | script_src: %w('unsafe-inline' 'unsafe-eval' https: data:), 11 | style_src: %w('unsafe-inline' https: about:), 12 | report_uri: %w(/csp_report) 13 | } 14 | end 15 | 16 | describe "#name" do 17 | context "when in report-only mode" do 18 | specify { expect(ContentSecurityPolicy.new(default_opts.merge(report_only: true)).name).to eq(ContentSecurityPolicyReportOnlyConfig::HEADER_NAME) } 19 | end 20 | 21 | context "when in enforce mode" do 22 | specify { expect(ContentSecurityPolicy.new(default_opts).name).to eq(ContentSecurityPolicyConfig::HEADER_NAME) } 23 | end 24 | end 25 | 26 | describe "#value" do 27 | it "uses a safe but non-breaking default value" do 28 | expect(ContentSecurityPolicy.new.value).to eq("default-src https:; form-action 'self'; img-src https: data: 'self'; object-src 'none'; script-src https:; style-src 'self' 'unsafe-inline' https:") 29 | end 30 | 31 | it "deprecates and escapes semicolons in directive source lists" do 32 | expect(Kernel).to receive(:warn).with(%(frame_ancestors contains a ; in "google.com;script-src *;.;" which will raise an error in future versions. It has been replaced with a blank space.)) 33 | expect(ContentSecurityPolicy.new(frame_ancestors: %w(https://google.com;script-src https://*;.;)).value).to eq("frame-ancestors google.com script-src * .") 34 | end 35 | 36 | it "deprecates and escapes semicolons in directive source lists" do 37 | expect(Kernel).to receive(:warn).with(%(frame_ancestors contains a \n in "\\nfoo.com\\nhacked" which will raise an error in future versions. It has been replaced with a blank space.)) 38 | expect(ContentSecurityPolicy.new(frame_ancestors: ["\nfoo.com\nhacked"]).value).to eq("frame-ancestors foo.com hacked") 39 | end 40 | 41 | it "discards 'none' values if any other source expressions are present" do 42 | csp = ContentSecurityPolicy.new(default_opts.merge(child_src: %w('self' 'none'))) 43 | expect(csp.value).not_to include("'none'") 44 | end 45 | 46 | it "discards source expressions (besides unsafe-* and non-host source values) when * is present" do 47 | csp = ContentSecurityPolicy.new(default_src: %w(* 'unsafe-inline' 'unsafe-eval' http: https: example.org data: blob:)) 48 | expect(csp.value).to eq("default-src * 'unsafe-inline' 'unsafe-eval' data: blob:") 49 | end 50 | 51 | it "does not minify source expressions based on overlapping wildcards" do 52 | config = { 53 | default_src: %w(a.example.org b.example.org *.example.org https://*.example.org) 54 | } 55 | csp = ContentSecurityPolicy.new(config) 56 | expect(csp.value).to eq("default-src a.example.org b.example.org *.example.org") 57 | end 58 | 59 | it "removes http/s schemes from hosts" do 60 | csp = ContentSecurityPolicy.new(default_src: %w(https://example.org)) 61 | expect(csp.value).to eq("default-src example.org") 62 | end 63 | 64 | it "does not build directives with a value of OPT_OUT (and bypasses directive requirements)" do 65 | csp = ContentSecurityPolicy.new(default_src: %w(https://example.org), script_src: OPT_OUT) 66 | expect(csp.value).to eq("default-src example.org") 67 | end 68 | 69 | it "does not remove schemes from report-uri values" do 70 | csp = ContentSecurityPolicy.new(default_src: %w(https:), report_uri: %w(https://example.org)) 71 | expect(csp.value).to eq("default-src https:; report-uri https://example.org") 72 | end 73 | 74 | it "does not remove schemes when :preserve_schemes is true" do 75 | csp = ContentSecurityPolicy.new(default_src: %w(https://example.org), preserve_schemes: true) 76 | expect(csp.value).to eq("default-src https://example.org") 77 | end 78 | 79 | it "removes nil from source lists" do 80 | csp = ContentSecurityPolicy.new(default_src: ["https://example.org", nil]) 81 | expect(csp.value).to eq("default-src example.org") 82 | end 83 | 84 | it "does not add a directive if the value is an empty array (or all nil)" do 85 | csp = ContentSecurityPolicy.new(default_src: ["https://example.org"], script_src: [nil]) 86 | expect(csp.value).to eq("default-src example.org") 87 | end 88 | 89 | it "does not add a directive if the value is nil" do 90 | csp = ContentSecurityPolicy.new(default_src: ["https://example.org"], script_src: nil) 91 | expect(csp.value).to eq("default-src example.org") 92 | end 93 | 94 | it "does add a boolean directive if the value is true" do 95 | csp = ContentSecurityPolicy.new(default_src: ["https://example.org"], upgrade_insecure_requests: true) 96 | expect(csp.value).to eq("default-src example.org; upgrade-insecure-requests") 97 | end 98 | 99 | it "does not add a boolean directive if the value is false" do 100 | csp = ContentSecurityPolicy.new(default_src: ["https://example.org"], upgrade_insecure_requests: false) 101 | expect(csp.value).to eq("default-src example.org") 102 | end 103 | 104 | it "handles wildcard subdomain with wildcard port" do 105 | csp = ContentSecurityPolicy.new(default_src: %w(https://*.example.org:*)) 106 | expect(csp.value).to eq("default-src *.example.org:*") 107 | end 108 | 109 | it "deduplicates source expressions that match exactly (after scheme stripping)" do 110 | csp = ContentSecurityPolicy.new(default_src: %w(example.org https://example.org example.org)) 111 | expect(csp.value).to eq("default-src example.org") 112 | end 113 | 114 | it "does not deduplicate non-matching schema source expressions" do 115 | csp = ContentSecurityPolicy.new(default_src: %w(*.example.org wss://example.example.org)) 116 | expect(csp.value).to eq("default-src *.example.org wss://example.example.org") 117 | end 118 | 119 | it "creates maximally strict sandbox policy when passed no sandbox token values" do 120 | csp = ContentSecurityPolicy.new(default_src: %w(example.org), sandbox: []) 121 | expect(csp.value).to eq("default-src example.org; sandbox") 122 | end 123 | 124 | it "creates maximally strict sandbox policy when passed true" do 125 | csp = ContentSecurityPolicy.new(default_src: %w(example.org), sandbox: true) 126 | expect(csp.value).to eq("default-src example.org; sandbox") 127 | end 128 | 129 | it "creates sandbox policy when passed valid sandbox token values" do 130 | csp = ContentSecurityPolicy.new(default_src: %w(example.org), sandbox: %w(allow-forms allow-scripts)) 131 | expect(csp.value).to eq("default-src example.org; sandbox allow-forms allow-scripts") 132 | end 133 | 134 | it "does not emit a warning when using frame-src" do 135 | expect(Kernel).to_not receive(:warn) 136 | ContentSecurityPolicy.new(default_src: %w('self'), frame_src: %w('self')).value 137 | end 138 | 139 | it "allows script as a require-sri-src" do 140 | csp = ContentSecurityPolicy.new(default_src: %w('self'), require_sri_for: %w(script)) 141 | expect(csp.value).to eq("default-src 'self'; require-sri-for script") 142 | end 143 | 144 | it "allows style as a require-sri-src" do 145 | csp = ContentSecurityPolicy.new(default_src: %w('self'), require_sri_for: %w(style)) 146 | expect(csp.value).to eq("default-src 'self'; require-sri-for style") 147 | end 148 | 149 | it "allows script and style as a require-sri-src" do 150 | csp = ContentSecurityPolicy.new(default_src: %w('self'), require_sri_for: %w(script style)) 151 | expect(csp.value).to eq("default-src 'self'; require-sri-for script style") 152 | end 153 | 154 | it "allows style as a require-trusted-types-for source" do 155 | csp = ContentSecurityPolicy.new(default_src: %w('self'), require_trusted_types_for: %w(script)) 156 | expect(csp.value).to eq("default-src 'self'; require-trusted-types-for script") 157 | end 158 | 159 | it "includes prefetch-src" do 160 | csp = ContentSecurityPolicy.new(default_src: %w('self'), prefetch_src: %w(foo.com)) 161 | expect(csp.value).to eq("default-src 'self'; prefetch-src foo.com") 162 | end 163 | 164 | it "includes navigate-to" do 165 | csp = ContentSecurityPolicy.new(default_src: %w('self'), navigate_to: %w(foo.com)) 166 | expect(csp.value).to eq("default-src 'self'; navigate-to foo.com") 167 | end 168 | 169 | it "supports strict-dynamic" do 170 | csp = ContentSecurityPolicy.new({default_src: %w('self'), script_src: [ContentSecurityPolicy::STRICT_DYNAMIC], script_nonce: 123456}) 171 | expect(csp.value).to eq("default-src 'self'; script-src 'strict-dynamic' 'nonce-123456' 'unsafe-inline'") 172 | end 173 | 174 | it "supports strict-dynamic and opting out of the appended 'unsafe-inline'" do 175 | csp = ContentSecurityPolicy.new({default_src: %w('self'), script_src: [ContentSecurityPolicy::STRICT_DYNAMIC], script_nonce: 123456, disable_nonce_backwards_compatibility: true }) 176 | expect(csp.value).to eq("default-src 'self'; script-src 'strict-dynamic' 'nonce-123456'") 177 | end 178 | 179 | it "supports script-src-elem directive" do 180 | csp = ContentSecurityPolicy.new({script_src: %w('self'), script_src_elem: %w('self')}) 181 | expect(csp.value).to eq("script-src 'self'; script-src-elem 'self'") 182 | end 183 | 184 | it "supports script-src-attr directive" do 185 | csp = ContentSecurityPolicy.new({script_src: %w('self'), script_src_attr: %w('self')}) 186 | expect(csp.value).to eq("script-src 'self'; script-src-attr 'self'") 187 | end 188 | 189 | it "supports style-src-elem directive" do 190 | csp = ContentSecurityPolicy.new({style_src: %w('self'), style_src_elem: %w('self')}) 191 | expect(csp.value).to eq("style-src 'self'; style-src-elem 'self'") 192 | end 193 | 194 | it "supports style-src-attr directive" do 195 | csp = ContentSecurityPolicy.new({style_src: %w('self'), style_src_attr: %w('self')}) 196 | expect(csp.value).to eq("style-src 'self'; style-src-attr 'self'") 197 | end 198 | 199 | it "supports trusted-types directive" do 200 | csp = ContentSecurityPolicy.new({trusted_types: %w(blahblahpolicy)}) 201 | expect(csp.value).to eq("trusted-types blahblahpolicy") 202 | end 203 | 204 | it "supports trusted-types directive with 'none'" do 205 | csp = ContentSecurityPolicy.new({trusted_types: %w('none')}) 206 | expect(csp.value).to eq("trusted-types 'none'") 207 | end 208 | 209 | it "allows duplicate policy names in trusted-types directive" do 210 | csp = ContentSecurityPolicy.new({trusted_types: %w(blahblahpolicy 'allow-duplicates')}) 211 | expect(csp.value).to eq("trusted-types blahblahpolicy 'allow-duplicates'") 212 | end 213 | end 214 | end 215 | end 216 | -------------------------------------------------------------------------------- /spec/lib/secure_headers/headers/cookie_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "spec_helper" 3 | 4 | module SecureHeaders 5 | describe Cookie do 6 | let(:raw_cookie) { "_session=thisisatest" } 7 | 8 | it "does not tamper with cookies when using OPT_OUT is used" do 9 | cookie = Cookie.new(raw_cookie, OPT_OUT) 10 | expect(cookie.to_s).to eq(raw_cookie) 11 | end 12 | 13 | it "applies httponly, secure, and samesite by default" do 14 | cookie = Cookie.new(raw_cookie, nil) 15 | expect(cookie.to_s).to eq("_session=thisisatest; secure; HttpOnly; SameSite=Lax") 16 | end 17 | 18 | it "preserves existing attributes" do 19 | cookie = Cookie.new("_session=thisisatest; secure", secure: true, httponly: OPT_OUT, samesite: OPT_OUT) 20 | expect(cookie.to_s).to eq("_session=thisisatest; secure") 21 | end 22 | 23 | it "prevents duplicate flagging of attributes" do 24 | cookie = Cookie.new("_session=thisisatest; secure", secure: true, httponly: OPT_OUT) 25 | expect(cookie.to_s.scan(/secure/i).count).to eq(1) 26 | end 27 | 28 | context "Secure cookies" do 29 | context "when configured with a boolean" do 30 | it "flags cookies as Secure" do 31 | cookie = Cookie.new(raw_cookie, secure: true, httponly: OPT_OUT, samesite: OPT_OUT) 32 | expect(cookie.to_s).to eq("_session=thisisatest; secure") 33 | end 34 | end 35 | 36 | context "when configured with a Hash" do 37 | it "flags cookies as Secure when whitelisted" do 38 | cookie = Cookie.new(raw_cookie, secure: { only: ["_session"]}, httponly: OPT_OUT, samesite: OPT_OUT) 39 | expect(cookie.to_s).to eq("_session=thisisatest; secure") 40 | end 41 | 42 | it "does not flag cookies as Secure when excluded" do 43 | cookie = Cookie.new(raw_cookie, secure: { except: ["_session"] }, httponly: OPT_OUT, samesite: OPT_OUT) 44 | expect(cookie.to_s).to eq("_session=thisisatest") 45 | end 46 | end 47 | end 48 | 49 | context "HttpOnly cookies" do 50 | context "when configured with a boolean" do 51 | it "flags cookies as HttpOnly" do 52 | cookie = Cookie.new(raw_cookie, httponly: true, secure: OPT_OUT, samesite: OPT_OUT) 53 | expect(cookie.to_s).to eq("_session=thisisatest; HttpOnly") 54 | end 55 | end 56 | 57 | context "when configured with a Hash" do 58 | it "flags cookies as HttpOnly when whitelisted" do 59 | cookie = Cookie.new(raw_cookie, httponly: { only: ["_session"]}, secure: OPT_OUT, samesite: OPT_OUT) 60 | expect(cookie.to_s).to eq("_session=thisisatest; HttpOnly") 61 | end 62 | 63 | it "does not flag cookies as HttpOnly when excluded" do 64 | cookie = Cookie.new(raw_cookie, httponly: { except: ["_session"] }, secure: OPT_OUT, samesite: OPT_OUT) 65 | expect(cookie.to_s).to eq("_session=thisisatest") 66 | end 67 | end 68 | end 69 | 70 | context "SameSite cookies" do 71 | %w(None Lax Strict).each do |flag| 72 | it "flags SameSite=#{flag}" do 73 | cookie = Cookie.new(raw_cookie, samesite: { flag.downcase.to_sym => { only: ["_session"] } }, secure: OPT_OUT, httponly: OPT_OUT) 74 | expect(cookie.to_s).to eq("_session=thisisatest; SameSite=#{flag}") 75 | end 76 | 77 | it "flags SameSite=#{flag} when configured with a boolean" do 78 | cookie = Cookie.new(raw_cookie, samesite: { flag.downcase.to_sym => true}, secure: OPT_OUT, httponly: OPT_OUT) 79 | expect(cookie.to_s).to eq("_session=thisisatest; SameSite=#{flag}") 80 | end 81 | 82 | it "does not flag cookies as SameSite=#{flag} when excluded" do 83 | cookie = Cookie.new(raw_cookie, samesite: { flag.downcase.to_sym => { except: ["_session"] } }, secure: OPT_OUT, httponly: OPT_OUT) 84 | expect(cookie.to_s).to eq("_session=thisisatest") 85 | end 86 | end 87 | 88 | it "flags SameSite=Strict when configured with a boolean" do 89 | cookie = Cookie.new(raw_cookie, {samesite: { strict: true}, secure: OPT_OUT, httponly: OPT_OUT}) 90 | expect(cookie.to_s).to eq("_session=thisisatest; SameSite=Strict") 91 | end 92 | 93 | it "flags properly when both lax and strict are configured" do 94 | raw_cookie = "_session=thisisatest" 95 | cookie = Cookie.new(raw_cookie, samesite: { strict: { only: ["_session"] }, lax: { only: ["_additional_session"] } }, secure: OPT_OUT, httponly: OPT_OUT) 96 | expect(cookie.to_s).to eq("_session=thisisatest; SameSite=Strict") 97 | end 98 | 99 | it "ignores configuration if the cookie is already flagged" do 100 | raw_cookie = "_session=thisisatest; SameSite=Strict" 101 | cookie = Cookie.new(raw_cookie, samesite: { lax: true }, secure: OPT_OUT, httponly: OPT_OUT) 102 | expect(cookie.to_s).to eq(raw_cookie) 103 | end 104 | 105 | it "samesite: true sets all cookies to samesite=lax" do 106 | raw_cookie = "_session=thisisatest" 107 | cookie = Cookie.new(raw_cookie, samesite: true, secure: OPT_OUT, httponly: OPT_OUT) 108 | expect(cookie.to_s).to eq("_session=thisisatest; SameSite=Lax") 109 | end 110 | end 111 | end 112 | 113 | context "with an invalid configuration" do 114 | it "raises an exception when not configured with a Hash" do 115 | expect do 116 | Cookie.validate_config!("configuration") 117 | end.to raise_error(CookiesConfigError) 118 | end 119 | 120 | it "raises an exception when configured without a boolean(true or OPT_OUT)/Hash" do 121 | expect do 122 | Cookie.validate_config!(secure: "true") 123 | end.to raise_error(CookiesConfigError) 124 | end 125 | 126 | it "raises an exception when configured with false" do 127 | expect do 128 | Cookie.validate_config!(secure: false) 129 | end.to raise_error(CookiesConfigError) 130 | end 131 | 132 | it "raises an exception when both only and except filters are provided" do 133 | expect do 134 | Cookie.validate_config!(secure: { only: [], except: [] }) 135 | end.to raise_error(CookiesConfigError) 136 | end 137 | 138 | it "raises an exception when SameSite is not configured with a Hash" do 139 | expect do 140 | Cookie.validate_config!(samesite: true) 141 | end.to raise_error(CookiesConfigError) 142 | end 143 | 144 | cookie_options = %i(none lax strict) 145 | cookie_options.each do |flag| 146 | (cookie_options - [flag]).each do |other_flag| 147 | it "raises an exception when SameSite #{flag} and #{other_flag} enforcement modes are configured with booleans" do 148 | expect do 149 | Cookie.validate_config!(samesite: { flag => true, other_flag => true}) 150 | end.to raise_error(CookiesConfigError) 151 | end 152 | end 153 | end 154 | 155 | it "raises an exception when SameSite lax and strict enforcement modes are configured with booleans" do 156 | expect do 157 | Cookie.validate_config!(samesite: { lax: true, strict: { only: ["_anything"] } }) 158 | end.to raise_error(CookiesConfigError) 159 | end 160 | 161 | it "raises an exception when both only and except filters are provided to SameSite configurations" do 162 | expect do 163 | Cookie.validate_config!(samesite: { lax: { only: ["_anything"], except: ["_anythingelse"] } }) 164 | end.to raise_error(CookiesConfigError) 165 | end 166 | 167 | it "raises an exception when both lax and strict only filters are provided to SameSite configurations" do 168 | expect do 169 | Cookie.validate_config!(samesite: { lax: { only: ["_anything"] }, strict: { only: ["_anything"] } }) 170 | end.to raise_error(CookiesConfigError) 171 | end 172 | 173 | it "raises an exception when both lax and strict only filters are provided to SameSite configurations" do 174 | expect do 175 | Cookie.validate_config!(samesite: { lax: { except: ["_anything"] }, strict: { except: ["_anything"] } }) 176 | end.to raise_error(CookiesConfigError) 177 | end 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /spec/lib/secure_headers/headers/expect_certificate_transparency_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "spec_helper" 3 | 4 | module SecureHeaders 5 | describe ExpectCertificateTransparency do 6 | specify { expect(ExpectCertificateTransparency.new(max_age: 1234, enforce: true).value).to eq("enforce, max-age=1234") } 7 | specify { expect(ExpectCertificateTransparency.new(max_age: 1234, enforce: false).value).to eq("max-age=1234") } 8 | specify { expect(ExpectCertificateTransparency.new(max_age: 1234, enforce: "yolocopter").value).to eq("max-age=1234") } 9 | specify { expect(ExpectCertificateTransparency.new(max_age: 1234, report_uri: "https://report-uri.io/expect-ct").value).to eq("max-age=1234, report-uri=\"https://report-uri.io/expect-ct\"") } 10 | specify do 11 | config = { enforce: true, max_age: 1234, report_uri: "https://report-uri.io/expect-ct" } 12 | header_value = "enforce, max-age=1234, report-uri=\"https://report-uri.io/expect-ct\"" 13 | expect(ExpectCertificateTransparency.new(config).value).to eq(header_value) 14 | end 15 | 16 | context "with an invalid configuration" do 17 | it "raises an exception when configuration isn't a hash" do 18 | expect do 19 | ExpectCertificateTransparency.validate_config!(%w(a)) 20 | end.to raise_error(ExpectCertificateTransparencyConfigError) 21 | end 22 | 23 | it "raises an exception when max-age is not provided" do 24 | expect do 25 | ExpectCertificateTransparency.validate_config!(foo: "bar") 26 | end.to raise_error(ExpectCertificateTransparencyConfigError) 27 | end 28 | 29 | it "raises an exception with an invalid max-age" do 30 | expect do 31 | ExpectCertificateTransparency.validate_config!(max_age: "abc123") 32 | end.to raise_error(ExpectCertificateTransparencyConfigError) 33 | end 34 | 35 | it "raises an exception with an invalid enforce value" do 36 | expect do 37 | ExpectCertificateTransparency.validate_config!(enforce: "brokenstring") 38 | end.to raise_error(ExpectCertificateTransparencyConfigError) 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/lib/secure_headers/headers/policy_management_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "spec_helper" 3 | 4 | module SecureHeaders 5 | describe PolicyManagement do 6 | before(:each) do 7 | reset_config 8 | Configuration.default 9 | end 10 | 11 | let (:default_opts) do 12 | { 13 | default_src: %w(https:), 14 | img_src: %w(https: data:), 15 | script_src: %w('unsafe-inline' 'unsafe-eval' https: data:), 16 | style_src: %w('unsafe-inline' https: about:), 17 | report_uri: %w(/csp_report) 18 | } 19 | end 20 | 21 | describe "#validate_config!" do 22 | it "accepts all keys" do 23 | # (pulled from README) 24 | config = { 25 | # "meta" values. these will shape the header, but the values are not included in the header. 26 | report_only: false, 27 | preserve_schemes: true, # default: false. Schemes are removed from host sources to save bytes and discourage mixed content. 28 | 29 | # directive values: these values will directly translate into source directives 30 | default_src: %w(https: 'self'), 31 | 32 | base_uri: %w('self'), 33 | connect_src: %w(wss:), 34 | child_src: %w('self' *.twimg.com itunes.apple.com), 35 | font_src: %w('self' data:), 36 | form_action: %w('self' github.com), 37 | frame_ancestors: %w('none'), 38 | frame_src: %w('self' *.twimg.com itunes.apple.com), 39 | img_src: %w(mycdn.com data:), 40 | manifest_src: %w(manifest.com), 41 | media_src: %w(utoob.com), 42 | navigate_to: %w(netscape.com), 43 | object_src: %w('self'), 44 | plugin_types: %w(application/x-shockwave-flash), 45 | prefetch_src: %w(fetch.com), 46 | require_sri_for: %w(script style), 47 | require_trusted_types_for: %w('script'), 48 | script_src: %w('self'), 49 | style_src: %w('unsafe-inline'), 50 | upgrade_insecure_requests: true, # see https://www.w3.org/TR/upgrade-insecure-requests/ 51 | worker_src: %w(worker.com), 52 | script_src_elem: %w(example.com), 53 | script_src_attr: %w(example.com), 54 | style_src_elem: %w(example.com), 55 | style_src_attr: %w(example.com), 56 | trusted_types: %w(abcpolicy), 57 | 58 | report_uri: %w(https://example.com/uri-directive), 59 | } 60 | 61 | ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(config)) 62 | end 63 | 64 | it "requires a :default_src value" do 65 | expect do 66 | ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(script_src: %w('self'))) 67 | end.to raise_error(ContentSecurityPolicyConfigError) 68 | end 69 | 70 | it "requires a :script_src value" do 71 | expect do 72 | ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_src: %w('self'))) 73 | end.to raise_error(ContentSecurityPolicyConfigError) 74 | end 75 | 76 | it "accepts OPT_OUT as a script-src value" do 77 | expect do 78 | ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_src: %w('self'), script_src: OPT_OUT)) 79 | end.to_not raise_error 80 | end 81 | 82 | it "requires :report_only to be a truthy value" do 83 | expect do 84 | ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(report_only: "steve"))) 85 | end.to raise_error(ContentSecurityPolicyConfigError) 86 | end 87 | 88 | it "requires :preserve_schemes to be a truthy value" do 89 | expect do 90 | ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(preserve_schemes: "steve"))) 91 | end.to raise_error(ContentSecurityPolicyConfigError) 92 | end 93 | 94 | it "requires :upgrade_insecure_requests to be a boolean value" do 95 | expect do 96 | ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(upgrade_insecure_requests: "steve"))) 97 | end.to raise_error(ContentSecurityPolicyConfigError) 98 | end 99 | 100 | it "requires all source lists to be an array of strings" do 101 | expect do 102 | ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_src: "steve")) 103 | end.to raise_error(ContentSecurityPolicyConfigError) 104 | end 105 | 106 | it "allows nil values" do 107 | expect do 108 | ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_src: %w('self'), script_src: ["https:", nil])) 109 | end.to_not raise_error 110 | end 111 | 112 | it "rejects unknown directives / config" do 113 | expect do 114 | ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_src: %w('self'), default_src_totally_mispelled: "steve")) 115 | end.to raise_error(ContentSecurityPolicyConfigError) 116 | end 117 | 118 | it "rejects style for trusted types" do 119 | expect do 120 | ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(style_src: %w('self'), require_trusted_types_for: %w(script style), trusted_types: %w(abcpolicy)))) 121 | end 122 | end 123 | 124 | # this is mostly to ensure people don't use the antiquated shorthands common in other configs 125 | it "performs light validation on source lists" do 126 | expect do 127 | ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_src: %w(self none inline eval), script_src: %w('self'))) 128 | end.to raise_error(ContentSecurityPolicyConfigError) 129 | end 130 | 131 | it "rejects anything not of the form allow-* as a sandbox value" do 132 | expect do 133 | ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(sandbox: ["steve"]))) 134 | end.to raise_error(ContentSecurityPolicyConfigError) 135 | end 136 | 137 | it "accepts anything of the form allow-* as a sandbox value " do 138 | expect do 139 | ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(sandbox: ["allow-foo"]))) 140 | end.to_not raise_error 141 | end 142 | 143 | it "accepts true as a sandbox policy" do 144 | expect do 145 | ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(sandbox: true))) 146 | end.to_not raise_error 147 | end 148 | 149 | it "rejects anything not of the form type/subtype as a plugin-type value" do 150 | expect do 151 | ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(plugin_types: ["steve"]))) 152 | end.to raise_error(ContentSecurityPolicyConfigError) 153 | end 154 | 155 | it "accepts anything of the form type/subtype as a plugin-type value " do 156 | expect do 157 | ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(plugin_types: ["application/pdf"]))) 158 | end.to_not raise_error 159 | end 160 | 161 | it "doesn't allow report_only to be set in a non-report-only config" do 162 | expect do 163 | ContentSecurityPolicy.validate_config!(ContentSecurityPolicyConfig.new(default_opts.merge(report_only: true))) 164 | end.to raise_error(ContentSecurityPolicyConfigError) 165 | end 166 | 167 | it "allows report_only to be set in a report-only config" do 168 | expect do 169 | ContentSecurityPolicy.validate_config!(ContentSecurityPolicyReportOnlyConfig.new(default_opts.merge(report_only: true))) 170 | end.to_not raise_error 171 | end 172 | end 173 | 174 | describe "#combine_policies" do 175 | before(:each) do 176 | reset_config 177 | end 178 | it "combines the default-src value with the override if the directive was unconfigured" do 179 | Configuration.default do |config| 180 | config.csp = { 181 | default_src: %w(https:), 182 | script_src: %w('self'), 183 | } 184 | end 185 | default_policy = Configuration.dup 186 | combined_config = ContentSecurityPolicy.combine_policies(default_policy.csp.to_h, style_src: %w(anothercdn.com)) 187 | csp = ContentSecurityPolicy.new(combined_config) 188 | expect(csp.name).to eq(ContentSecurityPolicyConfig::HEADER_NAME) 189 | expect(csp.value).to eq("default-src https:; script-src 'self'; style-src https: anothercdn.com") 190 | end 191 | 192 | it "combines directives where the original value is nil and the hash is frozen" do 193 | Configuration.default do |config| 194 | config.csp = { 195 | default_src: %w('self'), 196 | script_src: %w('self'), 197 | report_only: false 198 | }.freeze 199 | end 200 | report_uri = "https://report-uri.io/asdf" 201 | default_policy = Configuration.dup 202 | combined_config = ContentSecurityPolicy.combine_policies(default_policy.csp.to_h, report_uri: [report_uri]) 203 | csp = ContentSecurityPolicy.new(combined_config) 204 | expect(csp.value).to include("report-uri #{report_uri}") 205 | end 206 | 207 | it "does not combine the default-src value for directives that don't fall back to default sources" do 208 | Configuration.default do |config| 209 | config.csp = { 210 | default_src: %w('self'), 211 | script_src: %w('self'), 212 | report_only: false 213 | }.freeze 214 | end 215 | non_default_source_additions = ContentSecurityPolicy::NON_FETCH_SOURCES.each_with_object({}) do |directive, hash| 216 | hash[directive] = %w("http://example.org) 217 | end 218 | default_policy = Configuration.dup 219 | combined_config = ContentSecurityPolicy.combine_policies(default_policy.csp.to_h, non_default_source_additions) 220 | 221 | ContentSecurityPolicy::NON_FETCH_SOURCES.each do |directive| 222 | expect(combined_config[directive]).to eq(%w("http://example.org)) 223 | end 224 | end 225 | 226 | it "overrides the report_only flag" do 227 | Configuration.default do |config| 228 | config.csp = { 229 | default_src: %w('self'), 230 | script_src: %w('self'), 231 | report_only: false 232 | } 233 | end 234 | default_policy = Configuration.dup 235 | combined_config = ContentSecurityPolicy.combine_policies(default_policy.csp.to_h, report_only: true) 236 | csp = ContentSecurityPolicy.new(combined_config) 237 | expect(csp.name).to eq(ContentSecurityPolicyReportOnlyConfig::HEADER_NAME) 238 | end 239 | 240 | it "overrides the :upgrade_insecure_requests flag" do 241 | Configuration.default do |config| 242 | config.csp = { 243 | default_src: %w(https:), 244 | script_src: %w('self'), 245 | upgrade_insecure_requests: false 246 | } 247 | end 248 | default_policy = Configuration.dup 249 | combined_config = ContentSecurityPolicy.combine_policies(default_policy.csp.to_h, upgrade_insecure_requests: true) 250 | csp = ContentSecurityPolicy.new(combined_config) 251 | expect(csp.value).to eq("default-src https:; script-src 'self'; upgrade-insecure-requests") 252 | end 253 | 254 | it "raises an error if appending to a OPT_OUT policy" do 255 | Configuration.default do |config| 256 | config.csp = OPT_OUT 257 | end 258 | default_policy = Configuration.dup 259 | expect do 260 | ContentSecurityPolicy.combine_policies(default_policy.csp.to_h, script_src: %w(anothercdn.com)) 261 | end.to raise_error(ContentSecurityPolicyConfigError) 262 | end 263 | end 264 | end 265 | end 266 | -------------------------------------------------------------------------------- /spec/lib/secure_headers/headers/referrer_policy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "spec_helper" 3 | 4 | module SecureHeaders 5 | describe ReferrerPolicy do 6 | specify { expect(ReferrerPolicy.make_header).to eq([ReferrerPolicy::HEADER_NAME, "origin-when-cross-origin"]) } 7 | specify { expect(ReferrerPolicy.make_header("no-referrer")).to eq([ReferrerPolicy::HEADER_NAME, "no-referrer"]) } 8 | specify { expect(ReferrerPolicy.make_header(%w(origin-when-cross-origin strict-origin-when-cross-origin))).to eq([ReferrerPolicy::HEADER_NAME, "origin-when-cross-origin, strict-origin-when-cross-origin"]) } 9 | 10 | context "valid configuration values" do 11 | it "accepts 'no-referrer'" do 12 | expect do 13 | ReferrerPolicy.validate_config!("no-referrer") 14 | end.not_to raise_error 15 | end 16 | 17 | it "accepts 'no-referrer-when-downgrade'" do 18 | expect do 19 | ReferrerPolicy.validate_config!("no-referrer-when-downgrade") 20 | end.not_to raise_error 21 | end 22 | 23 | it "accepts 'same-origin'" do 24 | expect do 25 | ReferrerPolicy.validate_config!("same-origin") 26 | end.not_to raise_error 27 | end 28 | 29 | it "accepts 'strict-origin'" do 30 | expect do 31 | ReferrerPolicy.validate_config!("strict-origin") 32 | end.not_to raise_error 33 | end 34 | 35 | it "accepts 'strict-origin-when-cross-origin'" do 36 | expect do 37 | ReferrerPolicy.validate_config!("strict-origin-when-cross-origin") 38 | end.not_to raise_error 39 | end 40 | 41 | it "accepts 'origin'" do 42 | expect do 43 | ReferrerPolicy.validate_config!("origin") 44 | end.not_to raise_error 45 | end 46 | 47 | it "accepts 'origin-when-cross-origin'" do 48 | expect do 49 | ReferrerPolicy.validate_config!("origin-when-cross-origin") 50 | end.not_to raise_error 51 | end 52 | 53 | it "accepts 'unsafe-url'" do 54 | expect do 55 | ReferrerPolicy.validate_config!("unsafe-url") 56 | end.not_to raise_error 57 | end 58 | 59 | it "accepts nil" do 60 | expect do 61 | ReferrerPolicy.validate_config!(nil) 62 | end.not_to raise_error 63 | end 64 | 65 | it "accepts array of policy values" do 66 | expect do 67 | ReferrerPolicy.validate_config!( 68 | %w( 69 | origin-when-cross-origin 70 | strict-origin-when-cross-origin 71 | ) 72 | ) 73 | end.not_to raise_error 74 | end 75 | end 76 | 77 | context "invalid configuration values" do 78 | it "doesn't accept invalid values" do 79 | expect do 80 | ReferrerPolicy.validate_config!("open") 81 | end.to raise_error(ReferrerPolicyConfigError) 82 | end 83 | 84 | it "doesn't accept invalid types" do 85 | expect do 86 | ReferrerPolicy.validate_config!({}) 87 | end.to raise_error(TypeError) 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/lib/secure_headers/headers/strict_transport_security_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "spec_helper" 3 | 4 | module SecureHeaders 5 | describe StrictTransportSecurity do 6 | describe "#value" do 7 | specify { expect(StrictTransportSecurity.make_header).to eq([StrictTransportSecurity::HEADER_NAME, StrictTransportSecurity::DEFAULT_VALUE]) } 8 | specify { expect(StrictTransportSecurity.make_header("max-age=1234; includeSubdomains; preload")).to eq([StrictTransportSecurity::HEADER_NAME, "max-age=1234; includeSubdomains; preload"]) } 9 | 10 | context "with an invalid configuration" do 11 | context "with a string argument" do 12 | it "raises an exception with an invalid max-age" do 13 | expect do 14 | StrictTransportSecurity.validate_config!("max-age=abc123") 15 | end.to raise_error(STSConfigError) 16 | end 17 | 18 | it "raises an exception if max-age is not supplied" do 19 | expect do 20 | StrictTransportSecurity.validate_config!("includeSubdomains") 21 | end.to raise_error(STSConfigError) 22 | end 23 | 24 | it "raises an exception with an invalid format" do 25 | expect do 26 | StrictTransportSecurity.validate_config!("max-age=123includeSubdomains") 27 | end.to raise_error(STSConfigError) 28 | end 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/lib/secure_headers/headers/x_content_type_options_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "spec_helper" 3 | 4 | module SecureHeaders 5 | describe XContentTypeOptions do 6 | describe "#value" do 7 | specify { expect(XContentTypeOptions.make_header).to eq([XContentTypeOptions::HEADER_NAME, XContentTypeOptions::DEFAULT_VALUE]) } 8 | specify { expect(XContentTypeOptions.make_header("nosniff")).to eq([XContentTypeOptions::HEADER_NAME, "nosniff"]) } 9 | 10 | context "invalid configuration values" do 11 | it "accepts nosniff" do 12 | expect do 13 | XContentTypeOptions.validate_config!("nosniff") 14 | end.not_to raise_error 15 | end 16 | 17 | it "accepts nil" do 18 | expect do 19 | XContentTypeOptions.validate_config!(nil) 20 | end.not_to raise_error 21 | end 22 | 23 | it "doesn't accept anything besides no-sniff" do 24 | expect do 25 | XContentTypeOptions.validate_config!("donkey") 26 | end.to raise_error(XContentTypeOptionsConfigError) 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/lib/secure_headers/headers/x_download_options_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "spec_helper" 3 | 4 | module SecureHeaders 5 | describe XDownloadOptions do 6 | specify { expect(XDownloadOptions.make_header).to eq([XDownloadOptions::HEADER_NAME, XDownloadOptions::DEFAULT_VALUE]) } 7 | specify { expect(XDownloadOptions.make_header("noopen")).to eq([XDownloadOptions::HEADER_NAME, "noopen"]) } 8 | 9 | context "invalid configuration values" do 10 | it "accepts noopen" do 11 | expect do 12 | XDownloadOptions.validate_config!("noopen") 13 | end.not_to raise_error 14 | end 15 | 16 | it "accepts nil" do 17 | expect do 18 | XDownloadOptions.validate_config!(nil) 19 | end.not_to raise_error 20 | end 21 | 22 | it "doesn't accept anything besides noopen" do 23 | expect do 24 | XDownloadOptions.validate_config!("open") 25 | end.to raise_error(XDOConfigError) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/lib/secure_headers/headers/x_frame_options_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "spec_helper" 3 | 4 | module SecureHeaders 5 | describe XFrameOptions do 6 | describe "#value" do 7 | specify { expect(XFrameOptions.make_header).to eq([XFrameOptions::HEADER_NAME, XFrameOptions::DEFAULT_VALUE]) } 8 | specify { expect(XFrameOptions.make_header("DENY")).to eq([XFrameOptions::HEADER_NAME, "DENY"]) } 9 | 10 | context "with invalid configuration" do 11 | it "allows SAMEORIGIN" do 12 | expect do 13 | XFrameOptions.validate_config!("SAMEORIGIN") 14 | end.not_to raise_error 15 | end 16 | 17 | it "allows DENY" do 18 | expect do 19 | XFrameOptions.validate_config!("DENY") 20 | end.not_to raise_error 21 | end 22 | 23 | it "allows ALLOW-FROM*" do 24 | expect do 25 | XFrameOptions.validate_config!("ALLOW-FROM: example.com") 26 | end.not_to raise_error 27 | end 28 | it "does not allow garbage" do 29 | expect do 30 | XFrameOptions.validate_config!("I like turtles") 31 | end.to raise_error(XFOConfigError) 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/lib/secure_headers/headers/x_permitted_cross_domain_policies_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "spec_helper" 3 | 4 | module SecureHeaders 5 | describe XPermittedCrossDomainPolicies do 6 | specify { expect(XPermittedCrossDomainPolicies.make_header).to eq([XPermittedCrossDomainPolicies::HEADER_NAME, "none"]) } 7 | specify { expect(XPermittedCrossDomainPolicies.make_header("master-only")).to eq([XPermittedCrossDomainPolicies::HEADER_NAME, "master-only"]) } 8 | 9 | context "valid configuration values" do 10 | it "accepts 'all'" do 11 | expect do 12 | XPermittedCrossDomainPolicies.validate_config!("all") 13 | end.not_to raise_error 14 | end 15 | 16 | it "accepts 'by-ftp-filename'" do 17 | expect do 18 | XPermittedCrossDomainPolicies.validate_config!("by-ftp-filename") 19 | end.not_to raise_error 20 | end 21 | 22 | it "accepts 'by-content-type'" do 23 | expect do 24 | XPermittedCrossDomainPolicies.validate_config!("by-content-type") 25 | end.not_to raise_error 26 | end 27 | it "accepts 'master-only'" do 28 | expect do 29 | XPermittedCrossDomainPolicies.validate_config!("master-only") 30 | end.not_to raise_error 31 | end 32 | 33 | it "accepts nil" do 34 | expect do 35 | XPermittedCrossDomainPolicies.validate_config!(nil) 36 | end.not_to raise_error 37 | end 38 | end 39 | 40 | context "invlaid configuration values" do 41 | it "doesn't accept invalid values" do 42 | expect do 43 | XPermittedCrossDomainPolicies.validate_config!("open") 44 | end.to raise_error(XPCDPConfigError) 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/lib/secure_headers/headers/x_xss_protection_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "spec_helper" 3 | 4 | module SecureHeaders 5 | describe XXssProtection do 6 | specify { expect(XXssProtection.make_header).to eq([XXssProtection::HEADER_NAME, XXssProtection::DEFAULT_VALUE]) } 7 | specify { expect(XXssProtection.make_header("1; mode=block; report=https://www.secure.com/reports")).to eq([XXssProtection::HEADER_NAME, "1; mode=block; report=https://www.secure.com/reports"]) } 8 | 9 | context "with invalid configuration" do 10 | it "should raise an error when providing a string that is not valid" do 11 | expect do 12 | XXssProtection.validate_config!("asdf") 13 | end.to raise_error(XXssProtectionConfigError) 14 | 15 | expect do 16 | XXssProtection.validate_config!("asdf; mode=donkey") 17 | end.to raise_error(XXssProtectionConfigError) 18 | end 19 | 20 | context "when using a hash value" do 21 | it "should allow string values ('1' or '0' are the only valid strings)" do 22 | expect do 23 | XXssProtection.validate_config!("1") 24 | end.not_to raise_error 25 | end 26 | 27 | it "should raise an error if no value key is supplied" do 28 | expect do 29 | XXssProtection.validate_config!("mode=block") 30 | end.to raise_error(XXssProtectionConfigError) 31 | end 32 | 33 | it "should raise an error if an invalid key is supplied" do 34 | expect do 35 | XXssProtection.validate_config!("123") 36 | end.to raise_error(XXssProtectionConfigError) 37 | end 38 | 39 | it "should raise an error if mode != block" do 40 | expect do 41 | XXssProtection.validate_config!("1; mode=donkey") 42 | end.to raise_error(XXssProtectionConfigError) 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/lib/secure_headers/middleware_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "spec_helper" 3 | 4 | module SecureHeaders 5 | describe Middleware do 6 | let(:app) { lambda { |env| [200, env, "app"] } } 7 | let(:cookie_app) { lambda { |env| [200, env.merge("Set-Cookie" => "foo=bar"), "app"] } } 8 | 9 | let(:middleware) { Middleware.new(app) } 10 | let(:cookie_middleware) { Middleware.new(cookie_app) } 11 | 12 | before(:each) do 13 | reset_config 14 | Configuration.default 15 | end 16 | 17 | it "sets the headers" do 18 | _, env = middleware.call(Rack::MockRequest.env_for("https://looocalhost", {})) 19 | expect_default_values(env) 20 | end 21 | 22 | it "respects overrides" do 23 | request = Rack::Request.new("HTTP_X_FORWARDED_SSL" => "on") 24 | SecureHeaders.override_x_frame_options(request, "DENY") 25 | _, env = middleware.call request.env 26 | expect(env[XFrameOptions::HEADER_NAME]).to eq("DENY") 27 | end 28 | 29 | it "uses named overrides" do 30 | Configuration.override("my_custom_config") do |config| 31 | config.csp[:script_src] = %w(example.org) 32 | end 33 | request = Rack::Request.new({}) 34 | SecureHeaders.use_secure_headers_override(request, "my_custom_config") 35 | _, env = middleware.call request.env 36 | expect(env[ContentSecurityPolicyConfig::HEADER_NAME]).to match("example.org") 37 | end 38 | 39 | context "cookies" do 40 | before(:each) do 41 | reset_config 42 | end 43 | context "cookies should be flagged" do 44 | it "flags cookies as secure" do 45 | Configuration.default { |config| config.cookies = {secure: true, httponly: OPT_OUT, samesite: OPT_OUT} } 46 | request = Rack::Request.new("HTTPS" => "on") 47 | _, env = cookie_middleware.call request.env 48 | expect(env["Set-Cookie"]).to eq("foo=bar; secure") 49 | end 50 | end 51 | 52 | it "allows opting out of cookie protection with OPT_OUT alone" do 53 | Configuration.default { |config| config.cookies = OPT_OUT } 54 | 55 | # do NOT make this request https. non-https requests modify a config, 56 | # causing an exception when operating on OPT_OUT. This ensures we don't 57 | # try to modify the config. 58 | request = Rack::Request.new({}) 59 | _, env = cookie_middleware.call request.env 60 | expect(env["Set-Cookie"]).to eq("foo=bar") 61 | end 62 | 63 | context "cookies should not be flagged" do 64 | it "does not flags cookies as secure" do 65 | Configuration.default { |config| config.cookies = {secure: OPT_OUT, httponly: OPT_OUT, samesite: OPT_OUT} } 66 | request = Rack::Request.new("HTTPS" => "on") 67 | _, env = cookie_middleware.call request.env 68 | expect(env["Set-Cookie"]).to eq("foo=bar") 69 | end 70 | end 71 | end 72 | 73 | context "cookies" do 74 | before(:each) do 75 | reset_config 76 | end 77 | it "flags cookies from configuration" do 78 | Configuration.default { |config| config.cookies = { secure: true, httponly: true, samesite: { lax: true} } } 79 | request = Rack::Request.new("HTTPS" => "on") 80 | _, env = cookie_middleware.call request.env 81 | 82 | expect(env["Set-Cookie"]).to eq("foo=bar; secure; HttpOnly; SameSite=Lax") 83 | end 84 | 85 | it "flags cookies with a combination of SameSite configurations" do 86 | cookie_middleware = Middleware.new(lambda { |env| [200, env.merge("Set-Cookie" => ["_session=foobar", "_guest=true"]), "app"] }) 87 | 88 | Configuration.default { |config| config.cookies = { samesite: { lax: { except: ["_session"] }, strict: { only: ["_session"] } }, httponly: OPT_OUT, secure: OPT_OUT} } 89 | request = Rack::Request.new("HTTPS" => "on") 90 | _, env = cookie_middleware.call request.env 91 | 92 | expect(env["Set-Cookie"]).to match("_session=foobar; SameSite=Strict") 93 | expect(env["Set-Cookie"]).to match("_guest=true; SameSite=Lax") 94 | end 95 | 96 | it "disables secure cookies for non-https requests" do 97 | Configuration.default { |config| config.cookies = { secure: true, httponly: OPT_OUT, samesite: OPT_OUT } } 98 | 99 | request = Rack::Request.new("HTTPS" => "off") 100 | _, env = cookie_middleware.call request.env 101 | expect(env["Set-Cookie"]).to eq("foo=bar") 102 | end 103 | 104 | it "sets the secure cookie flag correctly on interleaved http/https requests" do 105 | Configuration.default { |config| config.cookies = { secure: true, httponly: OPT_OUT, samesite: OPT_OUT } } 106 | 107 | request = Rack::Request.new("HTTPS" => "off") 108 | _, env = cookie_middleware.call request.env 109 | expect(env["Set-Cookie"]).to eq("foo=bar") 110 | 111 | request = Rack::Request.new("HTTPS" => "on") 112 | _, env = cookie_middleware.call request.env 113 | expect(env["Set-Cookie"]).to eq("foo=bar; secure") 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /spec/lib/secure_headers/view_helpers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "spec_helper" 3 | require "erb" 4 | 5 | class Message < ERB 6 | include SecureHeaders::ViewHelpers 7 | 8 | def self.template 9 | <<-TEMPLATE 10 | <% hashed_javascript_tag(raise_error_on_unrecognized_hash = true) do %> 11 | console.log(1) 12 | <% end %> 13 | 14 | <% hashed_style_tag do %> 15 | body { 16 | background-color: black; 17 | } 18 | <% end %> 19 | 20 | <% nonced_javascript_tag do %> 21 | body { 22 | console.log(1) 23 | } 24 | <% end %> 25 | 26 | <% nonced_style_tag do %> 27 | body { 28 | background-color: black; 29 | } 30 | <% end %> 31 | 32 | 35 | 36 | 41 | 42 | <%= nonced_javascript_include_tag "include.js", defer: true %> 43 | 44 | <%= nonced_javascript_pack_tag "pack.js", "otherpack.js", defer: true %> 45 | 46 | <%= nonced_stylesheet_link_tag "link.css", media: :all %> 47 | 48 | <%= nonced_stylesheet_pack_tag "pack.css", "otherpack.css", media: :all %> 49 | 50 | TEMPLATE 51 | end 52 | 53 | def initialize(request, options = {}) 54 | @virtual_path = "/asdfs/index" 55 | @_request = request 56 | @template = self.class.template 57 | super(@template) 58 | end 59 | 60 | def capture(*args) 61 | yield(*args) 62 | end 63 | 64 | def content_tag(type, content = nil, options = nil, &block) 65 | content = 66 | if block_given? 67 | capture(block) 68 | end 69 | 70 | if options.is_a?(Hash) 71 | options = options.map { |k, v| " #{k}=#{v}" } 72 | end 73 | "<#{type}#{options}>#{content}" 74 | end 75 | 76 | def javascript_include_tag(*sources, **options) 77 | sources.map do |source| 78 | content_tag(:script, nil, options.merge(src: source)) 79 | end 80 | end 81 | 82 | alias_method :javascript_pack_tag, :javascript_include_tag 83 | 84 | def stylesheet_link_tag(*sources, **options) 85 | sources.map do |source| 86 | content_tag(:link, nil, options.merge(href: source, rel: "stylesheet", media: "screen")) 87 | end 88 | end 89 | 90 | alias_method :stylesheet_pack_tag, :stylesheet_link_tag 91 | 92 | def result 93 | super(binding) 94 | end 95 | 96 | def request 97 | @_request 98 | end 99 | end 100 | 101 | class MessageWithConflictingMethod < Message 102 | def content_security_policy_nonce 103 | "rails-nonce" 104 | end 105 | end 106 | 107 | module SecureHeaders 108 | describe ViewHelpers do 109 | let(:app) { lambda { |env| [200, env, "app"] } } 110 | let(:middleware) { Middleware.new(app) } 111 | let(:request) { Rack::Request.new("HTTP_USER_AGENT" => USER_AGENTS[:chrome]) } 112 | let(:filename) { "app/views/asdfs/index.html.erb" } 113 | 114 | before(:all) do 115 | reset_config 116 | Configuration.default do |config| 117 | config.csp = { 118 | default_src: %w('self'), 119 | script_src: %w('self'), 120 | style_src: %w('self') 121 | } 122 | end 123 | end 124 | 125 | after(:each) do 126 | Configuration.instance_variable_set(:@script_hashes, nil) 127 | Configuration.instance_variable_set(:@style_hashes, nil) 128 | end 129 | 130 | it "raises an error when using hashed content without precomputed hashes" do 131 | expect { 132 | Message.new(request).result 133 | }.to raise_error(ViewHelpers::UnexpectedHashedScriptException) 134 | end 135 | 136 | it "raises an error when using hashed content with precomputed hashes, but none for the given file" do 137 | Configuration.instance_variable_set(:@script_hashes, filename.reverse => ["'sha256-123'"]) 138 | expect { 139 | Message.new(request).result 140 | }.to raise_error(ViewHelpers::UnexpectedHashedScriptException) 141 | end 142 | 143 | it "raises an error when using previously unknown hashed content with precomputed hashes for a given file" do 144 | Configuration.instance_variable_set(:@script_hashes, filename => ["'sha256-123'"]) 145 | expect { 146 | Message.new(request).result 147 | }.to raise_error(ViewHelpers::UnexpectedHashedScriptException) 148 | end 149 | 150 | it "adds known hash values to the corresponding headers when the helper is used" do 151 | begin 152 | allow(SecureRandom).to receive(:base64).and_return("abc123") 153 | 154 | expected_hash = "sha256-3/URElR9+3lvLIouavYD/vhoICSNKilh15CzI/nKqg8=" 155 | Configuration.instance_variable_set(:@script_hashes, filename => ["'#{expected_hash}'"]) 156 | expected_style_hash = "sha256-7oYK96jHg36D6BM042er4OfBnyUDTG3pH1L8Zso3aGc=" 157 | Configuration.instance_variable_set(:@style_hashes, filename => ["'#{expected_style_hash}'"]) 158 | 159 | # render erb that calls out to helpers. 160 | Message.new(request).result 161 | _, env = middleware.call request.env 162 | 163 | expect(env[ContentSecurityPolicyConfig::HEADER_NAME]).to match(/script-src[^;]*'#{Regexp.escape(expected_hash)}'/) 164 | expect(env[ContentSecurityPolicyConfig::HEADER_NAME]).to match(/script-src[^;]*'nonce-abc123'/) 165 | expect(env[ContentSecurityPolicyConfig::HEADER_NAME]).to match(/style-src[^;]*'nonce-abc123'/) 166 | expect(env[ContentSecurityPolicyConfig::HEADER_NAME]).to match(/style-src[^;]*'#{Regexp.escape(expected_style_hash)}'/) 167 | end 168 | end 169 | 170 | it "avoids calling content_security_policy_nonce internally" do 171 | begin 172 | allow(SecureRandom).to receive(:base64).and_return("abc123") 173 | 174 | expected_hash = "sha256-3/URElR9+3lvLIouavYD/vhoICSNKilh15CzI/nKqg8=" 175 | Configuration.instance_variable_set(:@script_hashes, filename => ["'#{expected_hash}'"]) 176 | expected_style_hash = "sha256-7oYK96jHg36D6BM042er4OfBnyUDTG3pH1L8Zso3aGc=" 177 | Configuration.instance_variable_set(:@style_hashes, filename => ["'#{expected_style_hash}'"]) 178 | 179 | # render erb that calls out to helpers. 180 | MessageWithConflictingMethod.new(request).result 181 | _, env = middleware.call request.env 182 | 183 | expect(env[ContentSecurityPolicyConfig::HEADER_NAME]).to match(/script-src[^;]*'#{Regexp.escape(expected_hash)}'/) 184 | expect(env[ContentSecurityPolicyConfig::HEADER_NAME]).to match(/script-src[^;]*'nonce-abc123'/) 185 | expect(env[ContentSecurityPolicyConfig::HEADER_NAME]).to match(/style-src[^;]*'nonce-abc123'/) 186 | expect(env[ContentSecurityPolicyConfig::HEADER_NAME]).to match(/style-src[^;]*'#{Regexp.escape(expected_style_hash)}'/) 187 | 188 | expect(env[ContentSecurityPolicyConfig::HEADER_NAME]).not_to match(/rails-nonce/) 189 | end 190 | end 191 | end 192 | end 193 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "rubygems" 3 | require "rspec" 4 | require "rack" 5 | require "coveralls" 6 | Coveralls.wear! 7 | 8 | require File.join(File.dirname(__FILE__), "..", "lib", "secure_headers") 9 | 10 | 11 | 12 | USER_AGENTS = { 13 | edge: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246", 14 | firefox: "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:14.0) Gecko/20100101 Firefox/14.0.1", 15 | firefox46: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:46.0) Gecko/20100101 Firefox/46.0", 16 | chrome: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/536.5 (KHTML, like Gecko) Chrome/19.0.1084.56 Safari/536.5", 17 | ie: "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident/5.0)", 18 | opera: "Opera/9.80 (Windows NT 6.1; U; es-ES) Presto/2.9.181 Version/12.00", 19 | ios5: "Mozilla/5.0 (iPhone; CPU iPhone OS 5_0 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Version/5.1 Mobile/9A334 Safari/7534.48.3", 20 | ios6: "Mozilla/5.0 (iPhone; CPU iPhone OS 614 like Mac OS X) AppleWebKit/536.26 (KHTML like Gecko) Version/6.0 Mobile/10B350 Safari/8536.25", 21 | safari5: "Mozilla/5.0 (iPad; CPU OS 5_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko ) Version/5.1 Mobile/9B176 Safari/7534.48.3", 22 | safari5_1: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_3) AppleWebKit/534.55.3 (KHTML, like Gecko) Version/5.1.3 Safari/534.53.10", 23 | safari6: "Mozilla/5.0 (Macintosh; Intel Mac OS X 1084) AppleWebKit/536.30.1 (KHTML like Gecko) Version/6.0.5 Safari/536.30.1", 24 | safari10: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_1) AppleWebKit/602.2.11 (KHTML, like Gecko) Version/10.0.1 Safari/602.2.11" 25 | } 26 | 27 | def expect_default_values(hash) 28 | expect(hash[SecureHeaders::ContentSecurityPolicyConfig::HEADER_NAME]).to eq("default-src 'self' https:; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'; script-src https:; style-src 'self' https: 'unsafe-inline'") 29 | expect(hash[SecureHeaders::ContentSecurityPolicyReportOnlyConfig::HEADER_NAME]).to be_nil 30 | expect(hash[SecureHeaders::XFrameOptions::HEADER_NAME]).to eq(SecureHeaders::XFrameOptions::DEFAULT_VALUE) 31 | expect(hash[SecureHeaders::XDownloadOptions::HEADER_NAME]).to eq(SecureHeaders::XDownloadOptions::DEFAULT_VALUE) 32 | expect(hash[SecureHeaders::StrictTransportSecurity::HEADER_NAME]).to eq(SecureHeaders::StrictTransportSecurity::DEFAULT_VALUE) 33 | expect(hash[SecureHeaders::XXssProtection::HEADER_NAME]).to eq(SecureHeaders::XXssProtection::DEFAULT_VALUE) 34 | expect(hash[SecureHeaders::XContentTypeOptions::HEADER_NAME]).to eq(SecureHeaders::XContentTypeOptions::DEFAULT_VALUE) 35 | expect(hash[SecureHeaders::XPermittedCrossDomainPolicies::HEADER_NAME]).to eq(SecureHeaders::XPermittedCrossDomainPolicies::DEFAULT_VALUE) 36 | expect(hash[SecureHeaders::ReferrerPolicy::HEADER_NAME]).to be_nil 37 | expect(hash[SecureHeaders::ExpectCertificateTransparency::HEADER_NAME]).to be_nil 38 | expect(hash[SecureHeaders::ClearSiteData::HEADER_NAME]).to be_nil 39 | expect(hash[SecureHeaders::ExpectCertificateTransparency::HEADER_NAME]).to be_nil 40 | end 41 | 42 | module SecureHeaders 43 | class Configuration 44 | class << self 45 | def clear_default_config 46 | remove_instance_variable(:@default_config) if defined?(@default_config) 47 | end 48 | 49 | def clear_overrides 50 | remove_instance_variable(:@overrides) if defined?(@overrides) 51 | end 52 | 53 | def clear_appends 54 | remove_instance_variable(:@appends) if defined?(@appends) 55 | end 56 | end 57 | end 58 | end 59 | 60 | def reset_config 61 | SecureHeaders::Configuration.clear_default_config 62 | SecureHeaders::Configuration.clear_overrides 63 | SecureHeaders::Configuration.clear_appends 64 | end 65 | --------------------------------------------------------------------------------