CSP Builder — TypeScript 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 TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Pure Content-Security-Policy logic - no React, no DOM, deterministic.
// 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 function ever throws - parse is lenient by design so a pasted
// real-world header always yields something editable.
/** How a directive takes its value: a source list, a single URL, or a bare flag. */
export type DirectiveKind = 'sources' | 'url' | 'flag';
/** How much exposure the directive controls (drives UI emphasis). */
export type DirectiveRisk = 'low' | 'medium' | 'high';
/** One entry of the built-in directive catalog. */
export interface DirectiveInfo {
name: string;
kind: DirectiveKind;
description: string;
risk: DirectiveRisk;
/** Sources inserted when the directive is enabled in the UI. */
defaultSources: string[];
}
/** A policy: directive name (lowercase) -> enabled source list. Present key = enabled. */
export type CSPDirectiveMap = Record<string, string[]>;
/** The catalog, in canonical build/display order. */
export 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',
defaultSources: ["'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',
defaultSources: ["'self'"],
},
{
name: 'style-src',
kind: 'sources',
description: 'Where stylesheets may load from. Also gates inline style attributes.',
risk: 'medium',
defaultSources: ["'self'"],
},
{
name: 'img-src',
kind: 'sources',
description: 'Where images and favicons may load from.',
risk: 'low',
defaultSources: ["'self'"],
},
{
name: 'connect-src',
kind: 'sources',
description:
'Which URLs scripts may connect to (fetch, XHR, WebSocket). Your data-exfiltration boundary.',
risk: 'medium',
defaultSources: ["'self'"],
},
{
name: 'font-src',
kind: 'sources',
description: 'Where web fonts may load from.',
risk: 'low',
defaultSources: ["'self'"],
},
{
name: 'frame-src',
kind: 'sources',
description: 'Which URLs may be embedded as child browsing contexts (iframe, frame).',
risk: 'low',
defaultSources: ["'self'"],
},
{
name: 'media-src',
kind: 'sources',
description: 'Where audio and video may load from.',
risk: 'low',
defaultSources: ["'self'"],
},
{
name: 'object-src',
kind: 'sources',
description:
'Where plugin content (object, embed, applet) may load from. Almost always should be \'none\'.',
risk: 'high',
defaultSources: ["'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',
defaultSources: ["'self'"],
},
{
name: 'form-action',
kind: 'sources',
description: 'Where forms may submit to. Does not fall back to default-src.',
risk: 'medium',
defaultSources: ["'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',
defaultSources: ["'self'"],
},
{
name: 'report-uri',
kind: 'url',
description: 'URL where the browser posts violation reports. Pair with a report collector.',
risk: 'low',
defaultSources: [],
},
{
name: 'upgrade-insecure-requests',
kind: 'flag',
description: 'Tells the browser to rewrite http:// subresource requests to https://.',
risk: 'low',
defaultSources: [],
},
{
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. */
export const COMMON_SOURCES: string[] = [
"'self'",
"'none'",
"'unsafe-inline'",
"'unsafe-eval'",
"'strict-dynamic'",
'data:',
'blob:',
'https:',
];
/** Directives that take no value - emitted as a bare name. */
const FLAG_DIRECTIVES = new Set(CSP_DIRECTIVES.filter((d) => d.kind === 'flag').map((d) => d.name));
/** Catalog names, for ordering during build. */
const KNOWN_DIRECTIVES = new Set(CSP_DIRECTIVES.map((d) => d.name));
/**
* 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.
*/
export function buildCSP(directives: CSPDirectiveMap): string {
const parts: string[] = [];
const emit = (name: string): void => {
const sources = directives[name];
if (sources === undefined) return;
if (FLAG_DIRECTIVES.has(name)) {
parts.push(name);
return;
}
if (sources.length === 0) return;
parts.push(`${name} ${sources.join(' ')}`);
};
for (const d of CSP_DIRECTIVES) emit(d.name);
for (const name of Object.keys(directives)) {
if (!KNOWN_DIRECTIVES.has(name)) emit(name);
}
return parts.join('; ');
}
/**
* 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 throws; garbage in, {} out.
*/
export function parseCSP(header: string): CSPDirectiveMap {
let text = header.trim();
if (/^content-security-policy\s*:/i.test(text)) {
text = text.slice(text.indexOf(':') + 1);
}
const out: CSPDirectiveMap = {};
for (const token of text.split(';')) {
const words = token.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) continue;
const name = words[0].toLowerCase();
if (out[name] !== undefined) continue;
out[name] = words.slice(1);
}
return out;
}
/** Sources treated as security-weakening, compared case-insensitively. */
const RISKY_SOURCES = new Set(["'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.
*/
export function isRiskySource(source: string): boolean {
const s = source.trim().toLowerCase();
return RISKY_SOURCES.has(s) || s.startsWith('http://');
}
/** Short human explanation for each risky source (tooltip text in the UI). */
export const RISK_EXPLANATIONS: Record<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. */
export function riskExplanation(source: string): string {
const key = source.trim().toLowerCase();
return RISK_EXPLANATIONS[key] ?? 'Insecure http:// URL - traffic can be tampered with in transit.';
}
/** One policy problem: either policy-wide (directive === '') or a risky source. */
export interface CspIssue {
/** Directive the issue belongs to; '' for policy-wide issues. */
directive: string;
/** The offending source, or null for policy-wide issues. */
source: string | null;
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.
*/
export function validateCSP(directives: CSPDirectiveMap): CspIssue[] {
const issues: CspIssue[] = [];
if (directives['default-src'] === undefined) {
issues.push({
directive: '',
source: null,
message:
"No default-src - every directive you don't set explicitly falls back to the browser's permissive default.",
});
}
for (const [name, sources] of Object.entries(directives)) {
for (const src of sources) {
if (isRiskySource(src)) {
issues.push({
directive: name,
source: src,
message: `${name}: ${src} weakens this policy - ${riskExplanation(src)}`,
});
}
}
}
return issues;
}
/** Score penalty per risky source (case-insensitive key). */
const SCORE_PENALTIES: Record<string, number> = {
"'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.
*/
export function securityScore(directives: CSPDirectiveMap): number {
let score = 100;
if (directives['default-src'] === undefined) score -= 10;
for (const sources of Object.values(directives)) {
for (const src of sources) {
const s = src.trim().toLowerCase();
score -= SCORE_PENALTIES[s] ?? (s.startsWith('http://') ? 10 : 0);
}
}
return Math.max(0, Math.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 →