Permissions-Policy Builder — Kotlin source
Build a Permissions-Policy header interactively. Control which browser features (camera, microphone, geolocation, etc.) your site can use.
This is the Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Permissions-Policy header builder / parser.
//
// Language: Kotlin 1.9+ (JVM), standard library only.
// Ported from src/lib/permissions-policy.ts — display source, part of
// CosmoDev's polyglot tool pages. Functionally equivalent to the TS
// reference: same directives, same validation rules, same privacy score.
//
// The Permissions-Policy header is a comma-separated list of directives:
// Permissions-Policy: geolocation=(self), camera=(), microphone=*, usb=(https://a.example)
// Each directive maps a browser feature to an allowlist. An empty allowlist
// `()` disables the feature outright; `*` allows it everywhere; `self` limits
// it to the page's own origin; anything else is a space-separated origin list.
/** How much locking a feature down matters for visitor privacy. */
enum class PrivacyImpact { HIGH, MEDIUM, LOW }
data class FeatureInfo(
/** The directive token used in the header, e.g. `geolocation`. */
val name: String,
val description: String,
val privacyImpact: PrivacyImpact,
/** What browsers do when the feature is absent from the policy. */
val defaultBrowserBehavior: String,
)
/** feature name -> allowlist tokens. Empty list = disabled, ["*"] = all origins. */
typealias FeatureMap = Map<String, List<String>>
/** The catalog the tool grades against, in display order. */
val FEATURES: List<FeatureInfo> = listOf(
// --- high privacy impact -------------------------------------------------
FeatureInfo("camera", "Access the device camera for photos / video calls.", PrivacyImpact.HIGH, "Same-origin only; prompts the user."),
FeatureInfo("microphone", "Capture audio from the device microphone.", PrivacyImpact.HIGH, "Same-origin only; prompts the user."),
FeatureInfo("geolocation", "Read the precise GPS location of the visitor.", PrivacyImpact.HIGH, "Same-origin only; prompts the user."),
FeatureInfo("display-capture", "Screen / window sharing via getDisplayMedia.", PrivacyImpact.HIGH, "Same-origin only; prompts the user."),
FeatureInfo("idle-detection", "Detects when the user is away from the device — reveals usage patterns.", PrivacyImpact.HIGH, "Disabled; prompts the user."),
FeatureInfo("serial", "Talk to serial devices (Arduinos, POS terminals) over a physical port.", PrivacyImpact.HIGH, "Disabled; prompts the user."),
FeatureInfo("usb", "WebUSB — direct access to connected USB devices.", PrivacyImpact.HIGH, "Disabled; prompts the user."),
FeatureInfo("hid", "Human Interface Devices — raw access to unusual keyboards, gamepads, sensors.", PrivacyImpact.HIGH, "Disabled; prompts the user."),
FeatureInfo("xr-spatial-tracking", "Tracks head / hand position in WebXR sessions.", PrivacyImpact.HIGH, "Same-origin only; prompts the user."),
// --- medium privacy impact -----------------------------------------------
FeatureInfo("accelerometer", "Device motion sensor — can fingerprint and infer behaviour.", PrivacyImpact.MEDIUM, "Same-origin only."),
FeatureInfo("ambient-light-sensor", "Reads ambient light level around the device.", PrivacyImpact.MEDIUM, "Same-origin only."),
FeatureInfo("battery", "Battery Status API — a classic fingerprinting vector.", PrivacyImpact.MEDIUM, "Same-origin only."),
FeatureInfo("gyroscope", "Device orientation sensor — fingerprinting and behaviour inference.", PrivacyImpact.MEDIUM, "Same-origin only."),
FeatureInfo("magnetometer", "Compass readings — can leak details of the user's surroundings.", PrivacyImpact.MEDIUM, "Same-origin only."),
FeatureInfo("keyboard-map", "Reads the physical keyboard layout — a small but real fingerprint.", PrivacyImpact.MEDIUM, "Same-origin only."),
FeatureInfo("gamepad", "Enumerates connected controllers and their button state.", PrivacyImpact.MEDIUM, "Same-origin only."),
FeatureInfo("midi", "Web MIDI — access to attached music hardware.", PrivacyImpact.MEDIUM, "Same-origin only; prompts the user."),
FeatureInfo("payment", "Payment Request API — can expose stored payment handles.", PrivacyImpact.MEDIUM, "Same-origin only."),
FeatureInfo("publickey-credentials-get", "WebAuthn credential requests.", PrivacyImpact.MEDIUM, "Same-origin only."),
FeatureInfo("screen-wake-lock", "Keeps the screen awake — drains battery and signals intent.", PrivacyImpact.MEDIUM, "Same-origin only."),
FeatureInfo("speaker-selection", "Enumerates and switches audio output devices.", PrivacyImpact.MEDIUM, "Same-origin only."),
FeatureInfo("web-share", "Invokes the OS share sheet with chosen content.", PrivacyImpact.MEDIUM, "Same-origin only."),
FeatureInfo("encrypted-media", "DRM (EME) — playback identity can be correlated.", PrivacyImpact.MEDIUM, "Same-origin only."),
FeatureInfo("document-domain", "Let frames relax the same-origin policy via document.domain.", PrivacyImpact.MEDIUM, "Allowed in same-origin pages; deprecated."),
// --- low privacy impact --------------------------------------------------
FeatureInfo("autoplay", "Autoplay media with/without sound — not a data leak, an annoyance knob.", PrivacyImpact.LOW, "Muted autoplay allowed; audible blocked."),
FeatureInfo("cross-origin-isolated", "COOP/COEP isolation for SharedArrayBuffer — hardens the page.", PrivacyImpact.LOW, "Not isolated."),
FeatureInfo("fullscreen", "Element.requestFullscreen().", PrivacyImpact.LOW, "Same-origin only."),
FeatureInfo("navigation-override", "Lets a frame intercept its own top-level navigations.", PrivacyImpact.LOW, "Disabled."),
FeatureInfo("picture-in-picture", "Floating always-on-top video window.", PrivacyImpact.LOW, "Same-origin only."),
)
private val FEATURE_NAME_RE = Regex("^[a-z][a-z0-9-]*$")
private val ORIGIN_RE = Regex("^(?:https?://|https?:)[\\w.-]+(:\\d+)?$", RegexOption.IGNORE_CASE)
private fun formatAllowlist(tokens: List<String>): String =
if (tokens.size == 1 && tokens[0] == "*") "*" else "(${tokens.joinToString(" ")})"
/** Assemble a Permissions-Policy header value from a feature map. */
fun buildPermissionsPolicy(features: FeatureMap): String =
features.keys.sorted().joinToString(", ") { name -> "$name=${formatAllowlist(features.getValue(name))}" }
/** Unquote one allowlist token: `"https://a.example"` -> `https://a.example`. */
private fun unquote(token: String): String = token.replace(Regex("^\"(.*)\"$"), "$1")
/**
* Parse a Permissions-Policy header value back into a feature map.
* Accepts an optional `Permissions-Policy:` prefix. Returns null when the
* syntax is malformed (bad directive, missing allowlist, bad token).
*/
fun parsePermissionsPolicy(header: String): FeatureMap? {
val cleaned = header.trim().replaceFirst(Regex("^permissions-policy\\s*:\\s*", RegexOption.IGNORE_CASE), "")
if (cleaned.isEmpty()) return null
val map = mutableMapOf<String, List<String>>()
for (rawDirective in cleaned.split(',')) {
val directive = rawDirective.trim()
if (directive.isEmpty()) return null
val eq = directive.indexOf('=')
if (eq <= 0) return null
val name = directive.substring(0, eq).trim()
val value = directive.substring(eq + 1).trim()
if (!FEATURE_NAME_RE.matches(name)) return null
if (value == "*" || value == "self") {
map[name] = listOf(value)
continue
}
if (!value.startsWith("(") || !value.endsWith(")")) return null
val inner = value.substring(1, value.length - 1).trim()
if (inner.isEmpty()) {
map[name] = emptyList()
continue
}
val tokens = inner.split(Regex("\\s+")).map(::unquote)
for (token in tokens) {
if (token != "self" && token != "*" && !ORIGIN_RE.matches(token)) return null
}
map[name] = tokens
}
return map
}
data class Validation(val valid: Boolean, val errors: List<String>)
/** Syntax-check a header the same way parsePermissionsPolicy does, with messages. */
fun validatePermissionsPolicy(header: String): Validation {
val errors = mutableListOf<String>()
val cleaned = header.trim().replaceFirst(Regex("^permissions-policy\\s*:\\s*", RegexOption.IGNORE_CASE), "")
if (cleaned.isEmpty()) return Validation(false, listOf("Header is empty."))
cleaned.split(',').forEachIndexed { i, rawDirective ->
val directive = rawDirective.trim()
val where = "Directive ${i + 1}"
if (directive.isEmpty()) {
errors.add("$where: empty (stray comma?).")
return@forEachIndexed
}
val eq = directive.indexOf('=')
if (eq <= 0) {
errors.add("$where: expected `feature=allowlist`, got `$directive`.")
return@forEachIndexed
}
val name = directive.substring(0, eq).trim()
val value = directive.substring(eq + 1).trim()
if (!FEATURE_NAME_RE.matches(name)) {
errors.add("$where: `$name` is not a valid feature name.")
return@forEachIndexed
}
if (value == "*" || value == "self") return@forEachIndexed
if (!value.startsWith("(") || !value.endsWith(")")) {
errors.add("$where: `$name` allowlist must be `*`, `self`, or `(...)` — got `$value`.")
return@forEachIndexed
}
val inner = value.substring(1, value.length - 1).trim()
if (inner.isEmpty()) return@forEachIndexed // () = disabled, valid
for (raw in inner.split(Regex("\\s+"))) {
val token = unquote(raw)
if (token != "self" && token != "*" && !ORIGIN_RE.matches(token)) {
errors.add("$where: `$name` has an invalid allowlist token `$raw`.")
}
}
}
return Validation(errors.isEmpty(), errors)
}
/**
* Privacy score 0-100 for a policy: how much it locks down the catalog.
* Each feature is weighted by privacy impact (high 3, medium 2, low 1).
* Disabled `()` earns full credit, `self` or an origin list half, `*` or
* "absent from the policy" none (the browser default stays in force).
*/
fun privacyScore(features: FeatureMap): Int {
val weight = mapOf(
PrivacyImpact.HIGH to 3.0,
PrivacyImpact.MEDIUM to 2.0,
PrivacyImpact.LOW to 1.0,
)
var earned = 0.0
var possible = 0.0
for (feature in FEATURES) {
val w = weight.getValue(feature.privacyImpact)
possible += w
val allowlist = features[feature.name]
when {
allowlist != null && allowlist.isEmpty() -> earned += w // () disabled
allowlist != null && !(allowlist.size == 1 && allowlist[0] == "*") -> earned += w / 2 // self / origins
else -> {} // * or absent — no credit
}
}
return Math.round(earned / possible * 100).toInt()
}
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 →