Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Content Security Policy builder #14

Merged
merged 11 commits into from Mar 1, 2019
134 changes: 131 additions & 3 deletions README.md
Expand Up @@ -108,13 +108,134 @@ The Vapor Security Headers package will set a default CSP of `default-src: 'self

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

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:
You can build a CSP header (`ContentSecurityPolicy`) with the following directives:

- baseUri(sources)
- blockAllMixedContent()
- connectSrc(sources)
- defaultSrc(sources)
- fontSrc(sources)
- formAction(sources)
- frameAncestors(sources)
- frameSrc(sources)
- imgSrc(sources)
- manifestSrc(sources)
- mediaSrc(sources)
- objectSrc(sources)
- pluginTypes(types)
- reportTo(json_object)
- reportUri(uri)
- requireSriFor(values)
- sandbox(values)
- scriptSrc(sources)
- styleSrc(sources)
- upgradeInsecureRequests()
- workerSrc(sources)

*Example:*

```swift
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")
let cspConfig = ContentSecurityPolicy()
.scriptSrc(sources: "https://static.brokenhands.io")
.styleSrc(sources: "https://static.brokenhands.io")
.imgSrc(sources: "https://static.brokenhands.io")
```

```http
Content-Security-Policy: script-src https://static.brokenhands.io; style-src https://static.brokenhands.io; img-src https://static.brokenhands.io
```

You can set a custom header with ContentSecurityPolicy().set(value) or ContentSecurityPolicyConfiguration(value).

**ContentSecurityPolicy().set(value)**

```swift
let cspBuilder = ContentSecurityPolicy().set(value: "default-src: 'none'")

let cspConfig = ContentSecurityPolicyConfiguration(value: cspBuilder)

let securityHeaders = SecurityHeaders(contentSecurityPolicyConfiguration: cspConfig)
```

**ContentSecurityPolicyConfiguration(value)**

```swift
let cspConfig = ContentSecurityPolicyConfiguration(value: "default-src 'none'")

let securityHeaders = SecurityHeaders(contentSecurityPolicyConfiguration: cspConfig)
```

```http
Content-Security-Policy: default-src: 'none'
```

The following CSP keywords (`CSPKeywords`) are also available to you:

* CSPKeywords.all = *
* CSPKeywords.none = 'none'
* CSPKeywords.\`self\` = 'self'
* CSPKeywords.strictDynamic = 'strict-dynamic'
* CSPKeywords.unsafeEval = 'unsafe-eval'
* CSPKeywords.unsafeHashedAttributes = 'unsafe-hashed-attributes'
* CSPKeywords.unsafeInline = 'unsafe-inline'

*Example:*

``` swift
CSPKeywords.`self` // “‘self’”
ContentSecurityPolicy().defaultSrc(sources: CSPKeywords.`self`)
```

```http
Content-Security-Policy: default-src 'self'
```

You can also utilize the `Report-To` directive:

```swift
let reportToEndpoint = CSPReportToEndpoint(url: "https://csp-report.brokenhands.io/csp-reports")

let reportToValue = CSPReportTo(group: "vapor-csp", max_age: 10886400, endpoints: [reportToEndpoint], include_subdomains: true)

let cspValue = ContentSecurityPolicy()
.defaultSrc(sources: CSPKeywords.none)
.scriptSrc(sources: "https://static.brokenhands.io")
.reportTo(reportToObject: reportToValue)
```

```http
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}
```

See [Google Developers - The Reporting API](https://developers.google.com/web/updates/2018/09/reportingapi) for more information on the Report-To directive.

#### Content Security Policy Configuration

To configure your CSP you can add it to your `ContentSecurityPolicyConfiguration` like so:

```swift
let cspBuilder = ContentSecurityPolicy()
.defaultSrc(sources: CSPKeywords.none)
cak marked this conversation as resolved.
Show resolved Hide resolved
.scriptSrc(sources: "https://static.brokenhands.io")
.styleSrc(sources: "https://static.brokenhands.io")
.imgSrc(sources: "https://static.brokenhands.io")
.fontSrc(sources: "https://static.brokenhands.io")
.connectSrc(sources: "https://*.brokenhands.io")
.formAction(sources: CSPKeywords.`self`)
.upgradeInsecureRequests()
.blockAllMixedContent()
.requireSriFor(values: "script", "style")
.reportUri(uri: "https://csp-report.brokenhands.io")

let cspConfig = ContentSecurityPolicyConfiguration(value: cspBuilder)

let securityHeaders = SecurityHeaders(contentSecurityPolicyConfiguration: cspConfig)
```

```http
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
```

This policy means that by default everything is blocked, however:

* Scripts can be loaded from `https://static.brokenhands.io`
Expand All @@ -135,11 +256,18 @@ Check out [https://report-uri.io/](https://report-uri.io/) for a free tool to se
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:

```swift
let pageSpecificCSPVaue = "default-src 'none'; script-src https://comments.disqus.com;"
let cspConfig = ContentSecurityPolicy()
.defaultSrc(sources: CSPKeywords.none)
cak marked this conversation as resolved.
Show resolved Hide resolved
.scriptSrc(sources: "https://comments.disqus.com")

let pageSpecificCSP = ContentSecurityPolicyConfiguration(value: pageSpecificCSPValue)
request.contentSecurityPolicy = pageSpecificCSP
```

```http
Content-Security-Policy: default-src 'none'; script-src https://comments.disqus.com
```

You must also enable the `CSPRequestConfiguration` service for this to work. In `configure.swift` add:

```swift
Expand Down
@@ -1,13 +1,17 @@
import Vapor
import Foundation

public struct ContentSecurityPolicyConfiguration: SecurityHeaderConfiguration {

private let value: String

public init(value: String) {
self.value = value
}

public init(value: ContentSecurityPolicy) {
self.value = value.value
}

func setHeader(on response: Response, from request: Request) {
if let requestCSP = request.contentSecurityPolicy {
response.http.headers.replaceOrAdd(name: .contentSecurityPolicy, value: requestCSP.value)
Expand Down Expand Up @@ -38,3 +42,174 @@ extension Request {
}
}
}

public struct CSPReportTo: Codable {
private let group: String?
private let max_age: Int
private let endpoints: [CSPReportToEndpoint]
private let include_subdomains: Bool?

public init(group: String? = nil, max_age: Int,
endpoints: [CSPReportToEndpoint], include_subdomains: Bool? = nil) {
self.group = group
self.max_age = max_age
self.endpoints = endpoints
self.include_subdomains = include_subdomains
}
}

public struct CSPReportToEndpoint: Codable {
private let url: String

public init(url: String) {
self.url = url
}
}

extension CSPReportToEndpoint: Equatable {
public static func == (lhs: CSPReportToEndpoint, rhs: CSPReportToEndpoint) -> Bool {
return lhs.url == rhs.url
}
}

extension CSPReportTo: Equatable {
public static func == (lhs: CSPReportTo, rhs: CSPReportTo) -> Bool {
return lhs.group == rhs.group &&
lhs.max_age == rhs.max_age &&
lhs.endpoints == rhs.endpoints &&
lhs.include_subdomains == rhs.include_subdomains
}
}

public struct CSPKeywords {
public static let all = "*"
public static let none = "'none'"
public static let `self` = "'self'"
public static let strictDynamic = "'strict-dynamic'"
public static let unsafeEval = "'unsafe-eval'"
public static let unsafeHashedAttributes = "'unsafe-hashed-attributes'"
public static let unsafeInline = "'unsafe-inline'"
}

public class ContentSecurityPolicy {
private var policy: [String] = []

var value: String {
return policy.joined(separator: "; ")
}

public func set(value: String) -> ContentSecurityPolicy {
policy.append(value)
return self
}

public func baseUri(sources: String...) -> ContentSecurityPolicy {
policy.append("base-uri \(sources.joined(separator: " "))")
return self
}

public func blockAllMixedContent() -> ContentSecurityPolicy {
policy.append("block-all-mixed-content")
return self
}

public func connectSrc(sources: String...) -> ContentSecurityPolicy {
policy.append("connect-src \(sources.joined(separator: " "))")
return self
}

public func defaultSrc(sources: String...) -> ContentSecurityPolicy {
policy.append("default-src \(sources.joined(separator: " "))")
return self
}

public func fontSrc(sources: String...) -> ContentSecurityPolicy {
policy.append("font-src \(sources.joined(separator: " "))")
return self
}

public func formAction(sources: String...) -> ContentSecurityPolicy {
policy.append("form-action \(sources.joined(separator: " "))")
return self
}

public func frameAncestors(sources: String...) -> ContentSecurityPolicy {
policy.append("frame-ancestors \(sources.joined(separator: " "))")
return self
}

public func frameSrc(sources: String...) -> ContentSecurityPolicy {
policy.append("frame-src \(sources.joined(separator: " "))")
return self
}

public func imgSrc(sources: String...) -> ContentSecurityPolicy {
policy.append("img-src \(sources.joined(separator: " "))")
return self
}

public func manifestSrc(sources: String...) -> ContentSecurityPolicy {
policy.append("manifest-src \(sources.joined(separator: " "))")
return self
}

public func mediaSrc(sources: String...) -> ContentSecurityPolicy {
policy.append("media-src \(sources.joined(separator: " "))")
return self
}

public func objectSrc(sources: String...) -> ContentSecurityPolicy {
policy.append("object-src \(sources.joined(separator: " "))")
return self
}

public func pluginTypes(types: String...) -> ContentSecurityPolicy {
policy.append("plugin-types \(types.joined(separator: " "))")
return self
}

public func requireSriFor(values: String...) -> ContentSecurityPolicy {
policy.append("require-sri-for \(values.joined(separator: " "))")
return self
}

public func reportTo(reportToObject: CSPReportTo) -> ContentSecurityPolicy {
let encoder = JSONEncoder()
guard let data = try? encoder.encode(reportToObject) else { return self }
guard let jsonString = String(data: data, encoding: .utf8) else { return self }
policy.append("report-to \(String(describing: jsonString))")
return self
}

public func reportUri(uri: String) -> ContentSecurityPolicy {
policy.append("report-uri \(uri)")
return self
}

public func sandbox(values: String...) -> ContentSecurityPolicy {
policy.append("sandbox \(values.joined(separator: " "))")
return self
}

public func scriptSrc(sources: String...) -> ContentSecurityPolicy {
policy.append("script-src \(sources.joined(separator: " "))")
return self
}

public func styleSrc(sources: String...) -> ContentSecurityPolicy {
policy.append("style-src \(sources.joined(separator: " "))")
return self
}

public func upgradeInsecureRequests() -> ContentSecurityPolicy {
policy.append("upgrade-insecure-requests")
return self
}

public func workerSrc(sources: String...) -> ContentSecurityPolicy {
policy.append("worker-src \(sources.joined(separator: " "))")
return self
}

public init() {}
}
2 changes: 1 addition & 1 deletion Sources/VaporSecurityHeaders/SecurityHeaders.swift
Expand Up @@ -6,7 +6,7 @@ public struct SecurityHeaders {
var configurations: [SecurityHeaderConfiguration]

init(contentTypeConfiguration: ContentTypeOptionsConfiguration = ContentTypeOptionsConfiguration(option: .nosniff),
contentSecurityPolicyConfiguration: ContentSecurityPolicyConfiguration = ContentSecurityPolicyConfiguration(value: "default-src 'self'"),
contentSecurityPolicyConfiguration: ContentSecurityPolicyConfiguration = ContentSecurityPolicyConfiguration(value: ContentSecurityPolicy().defaultSrc(sources: CSPKeywords.`self`)),
cak marked this conversation as resolved.
Show resolved Hide resolved
frameOptionsConfiguration: FrameOptionsConfiguration = FrameOptionsConfiguration(option: .deny),
xssProtectionConfiguration: XSSProtectionConfiguration = XSSProtectionConfiguration(option: .block),
hstsConfiguration: StrictTransportSecurityConfiguration? = nil,
Expand Down
4 changes: 2 additions & 2 deletions Sources/VaporSecurityHeaders/SecurityHeadersFactory.swift
Expand Up @@ -2,7 +2,7 @@ import Vapor

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

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

Expand Down