Skip to content

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

// Pure Content-Security-Policy logic - no UI, deterministic.
//
// Language: Kotlin 1.9+ (JVM), standard library only.
// Ported from src/lib/csp-builder.ts — display source, part of CosmoDev's
// polyglot tool pages. Functionally equivalent to the TS reference: same
// inputs -> same outputs.
//
// 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. */
enum class DirectiveKind { SOURCES, URL, FLAG }

/** How much exposure the directive controls (drives UI emphasis). */
enum class DirectiveRisk { LOW, MEDIUM, HIGH }

/** One entry of the built-in directive catalog. */
data class DirectiveInfo(
    val name: String,
    val kind: DirectiveKind,
    val description: String,
    val risk: DirectiveRisk,
    /** Sources inserted when the directive is enabled in the UI. */
    val defaultSources: List<String>,
)

/**
 * A policy: directive name (lowercase) -> enabled source list.
 * Present key = enabled. A LinkedHashMap keeps insertion order stable for
 * the "unknown directives after the catalog" part of build.
 */
typealias CSPDirectiveMap = LinkedHashMap<String, List<String>>

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

/** Source presets offered in the UI when adding a source to a directive. */
val COMMON_SOURCES: List<String> = listOf(
    "'self'", "'none'", "'unsafe-inline'", "'unsafe-eval'", "'strict-dynamic'",
    "data:", "blob:", "https:",
)

/** Directives that take no value - emitted as a bare name. */
private val FLAG_DIRECTIVES = CSP_DIRECTIVES.filter { it.kind == DirectiveKind.FLAG }.map { it.name }.toSet()

/** Catalog names, for ordering during build. */
private val KNOWN_DIRECTIVES = CSP_DIRECTIVES.map { it.name }.toSet()

/**
 * 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.
 */
fun buildCSP(directives: CSPDirectiveMap): String {
    val parts = mutableListOf<String>()
    fun emit(name: String) {
        val sources = directives[name] ?: return
        if (name in FLAG_DIRECTIVES) {
            parts.add(name)
            return
        }
        if (sources.isEmpty()) return
        parts.add("$name ${sources.joinToString(" ")}")
    }
    for (d in CSP_DIRECTIVES) emit(d.name)
    for (name in directives.keys) {
        if (name !in KNOWN_DIRECTIVES) emit(name)
    }
    return parts.joinToString("; ")
}

/**
 * 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.
 */
fun parseCSP(header: String): CSPDirectiveMap {
    var text = header.trim()
    // Strip an optional leading `Content-Security-Policy:` label (anchored,
    // case-insensitive) so a pasted full header line works.
    val label = Regex("^content-security-policy\\s*:", RegexOption.IGNORE_CASE)
    if (label.containsMatchIn(text)) {
        text = text.substring(text.indexOf(':') + 1)
    }
    val out = CSPDirectiveMap()
    for (token in text.split(';')) {
        val words = token.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
        if (words.isEmpty()) continue
        val name = words.first().lowercase()
        if (out.containsKey(name)) continue
        out[name] = words.drop(1)
    }
    return out
}

/** Sources treated as security-weakening, compared case-insensitively. */
private val RISKY_SOURCES = setOf("'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.
 */
fun isRiskySource(source: String): Boolean {
    val s = source.trim().lowercase()
    return s in RISKY_SOURCES || s.startsWith("http://")
}

/** Short human explanation for each risky source (tooltip text in the UI). */
val RISK_EXPLANATIONS: Map<String, String> = mapOf(
    "'unsafe-inline'" to
        "Allows inline <script>/<style> and event handlers - defeats most of CSP's XSS protection.",
    "'unsafe-eval'" to "Allows eval() and similar code execution - weakens XSS protection.",
    "*" to "Allows every origin - effectively no restriction for this directive.",
    "data:" to
        "data: URIs can carry arbitrary payloads and are same-origin - attackers can smuggle content through them.",
    "http:" to "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. */
fun riskExplanation(source: String): String =
    RISK_EXPLANATIONS[source.trim().lowercase()]
        ?: "Insecure http:// URL - traffic can be tampered with in transit."

/** One policy problem: either policy-wide (directive === "") or a risky source. */
data class CspIssue(
    /** Directive the issue belongs to; "" for policy-wide issues. */
    val directive: String,
    /** The offending source, or null for policy-wide issues. */
    val source: String?,
    val 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.
 */
fun validateCSP(directives: CSPDirectiveMap): List<CspIssue> {
    val issues = mutableListOf<CspIssue>()
    if (!directives.containsKey("default-src")) {
        issues.add(
            CspIssue(
                "", null,
                "No default-src - every directive you don't set explicitly falls back to the browser's permissive default.",
            )
        )
    }
    for ((name, sources) in directives) {
        for (src in sources) {
            if (isRiskySource(src)) {
                issues.add(CspIssue(name, src, "$name: $src weakens this policy - ${riskExplanation(src)}"))
            }
        }
    }
    return issues
}

/** Score penalty per risky source (case-insensitive key). */
private val SCORE_PENALTIES = mapOf(
    "'unsafe-inline'" to 20,
    "'unsafe-eval'" to 15,
    "*" to 20,
    "data:" to 10,
    "http:" to 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.
 */
fun securityScore(directives: CSPDirectiveMap): Int {
    var score = 100
    if (!directives.containsKey("default-src")) score -= 10
    for (sources in directives.values) {
        for (src in sources) {
            val s = src.trim().lowercase()
            score -= SCORE_PENALTIES[s] ?: (if (s.startsWith("http://")) 10 else 0)
        }
    }
    return score.coerceIn(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 →