Skip to content

Permissions-Policy Builder — Ruby source

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

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

# Permissions-Policy — pure header builder / parser. Zero deps.
#
# Language: Ruby (3.1+, 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 Hash 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).

module PermissionsPolicy
  FeatureInfo = Struct.new(:name, :description, :privacy_impact,
                           :default_browser_behavior, keyword_init: true)
  ValidationResult = Struct.new(:valid, :errors, keyword_init: true)

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

  FEATURE_NAME_RE = /\A[a-z][a-z0-9-]*\z/
  ORIGIN_RE = /\A(https?:\/\/|https?:)[\w.-]+(:\d+)?\z/i

  class << self
    # Assemble a Permissions-Policy header value from a feature map.
    def build_permissions_policy(features)
      features.keys.sort.map do |name|
        "#{name}=#{format_allowlist(features[name])}"
      end.join(', ')
    end

    # 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).
    def parse_permissions_policy(header)
      cleaned = header.strip.sub(/\Apermissions-policy\s*:\s*/i, '')
      return nil if cleaned.empty?

      map = {}
      cleaned.split(',').each do |raw_directive|
        directive = raw_directive.strip
        return nil if directive.empty?

        eq = directive.index('=')
        return nil if eq.nil? || eq <= 0

        name = directive[0...eq].strip
        value = directive[(eq + 1)..].strip
        return nil unless name.match?(FEATURE_NAME_RE)

        if value == '*' || value == 'self'
          map[name] = [value]
          next
        end
        return nil unless value.start_with?('(') && value.end_with?(')')

        inner = value[1..-2].strip
        if inner.empty?
          map[name] = []
          next
        end

        tokens = inner.split(/\s+/).map { |t| t.sub(/\A"(.*)"\z/, '\1') }
        tokens.each do |token|
          return nil if token != 'self' && token != '*' && !token.match?(ORIGIN_RE)
        end
        map[name] = tokens
      end
      map
    end

    # Syntax-check a header the same way parse_permissions_policy does, with
    # messages.
    def validate_permissions_policy(header)
      errors = []
      cleaned = header.strip.sub(/\Apermissions-policy\s*:\s*/i, '')
      if cleaned.empty?
        return ValidationResult.new(valid: false, errors: ['Header is empty.'])
      end

      cleaned.split(',').each_with_index do |raw_directive, i|
        directive = raw_directive.strip
        where = "Directive #{i + 1}"
        if directive.empty?
          errors << "#{where}: empty (stray comma?)."
          next
        end
        eq = directive.index('=')
        if eq.nil? || eq <= 0
          errors << "#{where}: expected `feature=allowlist`, got `#{directive}`."
          next
        end

        name = directive[0...eq].strip
        value = directive[(eq + 1)..].strip
        unless name.match?(FEATURE_NAME_RE)
          errors << "#{where}: `#{name}` is not a valid feature name."
          next
        end

        next if value == '*' || value == 'self'

        unless value.start_with?('(') && value.end_with?(')')
          errors << "#{where}: `#{name}` allowlist must be `*`, `self`, or " \
                    "`(...)` — got `#{value}`."
          next
        end

        inner = value[1..-2].strip
        next if inner.empty? # () = disabled, valid

        inner.split(/\s+/).each do |raw|
          token = raw.sub(/\A"(.*)"\z/, '\1')
          if token != 'self' && token != '*' && !token.match?(ORIGIN_RE)
            errors << "#{where}: `#{name}` has an invalid allowlist token `#{raw}`."
          end
        end
      end
      ValidationResult.new(valid: errors.empty?, errors: errors)
    end

    # 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).
    def privacy_score(features)
      weight = { 'high' => 3, 'medium' => 2, 'low' => 1 }
      earned = 0.0
      possible = 0.0
      FEATURES.each do |feature|
        w = weight[feature.privacy_impact]
        possible += w
        allowlist = features[feature.name]
        if allowlist && allowlist.empty?
          earned += w # () disabled
        elsif allowlist && !(allowlist.length == 1 && allowlist[0] == '*')
          earned += w / 2.0 # self / origins
        end
      end
      ((earned / possible) * 100).round
    end

    private

    def format_allowlist(tokens)
      return '*' if tokens.length == 1 && tokens[0] == '*'

      "(#{tokens.join(' ')})"
    end
  end
end

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 →