Skip to content

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

// Content-Security-Policy Builder — pure policy logic, no DOM, deterministic.
// C# 12 / .NET 8 — ported from src/lib/csp-builder.ts (the canonical
// TypeScript implementation). Display source for CosmoDev's polyglot pages.
//
// A CSP is modeled as a map of directive → source list. 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 method ever throws — Parse is lenient by design so a pasted
// real-world header always yields something editable.

/// <summary>How a directive takes its value: a source list, a single URL, or a bare flag.</summary>
public enum DirectiveKind { Sources, Url, Flag }

/// <summary>How much exposure the directive controls (drives UI emphasis).</summary>
public enum DirectiveRisk { Low, Medium, High }

/// <summary>One entry of the built-in directive catalog.</summary>
/// <param name="Name">Directive name, lowercase.</param>
/// <param name="Kind">Value shape (sources / url / flag).</param>
/// <param name="Description">Human explanation shown in the UI.</param>
/// <param name="Risk">Security weight of the directive.</param>
/// <param name="DefaultSources">Sources inserted when the directive is enabled.</param>
public sealed record DirectiveInfo(
    string Name,
    DirectiveKind Kind,
    string Description,
    DirectiveRisk Risk,
    string[] DefaultSources);

/// <summary>A policy: directive name (lowercase) → enabled source list. A present key means enabled.</summary>
public sealed class CspDirectiveMap : Dictionary<string, string[]>;

public static class CspBuilder
{
    /// <summary>The catalog, in canonical build/display order.</summary>
    public static readonly DirectiveInfo[] Directives =
    [
        new("default-src", DirectiveKind.Sources,
            "Fallback for every fetch directive you do not set explicitly. Set this first, then tighten individual directives.",
            DirectiveRisk.Medium, ["'self'"]),
        new("script-src", DirectiveKind.Sources,
            "Where scripts may load from. The single most important XSS control - keep it as tight as you can.",
            DirectiveRisk.High, ["'self'"]),
        new("style-src", DirectiveKind.Sources,
            "Where stylesheets may load from. Also gates inline style attributes.",
            DirectiveRisk.Medium, ["'self'"]),
        new("img-src", DirectiveKind.Sources,
            "Where images and favicons may load from.",
            DirectiveRisk.Low, ["'self'"]),
        new("connect-src", DirectiveKind.Sources,
            "Which URLs scripts may connect to (fetch, XHR, WebSocket). Your data-exfiltration boundary.",
            DirectiveRisk.Medium, ["'self'"]),
        new("font-src", DirectiveKind.Sources,
            "Where web fonts may load from.",
            DirectiveRisk.Low, ["'self'"]),
        new("frame-src", DirectiveKind.Sources,
            "Which URLs may be embedded as child browsing contexts (iframe, frame).",
            DirectiveRisk.Low, ["'self'"]),
        new("media-src", DirectiveKind.Sources,
            "Where audio and video may load from.",
            DirectiveRisk.Low, ["'self'"]),
        new("object-src", DirectiveKind.Sources,
            "Where plugin content (object, embed, applet) may load from. Almost always should be 'none'.",
            DirectiveRisk.High, ["'none'"]),
        new("base-uri", DirectiveKind.Sources,
            "Which URLs may set the document base. Restrict to 'self' to block <base> hijacking of relative URLs.",
            DirectiveRisk.High, ["'self'"]),
        new("form-action", DirectiveKind.Sources,
            "Where forms may submit to. Does not fall back to default-src.",
            DirectiveRisk.Medium, ["'self'"]),
        new("frame-ancestors", DirectiveKind.Sources,
            "Which parents may embed this page (clickjacking control). Ignored inside a <meta> tag - header delivery only.",
            DirectiveRisk.Medium, ["'self'"]),
        new("report-uri", DirectiveKind.Url,
            "URL where the browser posts violation reports. Pair with a report collector.",
            DirectiveRisk.Low, []),
        new("upgrade-insecure-requests", DirectiveKind.Flag,
            "Tells the browser to rewrite http:// subresource requests to https://.",
            DirectiveRisk.Low, []),
        new("block-all-mixed-content", DirectiveKind.Flag,
            "Blocks loading of any http:// subresource on an https:// page.",
            DirectiveRisk.Low, []),
    ];

    /// <summary>Source presets offered in the UI when adding a source to a directive.</summary>
    public static readonly string[] CommonSources =
    [
        "'self'", "'none'", "'unsafe-inline'", "'unsafe-eval'", "'strict-dynamic'",
        "data:", "blob:", "https:",
    ];

    /// <summary>Directives that take no value - emitted as a bare name.</summary>
    private static readonly HashSet<string> FlagDirectives =
        [.. Directives.Where(d => d.Kind == DirectiveKind.Flag).Select(d => d.Name)];

    /// <summary>Catalog names, for ordering during build.</summary>
    private static readonly HashSet<string> KnownDirectives =
        [.. Directives.Select(d => d.Name)];

    /// <summary>
    /// Assemble a policy map into the <c>Content-Security-Policy</c> 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.
    /// </summary>
    public static string Build(CspDirectiveMap directives)
    {
        var parts = new List<string>();
        void Emit(string name)
        {
            if (!directives.TryGetValue(name, out var sources)) return;
            if (FlagDirectives.Contains(name))
            {
                parts.Add(name);
                return;
            }
            if (sources.Length == 0) return;
            parts.Add($"{name} {string.Join(' ', sources)}");
        }
        foreach (var d in Directives) Emit(d.Name);
        foreach (var name in directives.Keys)
        {
            if (!KnownDirectives.Contains(name)) Emit(name);
        }
        return string.Join("; ", parts);
    }

    /// <summary>
    /// 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 <c>Content-Security-Policy:</c>
    /// label so a pasted full header line works. Duplicate directives keep only
    /// the first occurrence (matching how browsers honor them). Never throws;
    /// garbage in, empty map out.
    /// </summary>
    public static CspDirectiveMap Parse(string? header)
    {
        var text = (header ?? "").Trim();
        if (Regex.IsMatch(text, @"^content-security-policy\s*:", RegexOptions.IgnoreCase))
        {
            text = text[(text.IndexOf(':') + 1)..];
        }
        var map = new CspDirectiveMap();
        foreach (var token in text.Split(';', StringSplitOptions.TrimEntries))
        {
            var words = token.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
            if (words.Length == 0) continue;
            var name = words[0].ToLowerInvariant();
            if (map.ContainsKey(name)) continue;
            map[name] = words[1..];
        }
        return map;
    }

    /// <summary>Sources treated as security-weakening, compared case-insensitively.</summary>
    private static readonly HashSet<string> RiskySources =
    ["'unsafe-inline'", "'unsafe-eval'", "data:", "http:", "*"];

    /// <summary>
    /// 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.
    /// </summary>
    public static bool IsRiskySource(string source)
    {
        var s = source.Trim().ToLowerInvariant();
        return RiskySources.Contains(s) || s.StartsWith("http://", StringComparison.Ordinal);
    }

    /// <summary>Short human explanation for each risky source (tooltip text in the UI).</summary>
    private static readonly Dictionary<string, string> RiskExplanations = new()
    {
        ["'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.",
    };

    /// <summary>Explanation for any risky source; falls back to the generic insecure-origin text.</summary>
    public static string RiskExplanation(string source)
    {
        var key = source.Trim().ToLowerInvariant();
        return RiskExplanations.TryGetValue(key, out var text)
            ? text
            : "Insecure http:// URL - traffic can be tampered with in transit.";
    }

    /// <summary>One policy problem: either policy-wide (Directive = "") or a risky source.</summary>
    /// <param name="Directive">Directive the issue belongs to; "" for policy-wide issues.</param>
    /// <param name="Source">The offending source, or null for policy-wide issues.</param>
    /// <param name="Message">Human-readable problem description.</param>
    public sealed record CspIssue(string Directive, string? Source, string Message);

    /// <summary>
    /// 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.
    /// </summary>
    public static List<CspIssue> Validate(CspDirectiveMap directives)
    {
        var issues = new List<CspIssue>();
        if (!directives.ContainsKey("default-src"))
        {
            issues.Add(new CspIssue(
                "", null,
                "No default-src - every directive you don't set explicitly falls back to the browser's permissive default."));
        }
        foreach (var (name, sources) in directives)
        {
            foreach (var src in sources)
            {
                if (IsRiskySource(src))
                {
                    issues.Add(new CspIssue(
                        name, src,
                        $"{name}: {src} weakens this policy - {RiskExplanation(src)}"));
                }
            }
        }
        return issues;
    }

    /// <summary>Score penalty per risky source (case-insensitive key).</summary>
    private static readonly Dictionary<string, int> ScorePenalties = new()
    {
        ["'unsafe-inline'"] = 20,
        ["'unsafe-eval'"] = 15,
        ["*"] = 20,
        ["data:"] = 10,
        ["http:"] = 10,
    };

    /// <summary>
    /// 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.
    /// </summary>
    public static int SecurityScore(CspDirectiveMap directives)
    {
        var score = 100;
        if (!directives.ContainsKey("default-src")) score -= 10;
        foreach (var sources in directives.Values)
        {
            foreach (var src in sources)
            {
                var s = src.Trim().ToLowerInvariant();
                score -= ScorePenalties.TryGetValue(s, out var penalty)
                    ? penalty
                    : s.StartsWith("http://", StringComparison.Ordinal) ? 10 : 0;
            }
        }
        return Math.Clamp(score, 0, 100);
    }
}

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 →