csp_maker 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +1 -0
- data/README.md +200 -0
- data/lib/csp_maker/csp.rb +316 -0
- data/lib/csp_maker/rack.rb +55 -0
- data/lib/csp_maker/version.rb +5 -0
- data/lib/csp_maker.rb +41 -0
- metadata +70 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a645c19c334a4c1b70f46a7b7afec40bd91dd05205de493cd690ca97d254affb
|
|
4
|
+
data.tar.gz: 12ab705a8aa1ca92f4843e3355e068959bb39a234f90cb7e1a4bfc52b6d4c313
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9a41bb604a093794b6ea224093e56e4bfd1272f31e1203ce0feb9673f3545cc39d093901412aeb7f5d036d881a1bbe11b2a43e8c1444fd97a0a91eef7f9439f9
|
|
7
|
+
data.tar.gz: 6a13a9fb9cee3bd78cee4fe158053c1fe75fd0439da1d437e183c31e3aeb19b6c17ec30fa7c2b69353af68f60dca4bd9e27109db852ffdd82b11accc191b0f29
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
## [Unreleased]
|
data/README.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# CSP Maker
|
|
2
|
+
|
|
3
|
+
CSP Maker is a micro gem in Ruby for defining a [Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy), extracted from Rails so it can be used standalone or in Rack apps.
|
|
4
|
+
|
|
5
|
+
The code is extracted from [ActionDispatch::ContentSecurityPolicy](https://github.com/rails/rails/blob/main/actionpack/lib/action_dispatch/http/content_security_policy.rb) in Action Pack, with some minor modifications. The DSL as described in Rails docs should be usable 1:1 here. There is also an optional Rack middleware updated to not rely on Rails internals.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Add the gem to your app's `Gemfile`:
|
|
11
|
+
|
|
12
|
+
gem 'csp_maker', '~> 0.1'
|
|
13
|
+
|
|
14
|
+
Then, require it in your server code:
|
|
15
|
+
|
|
16
|
+
```rb
|
|
17
|
+
require 'csp_maker'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
CSP Maker supports Ruby 3.0 and newer.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## Basic usage
|
|
24
|
+
|
|
25
|
+
The simplest way is through the `CSPMaker.build_policy` method:
|
|
26
|
+
|
|
27
|
+
```rb
|
|
28
|
+
headers['Content-Security-Policy'] = CSPMaker.build_policy do |p|
|
|
29
|
+
p.default_src :none
|
|
30
|
+
p.script_src :self, 'https://github.com', 'https://example.com'
|
|
31
|
+
p.style_src :self
|
|
32
|
+
p.font_src :self
|
|
33
|
+
p.img_src :self, :https, :data
|
|
34
|
+
p.connect_src :self, 'https://api.bsky.app'
|
|
35
|
+
p.base_uri :none
|
|
36
|
+
p.object_src :none
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
This creates a `ContentSecurityPolicy` definition object and then immediately encodes it into the final string, which you can assign to the target header.
|
|
41
|
+
|
|
42
|
+
Alternatively, you can also pass a block without parameters and call the DSL methods on an implicit `self` like this:
|
|
43
|
+
|
|
44
|
+
```rb
|
|
45
|
+
headers['Content-Security-Policy'] = CSPMaker.build_policy do
|
|
46
|
+
default_src :none
|
|
47
|
+
script_src :self, 'https://github.com', 'https://example.com'
|
|
48
|
+
style_src :self
|
|
49
|
+
font_src :self
|
|
50
|
+
img_src :self, :https, :data
|
|
51
|
+
connect_src :self, 'https://api.bsky.app'
|
|
52
|
+
base_uri :none
|
|
53
|
+
object_src :none
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Note, in this case (block without parameters) the block is run through `instance_eval` in the context of the policy object, so helper methods and instance vars from outside the block won't be accessible inside; if you want to use those, use the version with a parameter, or call the `ContentSecurityPolicy` constructor directly.
|
|
58
|
+
|
|
59
|
+
If you want to use nonces, you can generate a nonce yourself and pass it to the DSL using a new `nonce()` DSL method like this:
|
|
60
|
+
|
|
61
|
+
```rb
|
|
62
|
+
asset_nonce = ...
|
|
63
|
+
|
|
64
|
+
headers['Content-Security-Policy'] = CSPMaker.build_policy do
|
|
65
|
+
default_src :none
|
|
66
|
+
script_src :self, nonce(asset_nonce)
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A second approach is to use `CSPMaker.define_policy` to create a policy definition once, and then reuse it on each request:
|
|
71
|
+
|
|
72
|
+
```rb
|
|
73
|
+
CSP = CSPMaker.define_policy do
|
|
74
|
+
default_src :none
|
|
75
|
+
script_src :self, 'https://github.com', 'https://example.com'
|
|
76
|
+
style_src :self
|
|
77
|
+
...
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
You encode the policy into the header string by calling `#build(context, nonce, nonce_directives)`. The parameters (all optional) are:
|
|
82
|
+
|
|
83
|
+
- `context` – you can pass a Proc instead of a String/Symbol to a DSL directive, and that Proc will be called with this context object as `self` (it can be e.g. some kind of controller or request object)
|
|
84
|
+
- `nonce` – a nonce to be added to the designated directives
|
|
85
|
+
- `nonce_directives` – array of directives to which nonces should be added; if nil, the list is read from a global setting `CSPMaker.nonce_directives`, or the default `['script-src', 'style-src']`
|
|
86
|
+
|
|
87
|
+
So it can look like this:
|
|
88
|
+
|
|
89
|
+
```rb
|
|
90
|
+
asset_nonce = ...
|
|
91
|
+
|
|
92
|
+
headers['Content-Security-Policy'] = CSP.build(nil, asset_nonce, ['script-src'])
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
## Rack middleware
|
|
97
|
+
|
|
98
|
+
A third way is to use the Rack middleware. The middleware class is also taken from the Rails code, but simplified to not rely on Rails internals.
|
|
99
|
+
|
|
100
|
+
Require the `csp_maker/rack` file instead of `csp_maker`:
|
|
101
|
+
|
|
102
|
+
```rb
|
|
103
|
+
require 'csp_maker/rack'
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Define a policy, and pass it to the `CSPMaker::Middleware` when installing it into the Rack stack:
|
|
107
|
+
|
|
108
|
+
```rb
|
|
109
|
+
policy = CSPMaker.define_policy {
|
|
110
|
+
...
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
use CSPMaker::Middleware, policy
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The middleware will:
|
|
117
|
+
|
|
118
|
+
- automatically generate a nonce, append it to the directives and store it in the Rack `env`, if a nonce generator is configured (see below)
|
|
119
|
+
- store the generated nonce in the `env` under `csp_maker.nonce` (`CSPMaker::Middleware::NONCE_ENV_KEY`)
|
|
120
|
+
- create a `Rack::Request` object and use it as the block context for any Procs in the DSL
|
|
121
|
+
- encode the policy into a result string
|
|
122
|
+
- assign it to the `content-security-policy` header automatically
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
## Using nonces
|
|
126
|
+
|
|
127
|
+
When using the Rack middleware, if you want it to append nonces, you need to assign a nonce generator Proc to `CSPMaker.nonce_generator` (equivalent of `config.content_security_policy_nonce_generator` in Rails), or pass it as a `nonce_generator:` option to the `Middleware` initializer. You can use `CSPMaker.default_generator`, which calls `SecureRandom.base64(16)`:
|
|
128
|
+
|
|
129
|
+
```rb
|
|
130
|
+
CSPMaker.nonce_generator = CSPMaker.default_generator
|
|
131
|
+
|
|
132
|
+
# or:
|
|
133
|
+
|
|
134
|
+
CSPMaker.nonce_generator = -> { ... }
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Use `CSPMaker.nonce_directives` (equivalent of `config.content_security_policy_nonce_directives` in Rails) or `nonce_directives:` option in `Middleware` to configure which directives should have a nonce added to them (default is `['script-src', 'style-src']`):
|
|
138
|
+
|
|
139
|
+
```rb
|
|
140
|
+
CSPMaker.nonce_directives = ['script-src']
|
|
141
|
+
|
|
142
|
+
# or:
|
|
143
|
+
|
|
144
|
+
use CSPMaker::Middleware, policy, nonce_generator: -> { ... }, nonce_directives: ['style-src']
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The generated nonce is stored in the `env` hash under `csp_maker.nonce` (`CSPMaker::Middleware::NONCE_ENV_KEY`). You will need it for the view template code to add it to the inline `script` / `style` tags, so you could add a helper like:
|
|
148
|
+
|
|
149
|
+
```rb
|
|
150
|
+
def asset_nonce
|
|
151
|
+
request.env[CSPMaker::Middleware::NONCE_ENV_KEY]
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
When using `build_policy` or `define_policy` standalone without a middleware, generating and storing the nonce is up to you – and you either pass it to `nonce(...)` inside a `build_policy` block, or as the second argument to `#build` on a `CSPMaker::ContentSecurityPolicy` object returned from `define_policy`. However, you can also assign `CSPMaker.nonce_generator` and call `CSPMaker.make_nonce` to run it if you want:
|
|
156
|
+
|
|
157
|
+
```rb
|
|
158
|
+
CSPMaker.nonce_generator = CSPMaker.default_generator
|
|
159
|
+
|
|
160
|
+
policy = CSPMaker.define_policy { ... }
|
|
161
|
+
nonce = CSPMaker.make_nonce
|
|
162
|
+
|
|
163
|
+
headers['content-security-policy'] = policy.build(nil, nonce)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
## Other options
|
|
168
|
+
|
|
169
|
+
You can also set `CSPMaker.report_only` (equivalent of `config.content_security_policy_report_only` in Rails) or `report_only:` option to `Middleware` to assign the policy to the `content-security-policy-report-only` header instead of `content-security-policy`; this makes the browser only report errors to the URL configured via `report_uri` in the DSL, but not actually enforce the policy in the web app (i.e. not block any scripts & styles from loading).
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
## Full DSL API
|
|
173
|
+
|
|
174
|
+
Directives:
|
|
175
|
+
|
|
176
|
+
* directives that accept an array of sources: `base_uri`, `child_src`, `connect_src`, `default_src`, `font_src`, `form_action`, `frame_ancestors`, `frame_src`, `img_src`, `manifest_src`, `media_src`, `object_src`, `prefetch_src`, `require_trusted_types_for`, `script_src`, `script_src_attr`, `script_src_elem`, `style_src`, `style_src_attr`, `style_src_elem`, `trusted_types`, `worker_src`
|
|
177
|
+
* `block_all_mixed_content(enabled = true)`
|
|
178
|
+
* `plugin_types(*types)`
|
|
179
|
+
* `report_uri(uri)`
|
|
180
|
+
* `require_sri_for(*types)`
|
|
181
|
+
* `sandbox(enabled = true)` or `sandbox(*values)`
|
|
182
|
+
* `upgrade_insecure_requests(enabled = true)`
|
|
183
|
+
|
|
184
|
+
Source lists:
|
|
185
|
+
|
|
186
|
+
* special values: `:allow_duplicates`, `:none`, `:report_sample`, `:script`, `:self`, `:strict_dynamic`, `:unsafe_eval`, `:unsafe_hashes`, `:unsafe_inline`, `:wasm_unsafe_eval`
|
|
187
|
+
* protocols: `:http`, `:https`, `:data`, `:mediastream`, `:blob`, `:filesystem`, `:ws`, `:wss`
|
|
188
|
+
* content hashes: `"sha256-..."`, `"sha384-..."`, `"sha512-..."`
|
|
189
|
+
* `nonce(nonce_value)`
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
## Credits
|
|
193
|
+
|
|
194
|
+
The original `content_security_policy.rb` was added to Rails ActionPack code by Andrew White in 2017, and had some updates since then by others.
|
|
195
|
+
|
|
196
|
+
Modifications for the purposes of this gem are © 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)).
|
|
197
|
+
|
|
198
|
+
The code is available under the terms of the [MIT license](https://choosealicense.com/licenses/mit/).
|
|
199
|
+
|
|
200
|
+
Bug reports and pull requests are welcome :)
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Configures the HTTP [Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy)
|
|
4
|
+
# response header to help protect against XSS and injection attacks.
|
|
5
|
+
#
|
|
6
|
+
# Example global policy:
|
|
7
|
+
#
|
|
8
|
+
# CSPMaker.build_policy do |policy|
|
|
9
|
+
# policy.default_src :self, :https
|
|
10
|
+
# policy.font_src :self, :https, :data
|
|
11
|
+
# policy.img_src :self, :https, :data
|
|
12
|
+
# policy.object_src :none
|
|
13
|
+
# policy.script_src :self, :https
|
|
14
|
+
# policy.style_src :self, :https
|
|
15
|
+
#
|
|
16
|
+
# # Specify URI for violation reports
|
|
17
|
+
# policy.report_uri "/csp-violation-report-endpoint"
|
|
18
|
+
# end
|
|
19
|
+
|
|
20
|
+
module CSPMaker
|
|
21
|
+
class InvalidDirectiveError < StandardError
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class ContentSecurityPolicy
|
|
25
|
+
MAPPINGS = {
|
|
26
|
+
self: "'self'",
|
|
27
|
+
unsafe_eval: "'unsafe-eval'",
|
|
28
|
+
wasm_unsafe_eval: "'wasm-unsafe-eval'",
|
|
29
|
+
unsafe_hashes: "'unsafe-hashes'",
|
|
30
|
+
unsafe_inline: "'unsafe-inline'",
|
|
31
|
+
none: "'none'",
|
|
32
|
+
http: "http:",
|
|
33
|
+
https: "https:",
|
|
34
|
+
data: "data:",
|
|
35
|
+
mediastream: "mediastream:",
|
|
36
|
+
allow_duplicates: "'allow-duplicates'",
|
|
37
|
+
blob: "blob:",
|
|
38
|
+
filesystem: "filesystem:",
|
|
39
|
+
report_sample: "'report-sample'",
|
|
40
|
+
script: "'script'",
|
|
41
|
+
strict_dynamic: "'strict-dynamic'",
|
|
42
|
+
ws: "ws:",
|
|
43
|
+
wss: "wss:"
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
DIRECTIVES = {
|
|
47
|
+
base_uri: "base-uri",
|
|
48
|
+
child_src: "child-src",
|
|
49
|
+
connect_src: "connect-src",
|
|
50
|
+
default_src: "default-src",
|
|
51
|
+
font_src: "font-src",
|
|
52
|
+
form_action: "form-action",
|
|
53
|
+
frame_ancestors: "frame-ancestors",
|
|
54
|
+
frame_src: "frame-src",
|
|
55
|
+
img_src: "img-src",
|
|
56
|
+
manifest_src: "manifest-src",
|
|
57
|
+
media_src: "media-src",
|
|
58
|
+
object_src: "object-src",
|
|
59
|
+
prefetch_src: "prefetch-src",
|
|
60
|
+
require_trusted_types_for: "require-trusted-types-for",
|
|
61
|
+
script_src: "script-src",
|
|
62
|
+
script_src_attr: "script-src-attr",
|
|
63
|
+
script_src_elem: "script-src-elem",
|
|
64
|
+
style_src: "style-src",
|
|
65
|
+
style_src_attr: "style-src-attr",
|
|
66
|
+
style_src_elem: "style-src-elem",
|
|
67
|
+
trusted_types: "trusted-types",
|
|
68
|
+
worker_src: "worker-src"
|
|
69
|
+
}.freeze
|
|
70
|
+
|
|
71
|
+
HASH_SOURCE_ALGORITHM_PREFIXES = ["sha256-", "sha384-", "sha512-"].freeze
|
|
72
|
+
|
|
73
|
+
DEFAULT_NONCE_DIRECTIVES = %w[script-src style-src].freeze
|
|
74
|
+
|
|
75
|
+
private_constant :MAPPINGS, :DIRECTIVES, :DEFAULT_NONCE_DIRECTIVES
|
|
76
|
+
|
|
77
|
+
attr_reader :directives
|
|
78
|
+
|
|
79
|
+
def initialize
|
|
80
|
+
@directives = {}
|
|
81
|
+
yield self if block_given?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def initialize_copy(other)
|
|
85
|
+
@directives = other.directives.to_h do |directive, sources|
|
|
86
|
+
if sources.is_a?(Array)
|
|
87
|
+
copied_sources = sources.map { |source| source.is_a?(String) ? source.dup : source }
|
|
88
|
+
[directive, copied_sources]
|
|
89
|
+
else
|
|
90
|
+
[directive, sources]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
DIRECTIVES.each do |name, directive|
|
|
96
|
+
define_method(name) do |*sources|
|
|
97
|
+
if sources.first
|
|
98
|
+
@directives[directive] = apply_mappings(sources)
|
|
99
|
+
else
|
|
100
|
+
@directives.delete(directive)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Specify whether to prevent the user agent from loading any assets over HTTP
|
|
106
|
+
# when the page uses HTTPS:
|
|
107
|
+
#
|
|
108
|
+
# policy.block_all_mixed_content
|
|
109
|
+
#
|
|
110
|
+
# Pass `false` to allow it again:
|
|
111
|
+
#
|
|
112
|
+
# policy.block_all_mixed_content false
|
|
113
|
+
#
|
|
114
|
+
|
|
115
|
+
def block_all_mixed_content(enabled = true)
|
|
116
|
+
if enabled
|
|
117
|
+
@directives["block-all-mixed-content"] = true
|
|
118
|
+
else
|
|
119
|
+
@directives.delete("block-all-mixed-content")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Restricts the set of plugins that can be embedded:
|
|
124
|
+
#
|
|
125
|
+
# policy.plugin_types "application/x-shockwave-flash"
|
|
126
|
+
#
|
|
127
|
+
# Leave empty to allow all plugins:
|
|
128
|
+
#
|
|
129
|
+
# policy.plugin_types
|
|
130
|
+
|
|
131
|
+
def plugin_types(*types)
|
|
132
|
+
if types.first
|
|
133
|
+
@directives["plugin-types"] = types
|
|
134
|
+
else
|
|
135
|
+
@directives.delete("plugin-types")
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Enable the [report-uri](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/report-uri)
|
|
140
|
+
# directive. Violation reports will be sent to the specified URI:
|
|
141
|
+
#
|
|
142
|
+
# policy.report_uri "/csp-violation-report-endpoint"
|
|
143
|
+
|
|
144
|
+
def report_uri(uri)
|
|
145
|
+
@directives["report-uri"] = [uri]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Specify asset types for which [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity)
|
|
149
|
+
# is required:
|
|
150
|
+
#
|
|
151
|
+
# policy.require_sri_for :script, :style
|
|
152
|
+
#
|
|
153
|
+
# Leave empty to not require Subresource Integrity:
|
|
154
|
+
#
|
|
155
|
+
# policy.require_sri_for
|
|
156
|
+
|
|
157
|
+
def require_sri_for(*types)
|
|
158
|
+
if types.first
|
|
159
|
+
@directives["require-sri-for"] = types
|
|
160
|
+
else
|
|
161
|
+
@directives.delete("require-sri-for")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Specify whether a [sandbox](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/sandbox)
|
|
166
|
+
# should be enabled for the requested resource:
|
|
167
|
+
#
|
|
168
|
+
# policy.sandbox
|
|
169
|
+
#
|
|
170
|
+
# Values can be passed as arguments:
|
|
171
|
+
#
|
|
172
|
+
# policy.sandbox "allow-scripts", "allow-modals"
|
|
173
|
+
#
|
|
174
|
+
# Pass `false` to disable the sandbox:
|
|
175
|
+
#
|
|
176
|
+
# policy.sandbox false
|
|
177
|
+
|
|
178
|
+
def sandbox(*values)
|
|
179
|
+
if values.empty?
|
|
180
|
+
@directives["sandbox"] = true
|
|
181
|
+
elsif values.first
|
|
182
|
+
@directives["sandbox"] = values
|
|
183
|
+
else
|
|
184
|
+
@directives.delete("sandbox")
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Specify whether user agents should treat any assets over HTTP as HTTPS:
|
|
189
|
+
#
|
|
190
|
+
# policy.upgrade_insecure_requests
|
|
191
|
+
#
|
|
192
|
+
# Pass `false` to disable it:
|
|
193
|
+
#
|
|
194
|
+
# policy.upgrade_insecure_requests false
|
|
195
|
+
|
|
196
|
+
def upgrade_insecure_requests(enabled = true)
|
|
197
|
+
if enabled
|
|
198
|
+
@directives["upgrade-insecure-requests"] = true
|
|
199
|
+
else
|
|
200
|
+
@directives.delete("upgrade-insecure-requests")
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def nonce(nonce)
|
|
205
|
+
raise InvalidDirectiveError, "Nonce must be a non-empty string" unless nonce.is_a?(String) && nonce.length > 0
|
|
206
|
+
|
|
207
|
+
"'nonce-#{nonce}'"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def build(context = nil, nonce = nil, nonce_directives = nil)
|
|
211
|
+
nonce_directives ||= CSPMaker.nonce_directives || DEFAULT_NONCE_DIRECTIVES
|
|
212
|
+
build_directives(context, nonce, nonce_directives).compact.join("; ")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
def apply_mappings(sources)
|
|
219
|
+
sources.map do |source|
|
|
220
|
+
case source
|
|
221
|
+
when Symbol
|
|
222
|
+
apply_mapping(source)
|
|
223
|
+
when String
|
|
224
|
+
if hash_source?(source)
|
|
225
|
+
"'#{source}'"
|
|
226
|
+
else
|
|
227
|
+
source
|
|
228
|
+
end
|
|
229
|
+
when Proc
|
|
230
|
+
source
|
|
231
|
+
else
|
|
232
|
+
raise ArgumentError, "Invalid content security policy source: #{source.inspect}"
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def apply_mapping(source)
|
|
238
|
+
MAPPINGS.fetch(source) do
|
|
239
|
+
raise ArgumentError, "Unknown content security policy source mapping: #{source.inspect}"
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def build_directives(context, nonce, nonce_directives)
|
|
244
|
+
@directives.map do |directive, sources|
|
|
245
|
+
if sources.is_a?(Array)
|
|
246
|
+
if nonce && nonce_directive?(directive, nonce_directives)
|
|
247
|
+
"#{directive} #{build_directive(directive, sources, context).join(' ')} 'nonce-#{nonce}'"
|
|
248
|
+
else
|
|
249
|
+
"#{directive} #{build_directive(directive, sources, context).join(' ')}"
|
|
250
|
+
end
|
|
251
|
+
elsif sources
|
|
252
|
+
directive
|
|
253
|
+
else
|
|
254
|
+
nil
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def validate(directive, sources)
|
|
260
|
+
sources.each do |source|
|
|
261
|
+
if source.include?(";") || source =~ /[[:space:]]/
|
|
262
|
+
message =
|
|
263
|
+
"Invalid Content Security Policy #{directive}: #{source.inspect}. " +
|
|
264
|
+
"Directive values must not contain whitespace or semicolons. " +
|
|
265
|
+
"Please use multiple arguments or other directive methods instead."
|
|
266
|
+
|
|
267
|
+
raise InvalidDirectiveError, message
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def build_directive(directive, sources, context)
|
|
273
|
+
resolved_sources = sources.flat_map { |source| resolve_source(source, context) }
|
|
274
|
+
|
|
275
|
+
validate(directive, resolved_sources)
|
|
276
|
+
|
|
277
|
+
resolved_sources
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def resolve_source(source, context)
|
|
281
|
+
case source
|
|
282
|
+
when String
|
|
283
|
+
source
|
|
284
|
+
when Symbol
|
|
285
|
+
source.to_s
|
|
286
|
+
when Proc
|
|
287
|
+
if context.nil?
|
|
288
|
+
raise RuntimeError, "Missing context for the dynamic content security policy source: #{source.inspect}"
|
|
289
|
+
else
|
|
290
|
+
resolved = context.instance_exec(&source)
|
|
291
|
+
apply_mappings(wrap_array(resolved))
|
|
292
|
+
end
|
|
293
|
+
else
|
|
294
|
+
raise RuntimeError, "Unexpected content security policy source: #{source.inspect}"
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def wrap_array(object)
|
|
299
|
+
if object.nil?
|
|
300
|
+
[]
|
|
301
|
+
elsif object.respond_to?(:to_ary)
|
|
302
|
+
object.to_ary || [object]
|
|
303
|
+
else
|
|
304
|
+
[object]
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def nonce_directive?(directive, nonce_directives)
|
|
309
|
+
nonce_directives.include?(directive)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def hash_source?(source)
|
|
313
|
+
source.start_with?(*HASH_SOURCE_ALGORITHM_PREFIXES)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require_relative '../csp_maker'
|
|
2
|
+
require 'rack'
|
|
3
|
+
|
|
4
|
+
module CSPMaker
|
|
5
|
+
class Middleware
|
|
6
|
+
CONTENT_SECURITY_POLICY = "content-security-policy"
|
|
7
|
+
CONTENT_SECURITY_POLICY_REPORT_ONLY = "content-security-policy-report-only"
|
|
8
|
+
NONCE_ENV_KEY = "csp_maker.nonce"
|
|
9
|
+
|
|
10
|
+
def initialize(app, policy,
|
|
11
|
+
report_only: CSPMaker.report_only,
|
|
12
|
+
nonce_generator: CSPMaker.nonce_generator,
|
|
13
|
+
nonce_directives: CSPMaker.nonce_directives)
|
|
14
|
+
|
|
15
|
+
@app = app
|
|
16
|
+
@policy = policy
|
|
17
|
+
@report_only = report_only
|
|
18
|
+
@nonce_generator = nonce_generator
|
|
19
|
+
@nonce_directives = nonce_directives
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(env)
|
|
23
|
+
nonce = make_nonce(env) if @nonce_generator
|
|
24
|
+
|
|
25
|
+
status, headers, _ = response = @app.call(env)
|
|
26
|
+
|
|
27
|
+
# Returning CSP headers with a 304 Not Modified is harmful, since nonces in the
|
|
28
|
+
# new CSP headers might not match nonces in the cached HTML.
|
|
29
|
+
return response if status == 304
|
|
30
|
+
|
|
31
|
+
return response if policy_present?(headers)
|
|
32
|
+
|
|
33
|
+
request = Rack::Request.new(env)
|
|
34
|
+
headers[header_name] = @policy.build(request, nonce, @nonce_directives)
|
|
35
|
+
|
|
36
|
+
response
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def make_nonce(env)
|
|
42
|
+
if @nonce_generator
|
|
43
|
+
env[NONCE_ENV_KEY] ||= @nonce_generator.call
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def header_name
|
|
48
|
+
@report_only ? CONTENT_SECURITY_POLICY_REPORT_ONLY : CONTENT_SECURITY_POLICY
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def policy_present?(headers)
|
|
52
|
+
headers[CONTENT_SECURITY_POLICY] || headers[CONTENT_SECURITY_POLICY_REPORT_ONLY]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/csp_maker.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'csp_maker/csp'
|
|
4
|
+
require_relative 'csp_maker/version'
|
|
5
|
+
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
|
|
8
|
+
module CSPMaker
|
|
9
|
+
class << self
|
|
10
|
+
attr_accessor :report_only, :nonce_generator, :nonce_directives
|
|
11
|
+
|
|
12
|
+
def build_policy(context = nil, &block)
|
|
13
|
+
raise ArgumentError, "block required" unless block_given?
|
|
14
|
+
|
|
15
|
+
policy = define_policy(&block)
|
|
16
|
+
policy.build(context)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def define_policy(&block)
|
|
20
|
+
raise ArgumentError, "block required" unless block_given?
|
|
21
|
+
|
|
22
|
+
policy = ContentSecurityPolicy.new
|
|
23
|
+
|
|
24
|
+
if block.parameters.empty?
|
|
25
|
+
policy.instance_eval(&block)
|
|
26
|
+
else
|
|
27
|
+
block.call(policy)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
policy
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def default_generator
|
|
34
|
+
proc { SecureRandom.base64(16) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def make_nonce
|
|
38
|
+
nonce_generator.call
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: csp_maker
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kuba Suder
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rack
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.0'
|
|
26
|
+
description: "\n CSP Maker lets you define a Content Security Policy using a DSL
|
|
27
|
+
and encode it into a header string.\n It has support for nonces, and includes
|
|
28
|
+
a Rack middleware that automates generating and assigning\n the header.\n\n The
|
|
29
|
+
code was extracted from ActionDispatch::ContentSecurityPolicy in Rails ActionPack,
|
|
30
|
+
with some\n minor modifications. The DSL as described in Rails docs should be
|
|
31
|
+
usable 1:1 here.\n "
|
|
32
|
+
email:
|
|
33
|
+
- jakub.suder@gmail.com
|
|
34
|
+
executables: []
|
|
35
|
+
extensions: []
|
|
36
|
+
extra_rdoc_files: []
|
|
37
|
+
files:
|
|
38
|
+
- CHANGELOG.md
|
|
39
|
+
- README.md
|
|
40
|
+
- lib/csp_maker.rb
|
|
41
|
+
- lib/csp_maker/csp.rb
|
|
42
|
+
- lib/csp_maker/rack.rb
|
|
43
|
+
- lib/csp_maker/version.rb
|
|
44
|
+
homepage: https://tangled.org/mackuba.eu/csp_maker
|
|
45
|
+
licenses:
|
|
46
|
+
- MIT
|
|
47
|
+
metadata:
|
|
48
|
+
bug_tracker_uri: https://tangled.org/mackuba.eu/csp_maker/issues
|
|
49
|
+
changelog_uri: https://tangled.org/mackuba.eu/csp_maker/blob/master/CHANGELOG.md
|
|
50
|
+
source_code_uri: https://tangled.org/mackuba.eu/csp_maker
|
|
51
|
+
rubygems_mfa_required: 'true'
|
|
52
|
+
rdoc_options: []
|
|
53
|
+
require_paths:
|
|
54
|
+
- lib
|
|
55
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: 3.0.0
|
|
60
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: '0'
|
|
65
|
+
requirements: []
|
|
66
|
+
rubygems_version: 3.6.9
|
|
67
|
+
specification_version: 4
|
|
68
|
+
summary: A micro gem for defining a Content Security Policy using a DSL, extracted
|
|
69
|
+
from Rails
|
|
70
|
+
test_files: []
|