Skip to content

CSP Builder — Zig 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 Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! csp-builder — Content-Security-Policy build / parse / lint / score.
//!
//! Language: Zig 0.14 (standard library only)
//! Ported from: src/lib/csp-builder.ts (the canonical TypeScript implementation).
//! display source — part of CosmoDev's polyglot tool pages.
//!
//! Pure logic - deterministic, no DOM. A CSP is modeled as an ordered map of
//! directive -> source list (Zig's StringArrayHashMap preserves insertion
//! order, which the TS Record relies on for unknown-directive ordering).
//! build assembles the map into the header string (directives in catalog
//! order, then any unknown directives in insertion order); parse reads a
//! header back into the map. Neither function fails - parse is lenient by
//! design so a pasted real-world header always yields something editable.

const std = @import("std");

/// How a directive takes its value: a source list, a single URL, or a bare flag.
pub const DirectiveKind = enum { sources, url, flag };

/// How much exposure the directive controls (drives UI emphasis).
pub const DirectiveRisk = enum { low, medium, high };

/// One entry of the built-in directive catalog.
pub const DirectiveInfo = struct {
    name: []const u8,
    kind: DirectiveKind,
    description: []const u8,
    risk: DirectiveRisk,
    /// Sources inserted when the directive is enabled in the UI.
    default_sources: []const []const u8,
};

/// A policy: directive name (lowercase) -> enabled source list.
/// Present key = enabled. Insertion order is preserved.
pub const CSPDirectiveMap = std.StringArrayHashMap([]const []const u8);

/// The catalog, in canonical build/display order.
pub const csp_directives = [_]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,
        .default_sources = &.{"'self'"},
    },
    .{
        .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,
        .default_sources = &.{"'self'"},
    },
    .{
        .name = "style-src",
        .kind = .sources,
        .description = "Where stylesheets may load from. Also gates inline style attributes.",
        .risk = .medium,
        .default_sources = &.{"'self'"},
    },
    .{
        .name = "img-src",
        .kind = .sources,
        .description = "Where images and favicons may load from.",
        .risk = .low,
        .default_sources = &.{"'self'"},
    },
    .{
        .name = "connect-src",
        .kind = .sources,
        .description = "Which URLs scripts may connect to (fetch, XHR, WebSocket). Your data-exfiltration boundary.",
        .risk = .medium,
        .default_sources = &.{"'self'"},
    },
    .{
        .name = "font-src",
        .kind = .sources,
        .description = "Where web fonts may load from.",
        .risk = .low,
        .default_sources = &.{"'self'"},
    },
    .{
        .name = "frame-src",
        .kind = .sources,
        .description = "Which URLs may be embedded as child browsing contexts (iframe, frame).",
        .risk = .low,
        .default_sources = &.{"'self'"},
    },
    .{
        .name = "media-src",
        .kind = .sources,
        .description = "Where audio and video may load from.",
        .risk = .low,
        .default_sources = &.{"'self'"},
    },
    .{
        .name = "object-src",
        .kind = .sources,
        .description = "Where plugin content (object, embed, applet) may load from. Almost always should be 'none'.",
        .risk = .high,
        .default_sources = &.{"'none'"},
    },
    .{
        .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,
        .default_sources = &.{"'self'"},
    },
    .{
        .name = "form-action",
        .kind = .sources,
        .description = "Where forms may submit to. Does not fall back to default-src.",
        .risk = .medium,
        .default_sources = &.{"'self'"},
    },
    .{
        .name = "frame-ancestors",
        .kind = .sources,
        .description = "Which parents may embed this page (clickjacking control). Ignored inside a <meta> tag - header delivery only.",
        .risk = .medium,
        .default_sources = &.{"'self'"},
    },
    .{
        .name = "report-uri",
        .kind = .url,
        .description = "URL where the browser posts violation reports. Pair with a report collector.",
        .risk = .low,
        .default_sources = &.{},
    },
    .{
        .name = "upgrade-insecure-requests",
        .kind = .flag,
        .description = "Tells the browser to rewrite http:// subresource requests to https://.",
        .risk = .low,
        .default_sources = &.{},
    },
    .{
        .name = "block-all-mixed-content",
        .kind = .flag,
        .description = "Blocks loading of any http:// subresource on an https:// page.",
        .risk = .low,
        .default_sources = &.{},
    },
};

/// Source presets offered in the UI when adding a source to a directive.
pub const common_sources = [_][]const u8{
    "'self'", "'none'", "'unsafe-inline'", "'unsafe-eval'",
    "'strict-dynamic'", "data:", "blob:", "https:",
};

fn isFlagDirective(name: []const u8) bool {
    for (csp_directives) |d| {
        if (d.kind == .flag and std.mem.eql(u8, d.name, name)) return true;
    }
    return false;
}

fn isKnownDirective(name: []const u8) bool {
    for (csp_directives) |d| {
        if (std.mem.eql(u8, d.name, name)) return true;
    }
    return false;
}

fn eqlLower(a: []const u8, b: []const u8) bool {
    return std.ascii.eqlIgnoreCase(a, b);
}

/// Assemble a policy map 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 map yields an empty string. Caller owns the result.
pub fn buildCSP(allocator: std.mem.Allocator, directives: *const CSPDirectiveMap) ![]u8 {
    var parts = std.ArrayList([]const u8).init(allocator);
    defer parts.deinit();

    const emit = struct {
        fn f(list: *std.ArrayList([]const u8), map: *const CSPDirectiveMap, name: []const u8) !void {
            const sources = map.get(name) orelse return;
            if (isFlagDirective(name)) {
                try list.append(name);
                return;
            }
            if (sources.len == 0) return;
            const joined = try std.mem.join(map.allocator, " ", sources);
            defer map.allocator.free(joined);
            try list.append(try std.fmt.allocPrint(list.allocator, "{s} {s}", .{ name, joined }));
        }
    }.f;

    for (csp_directives) |d| try emit(&parts, directives, d.name);
    var it = directives.iterator();
    while (it.next()) |entry| {
        if (!isKnownDirective(entry.key_ptr.*)) try emit(&parts, directives, entry.key_ptr.*);
    }
    return std.mem.join(allocator, "; ", parts.items);
}

/// Parse a CSP header value back into a policy map. 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 fails on garbage -
/// garbage in, empty map out. Caller owns the returned map.
pub fn parseCSP(allocator: std.mem.Allocator, header: []const u8) !CSPDirectiveMap {
    var out = CSPDirectiveMap.init(allocator);
    errdefer out.deinit();

    var text = std.mem.trim(u8, header, " \t\r\n");
    if (std.ascii.startsWithIgnoreCase(text, "content-security-policy")) {
        var rest = text["content-security-policy".len..];
        rest = std.mem.trimLeft(u8, rest, " \t");
        if (rest.len > 0 and rest[0] == ':') {
            text = rest[1..];
        }
    }

    var tokens = std.mem.splitScalar(u8, text, ';');
    while (tokens.next()) |token| {
        var words = std.ArrayList([]const u8).init(allocator);
        defer words.deinit();
        var ws = std.mem.tokenizeAny(u8, token, " \t\r\n");
        while (ws.next()) |w| try words.append(w);
        if (words.items.len == 0) continue;

        const name = try std.ascii.allocLowerString(allocator, words.items[0]);
        if (out.contains(name)) {
            allocator.free(name);
            continue;
        }
        const sources = try allocator.dupe([]const u8, words.items[1..]);
        try out.put(name, sources);
    }
    return out;
}

/// 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.
pub fn isRiskySource(source: []const u8) bool {
    const risky = [_][]const u8{ "'unsafe-inline'", "'unsafe-eval'", "data:", "http:", "*" };
    const s = std.mem.trim(u8, source, " \t\r\n");
    for (risky) |r| {
        if (eqlLower(s, r)) return true;
    }
    return std.ascii.startsWithIgnoreCase(s, "http://");
}

/// Short human explanation for each risky source (tooltip text in the UI).
pub fn riskExplanation(source: []const u8) []const u8 {
    const s = std.mem.trim(u8, source, " \t\r\n");
    if (eqlLower(s, "'unsafe-inline'"))
        return "Allows inline <script>/<style> and event handlers - defeats most of CSP's XSS protection.";
    if (eqlLower(s, "'unsafe-eval'"))
        return "Allows eval() and similar code execution - weakens XSS protection.";
    if (std.mem.eql(u8, s, "*"))
        return "Allows every origin - effectively no restriction for this directive.";
    if (eqlLower(s, "data:"))
        return "data: URIs can carry arbitrary payloads and are same-origin - attackers can smuggle content through them.";
    if (eqlLower(s, "http:"))
        return "Allows insecure origins - a network attacker can inject or tamper with subresources.";
    return "Insecure http:// URL - traffic can be tampered with in transit.";
}

/// One policy problem: either policy-wide (directive == "") or a risky source.
pub const CspIssue = struct {
    /// Directive the issue belongs to; "" for policy-wide issues.
    directive: []const u8,
    /// The offending source, or null for policy-wide issues.
    source: ?[]const u8,
    message: []const u8,
};

/// 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. Caller owns the returned list and each issue's message.
pub fn validateCSP(
    allocator: std.mem.Allocator,
    directives: *const CSPDirectiveMap,
) !std.ArrayList(CspIssue) {
    var issues = std.ArrayList(CspIssue).init(allocator);
    errdefer issues.deinit();

    if (directives.get("default-src") == null) {
        try issues.append(.{
            .directive = "",
            .source = null,
            .message = "No default-src - every directive you don't set explicitly falls back to the browser's permissive default.",
        });
    }
    var it = directives.iterator();
    while (it.next()) |entry| {
        for (entry.value_ptr.*) |src| {
            if (isRiskySource(src)) {
                try issues.append(.{
                    .directive = entry.key_ptr.*,
                    .source = src,
                    .message = try std.fmt.allocPrint(
                        allocator,
                        "{s}: {s} weakens this policy - {s}",
                        .{ entry.key_ptr.*, src, riskExplanation(src) },
                    ),
                });
            }
        }
    }
    return issues;
}

/// Score penalty per risky source (case-insensitive key).
fn scorePenalty(source_lower: []const u8) i32 {
    if (std.mem.eql(u8, source_lower, "'unsafe-inline'")) return 20;
    if (std.mem.eql(u8, source_lower, "'unsafe-eval'")) return 15;
    if (std.mem.eql(u8, source_lower, "*")) return 20;
    if (std.mem.eql(u8, source_lower, "data:")) return 10;
    if (std.mem.eql(u8, source_lower, "http:")) return 10;
    if (std.mem.startsWith(u8, source_lower, "http://")) return 10;
    return 0;
}

/// 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.
pub fn securityScore(directives: *const CSPDirectiveMap) i32 {
    var score: i32 = 100;
    if (directives.get("default-src") == null) score -= 10;
    var it = directives.iterator();
    while (it.next()) |entry| {
        for (entry.value_ptr.*) |src| {
            const s = std.mem.trim(u8, src, " \t\r\n");
            var buf: [512]u8 = undefined;
            const lower = std.ascii.lowerString(buf[0..@min(s.len, buf.len)], s);
            score -= scorePenalty(lower);
        }
    }
    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 →