Skip to content

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 →