Skip to content

Permissions-Policy Builder — C# source

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

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

// Permissions Policy - pure Permissions-Policy header builder / parser.
// Zero dependencies.
//
// Language: C# 12 / .NET 8 (standard library only)
// Source:   CosmoDev polyglot showcase port of the Permissions Policy tool,
//           ported from src/lib/permissions-policy.ts (the canonical
//           TypeScript implementation).
// License:  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 tokens; features absent
// from the map are absent from the header (browser default applies).

using System.Text.RegularExpressions;

public enum PrivacyImpact
{
    High,
    Medium,
    Low,
}

/// <summary>Catalog entry for one browser feature the policy can control.</summary>
/// <param name="Name">The directive token used in the header, e.g. "geolocation".</param>
/// <param name="Description">What the feature grants, in one line.</param>
/// <param name="Impact">How much the feature matters for privacy.</param>
/// <param name="DefaultBrowserBehavior">What browsers do when the feature is absent from the policy.</param>
public sealed record FeatureInfo(
    string Name,
    string Description,
    PrivacyImpact Impact,
    string DefaultBrowserBehavior);

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

    private static readonly Regex FeatureNameRe = new("^[a-z][a-z0-9-]*$", RegexOptions.Compiled);
    private static readonly Regex OriginRe = new("^(?:https?://|https?:)[\\w.-]+(?::\\d+)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
    private static readonly Regex HeaderPrefixRe = new("^permissions-policy\\s*:\\s*", RegexOptions.Compiled | RegexOptions.IgnoreCase);
    private static readonly Regex QuotedTokenRe = new("^\"(.*)\"$", RegexOptions.Compiled);

    private static string FormatAllowlist(string[] tokens) =>
        tokens.Length == 1 && tokens[0] == "*"
            ? "*"
            : $"({string.Join(' ', tokens)})";

    /// <summary>Assemble a Permissions-Policy header value from a feature map.</summary>
    public static string BuildPermissionsPolicy(IReadOnlyDictionary<string, string[]> features) =>
        string.Join(", ", features.Keys
            .OrderBy(n => n, StringComparer.Ordinal)
            .Select(name => $"{name}={FormatAllowlist(features[name])}"));

    /// <summary>
    /// 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).
    /// </summary>
    public static Dictionary<string, string[]>? ParsePermissionsPolicy(string header)
    {
        string cleaned = HeaderPrefixRe.Replace(header.Trim(), "");
        if (cleaned.Length == 0) return null;
        var map = new Dictionary<string, string[]>();
        foreach (string rawDirective in cleaned.Split(','))
        {
            string directive = rawDirective.Trim();
            if (directive.Length == 0) return null;
            int eq = directive.IndexOf('=');
            if (eq <= 0) return null;
            string name = directive[..eq].Trim();
            string value = directive[(eq + 1)..].Trim();
            if (!FeatureNameRe.IsMatch(name)) return null;
            if (value is "*" or "self")
            {
                map[name] = new[] { value };
                continue;
            }
            if (!value.StartsWith('(') || !value.EndsWith(')')) return null;
            string inner = value[1..^1].Trim();
            if (inner.Length == 0)
            {
                map[name] = Array.Empty<string>();
                continue;
            }
            string[] tokens = Regex.Split(inner, "\\s+").Select(Unquote).ToArray();
            foreach (string token in tokens)
            {
                if (token != "self" && token != "*" && !OriginRe.IsMatch(token)) return null;
            }
            map[name] = tokens;
        }
        return map;
    }

    /// <summary>Syntax-check a header the same way ParsePermissionsPolicy does, with messages.</summary>
    public static (bool Valid, IReadOnlyList<string> Errors) ValidatePermissionsPolicy(string header)
    {
        var errors = new List<string>();
        string cleaned = HeaderPrefixRe.Replace(header.Trim(), "");
        if (cleaned.Length == 0) return (false, new[] { "Header is empty." });
        int i = 0;
        foreach (string rawDirective in cleaned.Split(','))
        {
            i++;
            string directive = rawDirective.Trim();
            string where = $"Directive {i}";
            if (directive.Length == 0)
            {
                errors.Add($"{where}: empty (stray comma?).");
                continue;
            }
            int eq = directive.IndexOf('=');
            if (eq <= 0)
            {
                errors.Add($"{where}: expected `feature=allowlist`, got `{directive}`.");
                continue;
            }
            string name = directive[..eq].Trim();
            string value = directive[(eq + 1)..].Trim();
            if (!FeatureNameRe.IsMatch(name))
            {
                errors.Add($"{where}: `{name}` is not a valid feature name.");
                continue;
            }
            if (value is "*" or "self") continue;
            if (!value.StartsWith('(') || !value.EndsWith(')'))
            {
                errors.Add($"{where}: `{name}` allowlist must be `*`, `self`, or `(...)` - got `{value}`.");
                continue;
            }
            string inner = value[1..^1].Trim();
            if (inner.Length == 0) continue; // () = disabled, valid
            foreach (string raw in Regex.Split(inner, "\\s+"))
            {
                string token = Unquote(raw);
                if (token != "self" && token != "*" && !OriginRe.IsMatch(token))
                {
                    errors.Add($"{where}: `{name}` has an invalid allowlist token `{raw}`.");
                }
            }
        }
        return (errors.Count == 0, errors);
    }

    /// <summary>
    /// 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).
    /// A disabled () allowlist earns full credit, self or an origin list half,
    /// * or "absent from the policy" none (the browser default stays in force).
    /// </summary>
    public static int PrivacyScore(IReadOnlyDictionary<string, string[]> features)
    {
        int Weight(PrivacyImpact impact) => impact switch
        {
            PrivacyImpact.High => 3,
            PrivacyImpact.Medium => 2,
            _ => 1,
        };
        double earned = 0;
        double possible = 0;
        foreach (FeatureInfo feature in Features)
        {
            int w = Weight(feature.Impact);
            possible += w;
            if (!features.TryGetValue(feature.Name, out string[]? allowlist)) continue;
            if (allowlist.Length == 0) earned += w;                                     // () disabled
            else if (!(allowlist.Length == 1 && allowlist[0] == "*")) earned += w / 2.0; // self / origins
        }
        return (int)Math.Round(earned / possible * 100, MidpointRounding.AwayFromZero);
    }

    private static string Unquote(string token)
    {
        Match m = QuotedTokenRe.Match(token);
        return m.Success ? m.Groups[1].Value : token;
    }
}

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 →