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 →