Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,17 +209,34 @@ 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 {
header: 'Permissions-Policy', score: 0, maxScore: 10, status: 'missing',
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 {
Expand Down
24 changes: 24 additions & 0 deletions test/analyzer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading