Skip to content

Permissions-Policy Builder — Swift source

Build a Permissions-Policy header interactively. Control which browser features (camera, microphone, geolocation, etc.) your site can use.

This is the Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.

// permissions-policy — Permissions-Policy header builder / parser.
//
// Language: Swift 5.9+ (Foundation only)
// Ported from src/lib/permissions-policy.ts
// display source — part of CosmoDev's polyglot tool pages
//
// 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.
//
// A policy is modeled here as a map of feature -> allowlist array:
//   ["geolocation": ["self"], "camera": [], "usb": ["https://a.example"]]
//   - []            => camera=()        (disabled)
//   - ["*"]         => microphone=*     (every origin)
//   - ["self"]      => geolocation=(self)
//   - [origins...]  => usb=(https://a.example https://b.example)
// Features absent from the map are absent from the header (browser default).

import Foundation

// MARK: - Types

enum PrivacyImpact: String {
    case high, medium, low
}

struct FeatureInfo {
    /// The directive token used in the header, e.g. `geolocation`.
    let name: String
    let description: String
    let privacyImpact: PrivacyImpact
    /// What browsers do when the feature is absent from the policy.
    let defaultBrowserBehavior: String
}

/// feature name -> allowlist tokens. `[]` = disabled, `["*"]` = all origins.
typealias FeatureMap = [String: [String]]

// MARK: - Feature catalog

let FEATURES: [FeatureInfo] = [
    // --- high privacy impact ---
    FeatureInfo(name: "camera", description: "Access the device camera for photos / video calls.", privacyImpact: .high, defaultBrowserBehavior: "Same-origin only; prompts the user."),
    FeatureInfo(name: "microphone", description: "Capture audio from the device microphone.", privacyImpact: .high, defaultBrowserBehavior: "Same-origin only; prompts the user."),
    FeatureInfo(name: "geolocation", description: "Read the precise GPS location of the visitor.", privacyImpact: .high, defaultBrowserBehavior: "Same-origin only; prompts the user."),
    FeatureInfo(name: "display-capture", description: "Screen / window sharing via getDisplayMedia.", privacyImpact: .high, defaultBrowserBehavior: "Same-origin only; prompts the user."),
    FeatureInfo(name: "idle-detection", description: "Detects when the user is away from the device — reveals usage patterns.", privacyImpact: .high, defaultBrowserBehavior: "Disabled; prompts the user."),
    FeatureInfo(name: "serial", description: "Talk to serial devices (Arduinos, POS terminals) over a physical port.", privacyImpact: .high, defaultBrowserBehavior: "Disabled; prompts the user."),
    FeatureInfo(name: "usb", description: "WebUSB — direct access to connected USB devices.", privacyImpact: .high, defaultBrowserBehavior: "Disabled; prompts the user."),
    FeatureInfo(name: "hid", description: "Human Interface Devices — raw access to unusual keyboards, gamepads, sensors.", privacyImpact: .high, defaultBrowserBehavior: "Disabled; prompts the user."),
    FeatureInfo(name: "xr-spatial-tracking", description: "Tracks head / hand position in WebXR sessions.", privacyImpact: .high, defaultBrowserBehavior: "Same-origin only; prompts the user."),
    // --- medium privacy impact ---
    FeatureInfo(name: "accelerometer", description: "Device motion sensor — can fingerprint and infer behaviour.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
    FeatureInfo(name: "ambient-light-sensor", description: "Reads ambient light level around the device.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
    FeatureInfo(name: "battery", description: "Battery Status API — a classic fingerprinting vector.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
    FeatureInfo(name: "gyroscope", description: "Device orientation sensor — fingerprinting and behaviour inference.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
    FeatureInfo(name: "magnetometer", description: "Compass readings — can leak details of the user's surroundings.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
    FeatureInfo(name: "keyboard-map", description: "Reads the physical keyboard layout — a small but real fingerprint.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
    FeatureInfo(name: "gamepad", description: "Enumerates connected controllers and their button state.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
    FeatureInfo(name: "midi", description: "Web MIDI — access to attached music hardware.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only; prompts the user."),
    FeatureInfo(name: "payment", description: "Payment Request API — can expose stored payment handles.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
    FeatureInfo(name: "publickey-credentials-get", description: "WebAuthn credential requests.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
    FeatureInfo(name: "screen-wake-lock", description: "Keeps the screen awake — drains battery and signals intent.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
    FeatureInfo(name: "speaker-selection", description: "Enumerates and switches audio output devices.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
    FeatureInfo(name: "web-share", description: "Invokes the OS share sheet with chosen content.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
    FeatureInfo(name: "encrypted-media", description: "DRM (EME) — playback identity can be correlated.", privacyImpact: .medium, defaultBrowserBehavior: "Same-origin only."),
    FeatureInfo(name: "document-domain", description: "Let frames relax the same-origin policy via document.domain.", privacyImpact: .medium, defaultBrowserBehavior: "Allowed in same-origin pages; deprecated."),
    // --- low privacy impact ---
    FeatureInfo(name: "autoplay", description: "Autoplay media with/without sound — not a data leak, an annoyance knob.", privacyImpact: .low, defaultBrowserBehavior: "Muted autoplay allowed; audible blocked."),
    FeatureInfo(name: "cross-origin-isolated", description: "COOP/COEP isolation for SharedArrayBuffer — hardens the page.", privacyImpact: .low, defaultBrowserBehavior: "Not isolated."),
    FeatureInfo(name: "fullscreen", description: "Element.requestFullscreen().", privacyImpact: .low, defaultBrowserBehavior: "Same-origin only."),
    FeatureInfo(name: "navigation-override", description: "Lets a frame intercept its own top-level navigations.", privacyImpact: .low, defaultBrowserBehavior: "Disabled."),
    FeatureInfo(name: "picture-in-picture", description: "Floating always-on-top video window.", privacyImpact: .low, defaultBrowserBehavior: "Same-origin only."),
]

// MARK: - Pattern helpers

/// `^[a-z][a-z0-9-]*$` — a valid feature directive token.
func isValidFeatureName(_ name: String) -> Bool {
    guard let first = name.first else { return false }
    guard first.isASCII, first.isLowercase, first.isLetter else { return false }
    return name.dropFirst().allSatisfy { c in
        (c.isASCII && c.isLowercase && c.isLetter) || (c.isNumber && c.isASCII) || c == "-"
    }
}

/// `^(https?:\/\/|https?:)[\w.-]+(:\d+)?$` (case-insensitive) — an origin token.
func isOriginToken(_ token: String) -> Bool {
    var s = Substring(token)
    let lowered = token.lowercased()
    if lowered.hasPrefix("https://") { s = s.dropFirst(8) }
    else if lowered.hasPrefix("http://") { s = s.dropFirst(7) }
    else if lowered.hasPrefix("https:") { s = s.dropFirst(6) }
    else if lowered.hasPrefix("http:") { s = s.dropFirst(5) }
    else { return false }
    // Optional :port
    if let colon = s.lastIndex(of: ":") {
        let port = s[s.index(after: colon)...]
        guard !port.isEmpty, port.allSatisfy({ $0.isNumber && $0.isASCII }) else { return false }
        s = s[..<colon]
    }
    guard !s.isEmpty else { return false }
    return s.allSatisfy { c in
        c == "." || c == "-" || c == "_" || (c.isASCII && (c.isLetter || c.isNumber))
    }
}

// MARK: - Build / parse

func formatAllowlist(_ tokens: [String]) -> String {
    if tokens.count == 1 && tokens[0] == "*" { return "*" }
    return "(\(tokens.joined(separator: " ")))"
}

/// Assemble a Permissions-Policy header value from a feature map.
func buildPermissionsPolicy(_ features: FeatureMap) -> String {
    features.keys
        .sorted()
        .map { name in "\(name)=\(formatAllowlist(features[name] ?? []))" }
        .joined(separator: ", ")
}

/// Strip an optional `Permissions-Policy:` label from a pasted header line.
func stripHeaderLabel(_ header: String) -> String {
    var text = header.trimmingCharacters(in: .whitespacesAndNewlines)
    let lowered = text.lowercased()
    if lowered.hasPrefix("permissions-policy") {
        let after = text.dropFirst("permissions-policy".count)
        if after.first == ":" {
            // also drop any spaces between the colon and the value
            text = String(after.drop(while: { $0 == ":" || $0 == " " || $0 == "\t" }))
        }
    }
    return text
}

/**
 Parse a Permissions-Policy header value back into a feature map.
 Accepts an optional `Permissions-Policy:` prefix. Returns nil when the
 syntax is malformed (bad directive, missing allowlist, bad token).
 */
func parsePermissionsPolicy(_ header: String) -> FeatureMap? {
    let cleaned = stripHeaderLabel(header)
    if cleaned.isEmpty { return nil }
    var map: FeatureMap = [:]
    for rawDirective in cleaned.split(separator: ",") {
        let directive = rawDirective.trimmingCharacters(in: .whitespacesAndNewlines)
        if directive.isEmpty { return nil }
        guard let eq = directive.firstIndex(of: "="), eq != directive.startIndex else { return nil }
        let name = String(directive[..<eq]).trimmingCharacters(in: .whitespaces)
        let value = String(directive[directive.index(after: eq)...]).trimmingCharacters(in: .whitespaces)
        if !isValidFeatureName(name) { return nil }
        if value == "*" || value == "self" {
            map[name] = [value]
            continue
        }
        guard value.hasPrefix("("), value.hasSuffix(")") else { return nil }
        let inner = String(value.dropFirst().dropLast()).trimmingCharacters(in: .whitespaces)
        if inner.isEmpty {
            map[name] = []
            continue
        }
        let tokens = inner.split(whereSeparator: { $0 == " " || $0 == "\t" })
            .map { token -> String in
                let t = String(token)
                return unquote(t)
            }
        for token in tokens {
            if token != "self" && token != "*" && !isOriginToken(token) { return nil }
        }
        map[name] = tokens
    }
    return map
}

/// Strip one pair of surrounding double quotes, as browsers accept for tokens.
private func unquote(_ token: String) -> String {
    guard token.count >= 2, token.hasPrefix("\""), token.hasSuffix("\"") else { return token }
    return String(token.dropFirst().dropLast())
}

// MARK: - Validation

/// Syntax-check a header the same way parsePermissionsPolicy does, with messages.
func validatePermissionsPolicy(_ header: String) -> (valid: Bool, errors: [String]) {
    var errors: [String] = []
    let cleaned = stripHeaderLabel(header)
    if cleaned.isEmpty { return (false, ["Header is empty."]) }
    for (i, rawDirective) in cleaned.split(separator: ",", omittingEmptySubsequences: false).enumerated() {
        let directive = rawDirective.trimmingCharacters(in: .whitespacesAndNewlines)
        let where_ = "Directive \(i + 1)"
        if directive.isEmpty {
            errors.append("\(where_): empty (stray comma?).")
            continue
        }
        guard let eq = directive.firstIndex(of: "="), eq != directive.startIndex else {
            errors.append("\(where_): expected `feature=allowlist`, got `\(directive)`.")
            continue
        }
        let name = String(directive[..<eq]).trimmingCharacters(in: .whitespaces)
        let value = String(directive[directive.index(after: eq)...]).trimmingCharacters(in: .whitespaces)
        if !isValidFeatureName(name) {
            errors.append("\(where_): `\(name)` is not a valid feature name.")
            continue
        }
        if value == "*" || value == "self" { continue }
        guard value.hasPrefix("("), value.hasSuffix(")") else {
            errors.append("\(where_): `\(name)` allowlist must be `*`, `self`, or `(...)` — got `\(value)`.")
            continue
        }
        let inner = String(value.dropFirst().dropLast()).trimmingCharacters(in: .whitespaces)
        if inner.isEmpty { continue } // () = disabled, valid
        for raw in inner.split(whereSeparator: { $0 == " " || $0 == "\t" }) {
            let token = unquote(String(raw))
            if token != "self" && token != "*" && !isOriginToken(token) {
                errors.append("\(where_): `\(name)` has an invalid allowlist token `\(raw)`.")
            }
        }
    }
    return (errors.isEmpty, errors)
}

// MARK: - Scoring

/**
 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).
 */
func privacyScore(_ features: FeatureMap) -> Int {
    let weight: [PrivacyImpact: Double] = [.high: 3, .medium: 2, .low: 1]
    var earned = 0.0
    var possible = 0.0
    for feature in FEATURES {
        let w = weight[feature.privacyImpact] ?? 0
        possible += w
        if let allowlist = features[feature.name] {
            if allowlist.isEmpty {
                earned += w // () disabled
            } else if !(allowlist.count == 1 && allowlist[0] == "*") {
                earned += w / 2 // self / origins
            }
        }
    }
    return Int((earned / possible * 100).rounded())
}

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 →