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 →