Skip to content

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 →