├── .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 
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 = /(
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}#{type}>"
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 |
--------------------------------------------------------------------------------