CSP Builder — Swift source
Build a Content-Security-Policy header interactively. Toggle directives, add sources, see the assembled header in real time — with a security score that flags unsafe sources.
This is the Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.
// csp-builder — Content-Security-Policy builder / parser / linter.
//
// Language: Swift 5.9+ (Foundation only)
// Ported from src/lib/csp-builder.ts
// display source — part of CosmoDev's polyglot tool pages
//
// A CSP is modeled as an ordered list of directives. build assembles the list
// into the header string (directives in catalog order, then any unknown
// directives in insertion order); parse reads a header back into the list.
// Neither function ever throws - parse is lenient by design so a pasted
// real-world header always yields something editable.
import Foundation
// MARK: - Types
/// How a directive takes its value: a source list, a single URL, or a bare flag.
enum DirectiveKind: String {
case sources, url, flag
}
/// How much exposure the directive controls (drives UI emphasis).
enum DirectiveRisk: String {
case low, medium, high
}
/// One entry of the built-in directive catalog.
struct DirectiveInfo {
let name: String
let kind: DirectiveKind
let description: String
let risk: DirectiveRisk
/// Sources inserted when the directive is enabled in the UI.
let defaultSources: [String]
}
/// One policy directive: lowercase name -> enabled source list. Present = enabled.
/// Kept as an ordered array so unknown directives round-trip in insertion order.
struct CSPDirective {
let name: String
var sources: [String]
}
/// A policy: an ordered list of directives.
typealias CSPDirectiveMap = [CSPDirective]
// MARK: - Catalog
/// The catalog, in canonical build/display order.
let CSP_DIRECTIVES: [DirectiveInfo] = [
DirectiveInfo(
name: "default-src", kind: .sources,
description: "Fallback for every fetch directive you do not set explicitly. Set this first, then tighten individual directives.",
risk: .medium, defaultSources: ["'self'"]),
DirectiveInfo(
name: "script-src", kind: .sources,
description: "Where scripts may load from. The single most important XSS control - keep it as tight as you can.",
risk: .high, defaultSources: ["'self'"]),
DirectiveInfo(
name: "style-src", kind: .sources,
description: "Where stylesheets may load from. Also gates inline style attributes.",
risk: .medium, defaultSources: ["'self'"]),
DirectiveInfo(
name: "img-src", kind: .sources,
description: "Where images and favicons may load from.",
risk: .low, defaultSources: ["'self'"]),
DirectiveInfo(
name: "connect-src", kind: .sources,
description: "Which URLs scripts may connect to (fetch, XHR, WebSocket). Your data-exfiltration boundary.",
risk: .medium, defaultSources: ["'self'"]),
DirectiveInfo(
name: "font-src", kind: .sources,
description: "Where web fonts may load from.",
risk: .low, defaultSources: ["'self'"]),
DirectiveInfo(
name: "frame-src", kind: .sources,
description: "Which URLs may be embedded as child browsing contexts (iframe, frame).",
risk: .low, defaultSources: ["'self'"]),
DirectiveInfo(
name: "media-src", kind: .sources,
description: "Where audio and video may load from.",
risk: .low, defaultSources: ["'self'"]),
DirectiveInfo(
name: "object-src", kind: .sources,
description: "Where plugin content (object, embed, applet) may load from. Almost always should be 'none'.",
risk: .high, defaultSources: ["'none'"]),
DirectiveInfo(
name: "base-uri", kind: .sources,
description: "Which URLs may set the document base. Restrict to 'self' to block <base> hijacking of relative URLs.",
risk: .high, defaultSources: ["'self'"]),
DirectiveInfo(
name: "form-action", kind: .sources,
description: "Where forms may submit to. Does not fall back to default-src.",
risk: .medium, defaultSources: ["'self'"]),
DirectiveInfo(
name: "frame-ancestors", kind: .sources,
description: "Which parents may embed this page (clickjacking control). Ignored inside a <meta> tag - header delivery only.",
risk: .medium, defaultSources: ["'self'"]),
DirectiveInfo(
name: "report-uri", kind: .url,
description: "URL where the browser posts violation reports. Pair with a report collector.",
risk: .low, defaultSources: []),
DirectiveInfo(
name: "upgrade-insecure-requests", kind: .flag,
description: "Tells the browser to rewrite http:// subresource requests to https://.",
risk: .low, defaultSources: []),
DirectiveInfo(
name: "block-all-mixed-content", kind: .flag,
description: "Blocks loading of any http:// subresource on an https:// page.",
risk: .low, defaultSources: []),
]
/// Source presets offered in the UI when adding a source to a directive.
let COMMON_SOURCES: [String] = [
"'self'", "'none'", "'unsafe-inline'", "'unsafe-eval'",
"'strict-dynamic'", "data:", "blob:", "https:",
]
/// Directives that take no value - emitted as a bare name.
let FLAG_DIRECTIVES: Set<String> = Set(CSP_DIRECTIVES.filter { $0.kind == .flag }.map { $0.name })
/// Catalog names, for ordering during build.
let KNOWN_DIRECTIVES: Set<String> = Set(CSP_DIRECTIVES.map { $0.name })
// MARK: - Build / parse
/// First source list registered for `name`, if the policy sets it.
func sources(for name: String, in directives: CSPDirectiveMap) -> [String]? {
directives.first { $0.name == name }?.sources
}
/**
Assemble a policy into the `Content-Security-Policy` header value.
Known directives emit in catalog order, unknown directives after them in
insertion order. Flag directives emit as a bare name; source/url directives
with an empty list are omitted (a valueless directive is invalid CSP).
An empty policy yields an empty string.
*/
func buildCSP(_ directives: CSPDirectiveMap) -> String {
var parts: [String] = []
func emit(_ name: String) {
guard let entry = directives.first(where: { $0.name == name }) else { return }
if FLAG_DIRECTIVES.contains(name) {
parts.append(name)
return
}
if entry.sources.isEmpty { return }
parts.append("\(name) \(entry.sources.joined(separator: " "))")
}
for d in CSP_DIRECTIVES { emit(d.name) }
for directive in directives where !KNOWN_DIRECTIVES.contains(directive.name) {
emit(directive.name)
}
return parts.joined(separator: "; ")
}
/**
Parse a CSP header value back into a policy. Lenient: splits on semicolons
and whitespace, lowercases directive names, ignores empty tokens, and strips
an optional leading `Content-Security-Policy:` label so a pasted full header
line works. Duplicate directives keep only the first occurrence (matching how
browsers honor them). Never throws; garbage in, [] out.
*/
func parseCSP(_ header: String) -> CSPDirectiveMap {
var text = header.trimmingCharacters(in: .whitespacesAndNewlines)
if text.lowercased().hasPrefix("content-security-policy") {
// drop the label up to and including the first colon
if let colon = text.firstIndex(of: ":") {
text = String(text[text.index(after: colon)...])
}
}
var out: CSPDirectiveMap = []
var seen: Set<String> = []
for token in text.split(separator: ";") {
let words = token.split(whereSeparator: { $0 == " " || $0 == "\t" })
.map(String.init)
.filter { !$0.isEmpty }
if words.isEmpty { continue }
let name = words[0].lowercased()
if seen.contains(name) { continue }
seen.insert(name)
out.append(CSPDirective(name: name, sources: Array(words.dropFirst())))
}
return out
}
// MARK: - Risky sources
/// Sources treated as security-weakening, compared case-insensitively.
let RISKY_SOURCES: Set<String> = ["'unsafe-inline'", "'unsafe-eval'", "data:", "http:", "*"]
/**
True when a source weakens the policy: 'unsafe-inline', 'unsafe-eval',
'data:', 'http:', the bare wildcard '*', or any insecure http:// URL.
'self', 'none', 'strict-dynamic', 'blob:', 'https:' and https URLs are fine.
*/
func isRiskySource(_ source: String) -> Bool {
let s = source.trimmingCharacters(in: .whitespaces).lowercased()
return RISKY_SOURCES.contains(s) || s.hasPrefix("http://")
}
/// Short human explanation for each risky source (tooltip text in the UI).
let RISK_EXPLANATIONS: [String: String] = [
"'unsafe-inline'": "Allows inline <script>/<style> and event handlers - defeats most of CSP's XSS protection.",
"'unsafe-eval'": "Allows eval() and similar code execution - weakens XSS protection.",
"*": "Allows every origin - effectively no restriction for this directive.",
"data:": "data: URIs can carry arbitrary payloads and are same-origin - attackers can smuggle content through them.",
"http:": "Allows insecure origins - a network attacker can inject or tamper with subresources.",
]
/// Explanation for any risky source; falls back to the generic insecure-origin text.
func riskExplanation(_ source: String) -> String {
let key = source.trimmingCharacters(in: .whitespaces).lowercased()
return RISK_EXPLANATIONS[key] ?? "Insecure http:// URL - traffic can be tampered with in transit."
}
// MARK: - Lint + score
/// One policy problem: either policy-wide (directive == "") or a risky source.
struct CspIssue {
/// Directive the issue belongs to; "" for policy-wide issues.
let directive: String
/// The offending source, or nil for policy-wide issues.
let source: String?
let message: String
}
/**
Lint a policy: warns when default-src is missing (unset directives fall back
to the browser's allow-everything default) and flags every risky source.
*/
func validateCSP(_ directives: CSPDirectiveMap) -> [CspIssue] {
var issues: [CspIssue] = []
if sources(for: "default-src", in: directives) == nil {
issues.append(CspIssue(
directive: "", source: nil,
message: "No default-src - every directive you don't set explicitly falls back to the browser's permissive default."))
}
for directive in directives {
for src in directive.sources where isRiskySource(src) {
issues.append(CspIssue(
directive: directive.name,
source: src,
message: "\(directive.name): \(src) weakens this policy - \(riskExplanation(src))"))
}
}
return issues
}
/// Score penalty per risky source (case-insensitive key).
let SCORE_PENALTIES: [String: Int] = [
"'unsafe-inline'": 20,
"'unsafe-eval'": 15,
"*": 20,
"data:": 10,
"http:": 10,
]
/**
Security score, 0-100. Starts at 100; each risky source subtracts its
penalty (insecure http:// URLs subtract 10), and a missing default-src
subtracts 10. Clamped to 0-100. Deterministic.
*/
func securityScore(_ directives: CSPDirectiveMap) -> Int {
var score = 100
if sources(for: "default-src", in: directives) == nil { score -= 10 }
for directive in directives {
for src in directive.sources {
let s = src.trimmingCharacters(in: .whitespaces).lowercased()
score -= SCORE_PENALTIES[s] ?? (s.hasPrefix("http://") ? 10 : 0)
}
}
return max(0, min(100, score))
}
Also available in 8 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →