Skip to content

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 →