Skip to content

Commit beecf53

Browse files
authored
Merge pull request #14 from cak/CSP-builder
Add Content Security Policy builder
2 parents 931b5a3 + 75ffdfa commit beecf53

File tree

5 files changed

+422
-15
lines changed

5 files changed

+422
-15
lines changed

README.md

Lines changed: 131 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,134 @@ The Vapor Security Headers package will set a default CSP of `default-src: 'self
108108

109109
The API default CSP is `default-src: 'none'` as an API should only return data and never be loading scripts or images to display!
110110

111-
I plan on massively improving creating the CSP configurations, but for now to configure your CSP you can add it to your `ContentSecurityPolicyConfiguration` like so:
111+
You can build a CSP header (`ContentSecurityPolicy`) with the following directives:
112+
113+
- baseUri(sources)
114+
- blockAllMixedContent()
115+
- connectSrc(sources)
116+
- defaultSrc(sources)
117+
- fontSrc(sources)
118+
- formAction(sources)
119+
- frameAncestors(sources)
120+
- frameSrc(sources)
121+
- imgSrc(sources)
122+
- manifestSrc(sources)
123+
- mediaSrc(sources)
124+
- objectSrc(sources)
125+
- pluginTypes(types)
126+
- reportTo(json_object)
127+
- reportUri(uri)
128+
- requireSriFor(values)
129+
- sandbox(values)
130+
- scriptSrc(sources)
131+
- styleSrc(sources)
132+
- upgradeInsecureRequests()
133+
- workerSrc(sources)
134+
135+
*Example:*
112136

113137
```swift
114-
let cspConfig = ContentSecurityPolicyConfiguration(value: "default-src 'none'; script-src https://static.brokenhands.io; style-src https://static.brokenhands.io; img-src https://static.brokenhands.io; font-src https://static.brokenhands.io; connect-src https://*.brokenhands.io; form-action 'self'; upgrade-insecure-requests; block-all-mixed-content; require-sri-for script style; report-uri https://csp-report.brokenhands.io")
138+
let cspConfig = ContentSecurityPolicy()
139+
.scriptSrc(sources: "https://static.brokenhands.io")
140+
.styleSrc(sources: "https://static.brokenhands.io")
141+
.imgSrc(sources: "https://static.brokenhands.io")
142+
```
143+
144+
```http
145+
Content-Security-Policy: script-src https://static.brokenhands.io; style-src https://static.brokenhands.io; img-src https://static.brokenhands.io
146+
```
147+
148+
You can set a custom header with ContentSecurityPolicy().set(value) or ContentSecurityPolicyConfiguration(value).
149+
150+
**ContentSecurityPolicy().set(value)**
151+
152+
```swift
153+
let cspBuilder = ContentSecurityPolicy().set(value: "default-src: 'none'")
154+
155+
let cspConfig = ContentSecurityPolicyConfiguration(value: cspBuilder)
156+
115157
let securityHeaders = SecurityHeaders(contentSecurityPolicyConfiguration: cspConfig)
116158
```
117159

160+
**ContentSecurityPolicyConfiguration(value)**
161+
162+
```swift
163+
let cspConfig = ContentSecurityPolicyConfiguration(value: "default-src 'none'")
164+
165+
let securityHeaders = SecurityHeaders(contentSecurityPolicyConfiguration: cspConfig)
166+
```
167+
168+
```http
169+
Content-Security-Policy: default-src: 'none'
170+
```
171+
172+
The following CSP keywords (`CSPKeywords`) are also available to you:
173+
174+
* CSPKeywords.all = *
175+
* CSPKeywords.none = 'none'
176+
* CSPKeywords.\`self\` = 'self'
177+
* CSPKeywords.strictDynamic = 'strict-dynamic'
178+
* CSPKeywords.unsafeEval = 'unsafe-eval'
179+
* CSPKeywords.unsafeHashedAttributes = 'unsafe-hashed-attributes'
180+
* CSPKeywords.unsafeInline = 'unsafe-inline'
181+
182+
*Example:*
183+
184+
``` swift
185+
CSPKeywords.`self` // “‘self’”
186+
ContentSecurityPolicy().defaultSrc(sources: CSPKeywords.`self`)
187+
```
188+
189+
```http
190+
Content-Security-Policy: default-src 'self'
191+
```
192+
193+
You can also utilize the `Report-To` directive:
194+
195+
```swift
196+
let reportToEndpoint = CSPReportToEndpoint(url: "https://csp-report.brokenhands.io/csp-reports")
197+
198+
let reportToValue = CSPReportTo(group: "vapor-csp", max_age: 10886400, endpoints: [reportToEndpoint], include_subdomains: true)
199+
200+
let cspValue = ContentSecurityPolicy()
201+
.defaultSrc(sources: CSPKeywords.none)
202+
.scriptSrc(sources: "https://static.brokenhands.io")
203+
.reportTo(reportToObject: reportToValue)
204+
```
205+
206+
```http
207+
Content-Security-Policy: default-src 'none'; script-src https://static.brokenhands.io; report-to {"group":"vapor-csp","endpoints":[{"url":"https:\/\/csp-report.brokenhands.io\/csp-reports"}],"include_subdomains":true,"max_age":10886400}
208+
```
209+
210+
See [Google Developers - The Reporting API](https://developers.google.com/web/updates/2018/09/reportingapi) for more information on the Report-To directive.
211+
212+
#### Content Security Policy Configuration
213+
214+
To configure your CSP you can add it to your `ContentSecurityPolicyConfiguration` like so:
215+
216+
```swift
217+
let cspBuilder = ContentSecurityPolicy()
218+
.defaultSrc(sources: CSPKeywords.none)
219+
.scriptSrc(sources: "https://static.brokenhands.io")
220+
.styleSrc(sources: "https://static.brokenhands.io")
221+
.imgSrc(sources: "https://static.brokenhands.io")
222+
.fontSrc(sources: "https://static.brokenhands.io")
223+
.connectSrc(sources: "https://*.brokenhands.io")
224+
.formAction(sources: CSPKeywords.`self`)
225+
.upgradeInsecureRequests()
226+
.blockAllMixedContent()
227+
.requireSriFor(values: "script", "style")
228+
.reportUri(uri: "https://csp-report.brokenhands.io")
229+
230+
let cspConfig = ContentSecurityPolicyConfiguration(value: cspBuilder)
231+
232+
let securityHeaders = SecurityHeaders(contentSecurityPolicyConfiguration: cspConfig)
233+
```
234+
235+
```http
236+
Content-Security-Policy: default-src 'none'; script-src https://static.brokenhands.io; style-src https://static.brokenhands.io; img-src https://static.brokenhands.io; font-src https://static.brokenhands.io; connect-src https://*.brokenhands.io; form-action 'self'; upgrade-insecure-requests; block-all-mixed-content; require-sri-for script style; report-uri https://csp-report.brokenhands.io
237+
```
238+
118239
This policy means that by default everything is blocked, however:
119240

120241
* Scripts can be loaded from `https://static.brokenhands.io`
@@ -135,11 +256,18 @@ Check out [https://report-uri.io/](https://report-uri.io/) for a free tool to se
135256
Vapor Security Headers also supports setting the CSP on a route or request basis. If the middleware has been added to the `MiddlewareConfig`, you can override the CSP for a request. This allows you to have a strict default CSP, but allow content from extra sources when required, such as only allowing the Javascript for blog comments on the blog page. Create a separate `ContentSecurityPolicyConfiguration` and then add it to the request. For example, inside a route handler, you could do:
136257

137258
```swift
138-
let pageSpecificCSPVaue = "default-src 'none'; script-src https://comments.disqus.com;"
259+
let cspConfig = ContentSecurityPolicy()
260+
.defaultSrc(sources: CSPKeywords.none)
261+
.scriptSrc(sources: "https://comments.disqus.com")
262+
139263
let pageSpecificCSP = ContentSecurityPolicyConfiguration(value: pageSpecificCSPValue)
140264
request.contentSecurityPolicy = pageSpecificCSP
141265
```
142266

267+
```http
268+
Content-Security-Policy: default-src 'none'; script-src https://comments.disqus.com
269+
```
270+
143271
You must also enable the `CSPRequestConfiguration` service for this to work. In `configure.swift` add:
144272

145273
```swift

Sources/VaporSecurityHeaders/Configurations/ContentSecurityPolicyConfiguration.swift

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import Vapor
2+
import Foundation
23

34
public struct ContentSecurityPolicyConfiguration: SecurityHeaderConfiguration {
4-
55
private let value: String
66

77
public init(value: String) {
88
self.value = value
99
}
1010

11+
public init(value: ContentSecurityPolicy) {
12+
self.value = value.value
13+
}
14+
1115
func setHeader(on response: Response, from request: Request) {
1216
if let requestCSP = request.contentSecurityPolicy {
1317
response.http.headers.replaceOrAdd(name: .contentSecurityPolicy, value: requestCSP.value)
@@ -38,3 +42,174 @@ extension Request {
3842
}
3943
}
4044
}
45+
46+
public struct CSPReportTo: Codable {
47+
private let group: String?
48+
private let max_age: Int
49+
private let endpoints: [CSPReportToEndpoint]
50+
private let include_subdomains: Bool?
51+
52+
public init(group: String? = nil, max_age: Int,
53+
endpoints: [CSPReportToEndpoint], include_subdomains: Bool? = nil) {
54+
self.group = group
55+
self.max_age = max_age
56+
self.endpoints = endpoints
57+
self.include_subdomains = include_subdomains
58+
}
59+
}
60+
61+
public struct CSPReportToEndpoint: Codable {
62+
private let url: String
63+
64+
public init(url: String) {
65+
self.url = url
66+
}
67+
}
68+
69+
extension CSPReportToEndpoint: Equatable {
70+
public static func == (lhs: CSPReportToEndpoint, rhs: CSPReportToEndpoint) -> Bool {
71+
return lhs.url == rhs.url
72+
}
73+
}
74+
75+
extension CSPReportTo: Equatable {
76+
public static func == (lhs: CSPReportTo, rhs: CSPReportTo) -> Bool {
77+
return lhs.group == rhs.group &&
78+
lhs.max_age == rhs.max_age &&
79+
lhs.endpoints == rhs.endpoints &&
80+
lhs.include_subdomains == rhs.include_subdomains
81+
}
82+
}
83+
84+
public struct CSPKeywords {
85+
public static let all = "*"
86+
public static let none = "'none'"
87+
public static let `self` = "'self'"
88+
public static let strictDynamic = "'strict-dynamic'"
89+
public static let unsafeEval = "'unsafe-eval'"
90+
public static let unsafeHashedAttributes = "'unsafe-hashed-attributes'"
91+
public static let unsafeInline = "'unsafe-inline'"
92+
}
93+
94+
public class ContentSecurityPolicy {
95+
private var policy: [String] = []
96+
97+
var value: String {
98+
return policy.joined(separator: "; ")
99+
}
100+
101+
public func set(value: String) -> ContentSecurityPolicy {
102+
policy.append(value)
103+
return self
104+
}
105+
106+
public func baseUri(sources: String...) -> ContentSecurityPolicy {
107+
policy.append("base-uri \(sources.joined(separator: " "))")
108+
return self
109+
}
110+
111+
public func blockAllMixedContent() -> ContentSecurityPolicy {
112+
policy.append("block-all-mixed-content")
113+
return self
114+
}
115+
116+
public func connectSrc(sources: String...) -> ContentSecurityPolicy {
117+
policy.append("connect-src \(sources.joined(separator: " "))")
118+
return self
119+
}
120+
121+
public func defaultSrc(sources: String...) -> ContentSecurityPolicy {
122+
policy.append("default-src \(sources.joined(separator: " "))")
123+
return self
124+
}
125+
126+
public func fontSrc(sources: String...) -> ContentSecurityPolicy {
127+
policy.append("font-src \(sources.joined(separator: " "))")
128+
return self
129+
}
130+
131+
public func formAction(sources: String...) -> ContentSecurityPolicy {
132+
policy.append("form-action \(sources.joined(separator: " "))")
133+
return self
134+
}
135+
136+
public func frameAncestors(sources: String...) -> ContentSecurityPolicy {
137+
policy.append("frame-ancestors \(sources.joined(separator: " "))")
138+
return self
139+
}
140+
141+
public func frameSrc(sources: String...) -> ContentSecurityPolicy {
142+
policy.append("frame-src \(sources.joined(separator: " "))")
143+
return self
144+
}
145+
146+
public func imgSrc(sources: String...) -> ContentSecurityPolicy {
147+
policy.append("img-src \(sources.joined(separator: " "))")
148+
return self
149+
}
150+
151+
public func manifestSrc(sources: String...) -> ContentSecurityPolicy {
152+
policy.append("manifest-src \(sources.joined(separator: " "))")
153+
return self
154+
}
155+
156+
public func mediaSrc(sources: String...) -> ContentSecurityPolicy {
157+
policy.append("media-src \(sources.joined(separator: " "))")
158+
return self
159+
}
160+
161+
public func objectSrc(sources: String...) -> ContentSecurityPolicy {
162+
policy.append("object-src \(sources.joined(separator: " "))")
163+
return self
164+
}
165+
166+
public func pluginTypes(types: String...) -> ContentSecurityPolicy {
167+
policy.append("plugin-types \(types.joined(separator: " "))")
168+
return self
169+
}
170+
171+
public func requireSriFor(values: String...) -> ContentSecurityPolicy {
172+
policy.append("require-sri-for \(values.joined(separator: " "))")
173+
return self
174+
}
175+
176+
public func reportTo(reportToObject: CSPReportTo) -> ContentSecurityPolicy {
177+
let encoder = JSONEncoder()
178+
guard let data = try? encoder.encode(reportToObject) else { return self }
179+
guard let jsonString = String(data: data, encoding: .utf8) else { return self }
180+
policy.append("report-to \(String(describing: jsonString))")
181+
return self
182+
}
183+
184+
public func reportUri(uri: String) -> ContentSecurityPolicy {
185+
policy.append("report-uri \(uri)")
186+
return self
187+
}
188+
189+
public func sandbox(values: String...) -> ContentSecurityPolicy {
190+
policy.append("sandbox \(values.joined(separator: " "))")
191+
return self
192+
}
193+
194+
public func scriptSrc(sources: String...) -> ContentSecurityPolicy {
195+
policy.append("script-src \(sources.joined(separator: " "))")
196+
return self
197+
}
198+
199+
public func styleSrc(sources: String...) -> ContentSecurityPolicy {
200+
policy.append("style-src \(sources.joined(separator: " "))")
201+
return self
202+
}
203+
204+
public func upgradeInsecureRequests() -> ContentSecurityPolicy {
205+
policy.append("upgrade-insecure-requests")
206+
return self
207+
}
208+
209+
public func workerSrc(sources: String...) -> ContentSecurityPolicy {
210+
policy.append("worker-src \(sources.joined(separator: " "))")
211+
return self
212+
}
213+
214+
public init() {}
215+
}

Sources/VaporSecurityHeaders/SecurityHeaders.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ public struct SecurityHeaders {
66
var configurations: [SecurityHeaderConfiguration]
77

88
init(contentTypeConfiguration: ContentTypeOptionsConfiguration = ContentTypeOptionsConfiguration(option: .nosniff),
9-
contentSecurityPolicyConfiguration: ContentSecurityPolicyConfiguration = ContentSecurityPolicyConfiguration(value: "default-src 'self'"),
9+
contentSecurityPolicyConfiguration: ContentSecurityPolicyConfiguration = ContentSecurityPolicyConfiguration(value: ContentSecurityPolicy().defaultSrc(sources: CSPKeywords.`self`)),
1010
frameOptionsConfiguration: FrameOptionsConfiguration = FrameOptionsConfiguration(option: .deny),
1111
xssProtectionConfiguration: XSSProtectionConfiguration = XSSProtectionConfiguration(option: .block),
1212
hstsConfiguration: StrictTransportSecurityConfiguration? = nil,

Sources/VaporSecurityHeaders/SecurityHeadersFactory.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Vapor
22

33
public class SecurityHeadersFactory {
44
var contentTypeOptions = ContentTypeOptionsConfiguration(option: .nosniff)
5-
var contentSecurityPolicy = ContentSecurityPolicyConfiguration(value: "default-src 'self'")
5+
var contentSecurityPolicy = ContentSecurityPolicyConfiguration(value: ContentSecurityPolicy().defaultSrc(sources: CSPKeywords.`self`))
66
var frameOptions = FrameOptionsConfiguration(option: .deny)
77
var xssProtection = XSSProtectionConfiguration(option: .block)
88
var hsts: StrictTransportSecurityConfiguration?
@@ -14,7 +14,7 @@ public class SecurityHeadersFactory {
1414

1515
public static func api() -> SecurityHeadersFactory {
1616
let apiFactory = SecurityHeadersFactory()
17-
apiFactory.contentSecurityPolicy = ContentSecurityPolicyConfiguration(value: "default-src 'none'")
17+
apiFactory.contentSecurityPolicy = ContentSecurityPolicyConfiguration(value: ContentSecurityPolicy().defaultSrc(sources: CSPKeywords.none))
1818
return apiFactory
1919
}
2020

0 commit comments

Comments
 (0)