From 66f7ba348a461eee69543673df247af62fb7a70d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 05:05:51 +0000 Subject: [PATCH] fix(permissions-policy): recognize any non-wildcard directive value as restrictive The previous check used literal `.includes("camera=()")`, which only matched the deny-all form and incorrectly flagged valid restrictive policies like `camera=(self)` or `camera=(https://trusted.example.com)` as warnings (score 5 instead of 10). The new `isPermissionsPolicyFeatureRestricted` helper parses the directive properly: any `feature=(...)` value passes; only the bare `*` wildcard (allow-all) does not. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_011rKFKyLpkTsT5DeJi8yw8U --- src/rules.ts | 25 +++++++++++++++++++++---- test/analyzer.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/rules.ts b/src/rules.ts index 779cf09..5a6f2a7 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -209,6 +209,24 @@ export function checkReferrerPolicy(headers: RawHeaders): HeaderFinding { recommendations: isStrong ? [] : ['Use: strict-origin-when-cross-origin'] }; } +/** + * Returns true when a Permissions-Policy directive is present and restrictive — + * i.e. `feature=(...)` with any allowlist (including the empty allowlist `()` + * that denies all origins). Returns false when the feature is absent or when + * its value is the bare wildcard `*`, which allows every origin. + */ +function isPermissionsPolicyFeatureRestricted(policy: string, feature: string): boolean { + const lower = policy.toLowerCase(); + const feat = feature.toLowerCase(); + const idx = lower.indexOf(feat + '='); + if (idx === -1) return false; + // Reject a partial suffix match (e.g. "notcamera=()"). + if (idx > 0 && /[\w-]/.test(lower[idx - 1])) return false; + const rest = lower.slice(idx + feat.length + 1).trimStart(); + if (rest.startsWith('*')) return false; // `feature=*` — allow-all, not restrictive + return rest.startsWith('('); // `feature=(...)` — any allowlist is restrictive +} + export function checkPermissionsPolicy(headers: RawHeaders): HeaderFinding { const raw = getHeader(headers, 'permissions-policy') ?? getHeader(headers, 'feature-policy'); if (!raw) return { @@ -216,10 +234,9 @@ export function checkPermissionsPolicy(headers: RawHeaders): HeaderFinding { findings: ['Permissions-Policy not set — browser features are not restricted'], recommendations: ['Add Permissions-Policy: camera=(), microphone=(), geolocation=()'], }; - const lc = raw.toLowerCase(); - const hasCam = lc.includes("camera=()"); - const hasMic = lc.includes("microphone=()"); - const hasGeo = lc.includes("geolocation=()"); + const hasCam = isPermissionsPolicyFeatureRestricted(raw, 'camera'); + const hasMic = isPermissionsPolicyFeatureRestricted(raw, 'microphone'); + const hasGeo = isPermissionsPolicyFeatureRestricted(raw, 'geolocation'); const score = (hasCam && hasMic && hasGeo) ? 10 : 5; const isGood = score === 10; return { diff --git a/test/analyzer.test.ts b/test/analyzer.test.ts index ed22923..d174dd0 100644 --- a/test/analyzer.test.ts +++ b/test/analyzer.test.ts @@ -441,6 +441,30 @@ describe('checkPermissionsPolicy', () => { const r = checkPermissionsPolicy({ 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()' }); expect(r.score).toBe(10); }); + + it('camera=(self) is restrictive — not all deny-all forms are required', () => { + const r = checkPermissionsPolicy({ 'permissions-policy': 'camera=(self), microphone=(self), geolocation=()' }); + expect(r.score).toBe(10); + expect(r.status).toBe('good'); + }); + + it('camera=(https://example.com) is restrictive', () => { + const r = checkPermissionsPolicy({ 'permissions-policy': 'camera=(https://example.com), microphone=(), geolocation=()' }); + expect(r.score).toBe(10); + expect(r.status).toBe('good'); + }); + + it('camera=* is permissive — does not count as restricted', () => { + const r = checkPermissionsPolicy({ 'permissions-policy': 'camera=*, microphone=(), geolocation=()' }); + expect(r.score).toBe(5); + expect(r.status).toBe('warning'); + }); + + it('partial suffix match (notcamera) is not treated as camera', () => { + const r = checkPermissionsPolicy({ 'permissions-policy': 'notcamera=(), microphone=(), geolocation=()' }); + expect(r.score).toBe(5); + expect(r.status).toBe('warning'); + }); }); describe('checkCrossOriginPolicies', () => {