1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'PermissionsPolicyAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8
9
10
11
12
13
14
15
16function isPrivateIPv4(ip) {
17 const parts = ip.split('.').map(Number);
18 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
19 const [a, b] = parts;
20 return a === 10
21 || (a === 172 && b >= 16 && b <= 31)
22 || (a === 192 && b === 168)
23 || a === 127
24 || a === 0
25 || (a === 169 && b === 254);
26}
27
28function isPrivateIPv6(ip) {
29 const normalized = ip.toLowerCase();
30 return normalized === '::1'
31 || normalized.startsWith('fc')
32 || normalized.startsWith('fd')
33 || normalized.startsWith('fe80:');
34}
35
36export async function normalizeAndValidateUrl(rawUrl) {
37 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
38 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
39 throw new Error('Only HTTP and HTTPS URLs are supported');
40 }
41 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
42 const url = new URL(withScheme);
43 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
44 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
45
46 const literalType = net.isIP(url.hostname);
47 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
48 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
49
50 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
51 for (const record of records) {
52 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
53 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
54 }
55 return url;
56}
57
58
59
60
61
62async function fetchHeaders(initialUrl, timeoutSeconds, redirectsRemaining = 3) {
63 await normalizeAndValidateUrl(initialUrl.href);
64 const controller = new AbortController();
65 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
66 try {
67 const response = await fetch(initialUrl, {
68 redirect: 'manual',
69 signal: controller.signal,
70 headers: {
71 'user-agent': USER_AGENT,
72 accept: 'text/html,application/xhtml+xml,*/*;q=0.1',
73 },
74 });
75
76 if ([301, 302, 303, 307, 308].includes(response.status)) {
77 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
78 const location = response.headers.get('location');
79 if (!location) throw new Error('Redirect without Location header');
80 const nextUrl = new URL(location, initialUrl.href);
81 try { await response.arrayBuffer(); } catch { }
82 return fetchHeaders(nextUrl, timeoutSeconds, redirectsRemaining - 1);
83 }
84
85 try { await response.arrayBuffer(); } catch { }
86
87 const headerMap = {};
88 response.headers.forEach((value, key) => {
89 headerMap[key.toLowerCase()] = value;
90 });
91
92 return {
93 ok: response.ok,
94 status: response.status,
95 finalUrl: response.url || initialUrl.href,
96 https: (response.url || initialUrl.href).startsWith('https://'),
97 headers: headerMap,
98 error: null,
99 };
100 } catch (error) {
101 return {
102 ok: false,
103 status: null,
104 finalUrl: initialUrl.href,
105 https: initialUrl.protocol === 'https:',
106 headers: {},
107 error: error.message,
108 };
109 } finally {
110 clearTimeout(timeout);
111 }
112}
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128export function parsePermissionsPolicy(headerValue) {
129 if (!headerValue || typeof headerValue !== 'string') return [];
130
131 const directives = [];
132 const parts = splitTopLevel(headerValue, ',');
133 for (const part of parts) {
134 const trimmed = part.trim();
135 if (!trimmed) continue;
136 const eqIdx = trimmed.indexOf('=');
137 if (eqIdx === -1) continue;
138 const feature = trimmed.slice(0, eqIdx).trim().toLowerCase();
139 const allowlistRaw = trimmed.slice(eqIdx + 1).trim();
140 if (!feature) continue;
141
142 const { allowlist, allowAll, allowSelf, allowNone } = parseAllowlist(allowlistRaw);
143
144 directives.push({
145 feature,
146 allowlist,
147 allowAll,
148 allowSelf,
149 allowNone,
150 raw: trimmed,
151 });
152 }
153 return directives;
154}
155
156export function parseFeaturePolicy(headerValue) {
157 if (!headerValue || typeof headerValue !== 'string') return [];
158
159
160 const directives = [];
161 const parts = headerValue.split(';');
162 for (const part of parts) {
163 const trimmed = part.trim();
164 if (!trimmed) continue;
165 const tokens = trimmed.split(/\s+/);
166 if (tokens.length === 0) continue;
167 const feature = tokens[0].trim().toLowerCase();
168 if (!feature) continue;
169 const originTokens = tokens.slice(1);
170
171 const allowlist = [];
172 let allowAll = false;
173 let allowSelf = false;
174 let allowNone = false;
175
176 if (originTokens.length === 0) {
177 allowAll = true;
178 } else {
179 for (const tok of originTokens) {
180 const lt = tok.trim().toLowerCase();
181 if (lt === '*') allowAll = true;
182 else if (lt === "'self'") allowSelf = true;
183 else if (lt === "'none'") allowNone = true;
184 else if (lt === 'self') allowSelf = true;
185 else if (lt === 'none') allowNone = true;
186 else allowlist.push(tok);
187 }
188 }
189
190 directives.push({
191 feature,
192 allowlist,
193 allowAll,
194 allowSelf,
195 allowNone,
196 raw: trimmed,
197 });
198 }
199 return directives;
200}
201
202function splitTopLevel(str, delimiter) {
203 const result = [];
204 let depth = 0;
205 let current = '';
206 for (const char of str) {
207 if (char === '(') depth++;
208 else if (char === ')') depth = Math.max(0, depth - 1);
209 if (char === delimiter && depth === 0) {
210 result.push(current);
211 current = '';
212 } else {
213 current += char;
214 }
215 }
216 if (current.trim()) result.push(current);
217 return result;
218}
219
220function parseAllowlist(raw) {
221 const trimmed = raw.trim();
222
223
224 if (trimmed === '*') {
225 return { allowlist: [], allowAll: true, allowSelf: false, allowNone: false };
226 }
227
228
229 const parenMatch = /^\((.*)\)$/s.exec(trimmed);
230 if (parenMatch) {
231 const inner = parenMatch[1].trim();
232 if (!inner || inner.toLowerCase() === 'none') {
233 return { allowlist: [], allowAll: false, allowSelf: false, allowNone: true };
234 }
235 const tokens = inner.split(/\s+/).filter(Boolean);
236 const allowlist = [];
237 let allowAll = false;
238 let allowSelf = false;
239 for (const tok of tokens) {
240 const lt = tok.trim().toLowerCase();
241 if (lt === '*') allowAll = true;
242 else if (lt === 'self') allowSelf = true;
243 else allowlist.push(tok);
244 }
245 return { allowlist, allowAll, allowSelf, allowNone: false };
246 }
247
248
249 if (trimmed.toLowerCase() === 'self') {
250 return { allowlist: [], allowAll: false, allowSelf: true, allowNone: false };
251 }
252 if (trimmed.toLowerCase() === 'none') {
253 return { allowlist: [], allowAll: false, allowSelf: false, allowNone: true };
254 }
255 return { allowlist: [trimmed], allowAll: false, allowSelf: false, allowNone: false };
256}
257
258
259
260
261
262
263
264
265const SENSITIVE_FEATURES = new Set([
266
267 'camera', 'microphone', 'display-capture',
268
269 'geolocation',
270
271 'accelerometer', 'gyroscope', 'magnetometer',
272
273 'clipboard-read', 'clipboard-write',
274
275 'notifications', 'push',
276
277 'payment',
278
279 'usb', 'bluetooth', 'nfc', 'serial', 'hid',
280
281 'identity-credentials-get',
282
283 'screen-wake-lock',
284
285 'local-fonts',
286
287 'midi',
288
289 'vibrate',
290
291 'fullscreen',
292
293 'picture-in-picture',
294
295 'web-share',
296
297 'gamepad',
298
299 'publickey-credentials-get', 'publickey-credentials-create',
300]);
301
302
303
304
305const HIGH_RISK_FEATURES = new Set([
306 'camera', 'microphone', 'geolocation',
307 'payment', 'usb', 'bluetooth', 'nfc',
308 'clipboard-read', 'notifications', 'push',
309 'midi', 'serial', 'hid', 'display-capture',
310]);
311
312export function classifyFeature(feature) {
313 return {
314 isSensitive: SENSITIVE_FEATURES.has(feature),
315 isHighRisk: HIGH_RISK_FEATURES.has(feature),
316 };
317}
318
319
320
321
322
323export function analyzeDirective(directive) {
324 const { isSensitive, isHighRisk } = classifyFeature(directive.feature);
325 const issues = [];
326
327 if (directive.allowAll) {
328 if (isHighRisk) {
329 issues.push({
330 severity: 'error',
331 message: `High-risk feature "${directive.feature}" is allowed for all origins (*); this permits any third-party iframe to use it.`,
332 });
333 } else if (isSensitive) {
334 issues.push({
335 severity: 'warn',
336 message: `Sensitive feature "${directive.feature}" is allowed for all origins (*); consider restricting to self or specific origins.`,
337 });
338 }
339 }
340
341 if (directive.allowSelf && isHighRisk) {
342
343 issues.push({
344 severity: 'info',
345 message: `High-risk feature "${directive.feature}" is restricted to self; this is a reasonable baseline.`,
346 });
347 }
348
349 if (directive.allowNone && isHighRisk) {
350 issues.push({
351 severity: 'info',
352 message: `High-risk feature "${directive.feature}" is fully denied; this is the safest posture if the feature is not needed.`,
353 });
354 }
355
356
357 if (directive.allowlist.length > 0 && isSensitive) {
358 issues.push({
359 severity: 'warn',
360 message: `Sensitive feature "${directive.feature}" is allowed for specific origins: ${directive.allowlist.join(', ')}. Verify these are trusted.`,
361 });
362 }
363
364 return {
365 feature: directive.feature,
366 allowlist: directive.allowlist,
367 allowAll: directive.allowAll,
368 allowSelf: directive.allowSelf,
369 allowNone: directive.allowNone,
370 raw: directive.raw,
371 isSensitive,
372 isHighRisk,
373 issues,
374 };
375}
376
377
378
379
380
381export function scorePolicy(analysis) {
382 if (!analysis.hasPermissionsPolicy && !analysis.hasFeaturePolicy) {
383 return {
384 score: 0,
385 grade: 'F',
386 issues: ['No Permissions-Policy or Feature-Policy header is set. Browser features (camera, microphone, geolocation, payment, USB) are not restricted.'],
387 recommendations: [
388 'Set a Permissions-Policy header that explicitly denies or restricts sensitive browser features.',
389 'At minimum, deny high-risk features: camera, microphone, geolocation, payment, usb.',
390 'Example: Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()',
391 ],
392 };
393 }
394
395 let score = 100;
396 const issues = [];
397
398
399 const highRiskAllowed = analysis.directives.filter(
400 (d) => d.isHighRisk && d.allowAll,
401 );
402 if (highRiskAllowed.length > 0) {
403 const penalty = Math.min(40, highRiskAllowed.length * 15);
404 score -= penalty;
405 issues.push(`${highRiskAllowed.length} high-risk feature(s) are allowed for all origins (*): ${highRiskAllowed.map((d) => d.feature).join(', ')}.`);
406 }
407
408
409 const sensitiveAllowed = analysis.directives.filter(
410 (d) => d.isSensitive && !d.isHighRisk && d.allowAll,
411 );
412 if (sensitiveAllowed.length > 0) {
413 const penalty = Math.min(20, sensitiveAllowed.length * 5);
414 score -= penalty;
415 issues.push(`${sensitiveAllowed.length} sensitive feature(s) are allowed for all origins (*): ${sensitiveAllowed.map((d) => d.feature).join(', ')}.`);
416 }
417
418
419 const managedFeatures = new Set(analysis.directives.map((d) => d.feature));
420 const missingHighRisk = [...HIGH_RISK_FEATURES].filter((f) => !managedFeatures.has(f));
421 if (missingHighRisk.length > 0) {
422 const penalty = Math.min(25, missingHighRisk.length * 3);
423 score -= penalty;
424 issues.push(`${missingHighRisk.length} high-risk feature(s) are not mentioned in the policy (defaulting to allowed): ${missingHighRisk.join(', ')}.`);
425 }
426
427
428 if (analysis.hasFeaturePolicy) {
429 score -= 10;
430 issues.push('Legacy Feature-Policy header is present; it is deprecated and replaced by Permissions-Policy. Migrate to the new syntax.');
431 }
432
433
434 if (analysis.hasPermissionsPolicy && analysis.hasFeaturePolicy) {
435 score -= 5;
436 issues.push('Both Permissions-Policy and Feature-Policy are set; browsers may apply conflicting rules. Remove the legacy Feature-Policy header.');
437 }
438
439
440 const thirdPartySensitive = analysis.directives.filter(
441 (d) => d.isSensitive && d.allowlist.length > 0,
442 );
443 if (thirdPartySensitive.length > 0) {
444 score -= Math.min(10, thirdPartySensitive.length * 3);
445 issues.push(`${thirdPartySensitive.length} sensitive feature(s) are allowed for specific origins; verify trust: ${thirdPartySensitive.map((d) => d.feature).join(', ')}.`);
446 }
447
448
449 const allHighRiskManaged = [...HIGH_RISK_FEATURES].every((f) => managedFeatures.has(f));
450 if (allHighRiskManaged) {
451 const allRestricted = [...HIGH_RISK_FEATURES].every((f) => {
452 const d = analysis.directives.find((dd) => dd.feature === f);
453 return d && (d.allowNone || d.allowSelf);
454 });
455 if (allRestricted) {
456 score = Math.min(100, score + 5);
457 }
458 }
459
460 const bounded = Math.max(0, Math.min(100, score));
461 const grade = bounded >= 95 ? 'A+'
462 : bounded >= 85 ? 'A'
463 : bounded >= 75 ? 'B'
464 : bounded >= 65 ? 'C'
465 : bounded >= 50 ? 'D'
466 : bounded >= 30 ? 'E'
467 : 'F';
468
469 const recommendations = buildRecommendations(analysis, issues, bounded);
470 return { score: bounded, grade, issues, recommendations };
471}
472
473function buildRecommendations(analysis, issues, score) {
474 const recs = [...issues];
475
476 const managedFeatures = new Set(analysis.directives.map((d) => d.feature));
477 const missingHighRisk = [...HIGH_RISK_FEATURES].filter((f) => !managedFeatures.has(f));
478
479 if (!analysis.hasPermissionsPolicy && !analysis.hasFeaturePolicy) {
480 return recs;
481 }
482
483 if (missingHighRisk.length > 0) {
484 recs.push(`Add the following high-risk features to the Permissions-Policy: ${missingHighRisk.join(', ')}. Deny them with () if not needed.`);
485 }
486
487 const highRiskAllowed = analysis.directives.filter((d) => d.isHighRisk && d.allowAll);
488 if (highRiskAllowed.length > 0) {
489 recs.push(`Restrict high-risk features that are currently allowed for all origins: ${highRiskAllowed.map((d) => d.feature).join(', ')}. Use () to deny or (self) to restrict to same-origin.`);
490 }
491
492 if (analysis.hasFeaturePolicy) {
493 recs.push('Remove the deprecated Feature-Policy header and consolidate all feature restrictions into a single Permissions-Policy header.');
494 }
495
496 if (score >= 95 && issues.length === 0) {
497 recs.push('Permissions-Policy posture is strong; all high-risk features are restricted. Continue monitoring after deploys and CDN cutovers.');
498 }
499
500 return recs.length ? recs : ['No Permissions-Policy issues detected.'];
501}
502
503
504
505
506
507export async function auditPermissionsPolicy(input) {
508 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
509
510 let startUrl;
511 let fetched;
512 try {
513 startUrl = await normalizeAndValidateUrl(input.startUrl);
514 fetched = await fetchHeaders(startUrl, timeoutSeconds);
515 } catch (validationError) {
516 return errorResult(input.startUrl, validationError.message);
517 }
518
519 if (fetched.error) {
520 return errorResult(input.startUrl, fetched.error, fetched.finalUrl, fetched.https, fetched.status);
521 }
522
523 const rawPermissionsPolicy = fetched.headers['permissions-policy'] || null;
524 const rawFeaturePolicy = fetched.headers['feature-policy'] || null;
525
526 const ppDirectives = rawPermissionsPolicy ? parsePermissionsPolicy(rawPermissionsPolicy) : [];
527 const fpDirectives = rawFeaturePolicy ? parseFeaturePolicy(rawFeaturePolicy) : [];
528
529 const allDirectives = [...ppDirectives, ...fpDirectives];
530 const analyzedDirectives = allDirectives.map(analyzeDirective);
531
532 const analysis = {
533 hasPermissionsPolicy: Boolean(rawPermissionsPolicy),
534 hasFeaturePolicy: Boolean(rawFeaturePolicy),
535 directives: analyzedDirectives,
536 directiveCount: analyzedDirectives.length,
537 };
538
539 const scored = scorePolicy(analysis);
540
541 return {
542 inputUrl: input.startUrl,
543 normalizedInputUrl: startUrl.href,
544 finalUrl: fetched.finalUrl,
545 https: fetched.https,
546 ok: true,
547 checkedAt: new Date().toISOString(),
548 httpStatus: fetched.status,
549 hasPermissionsPolicy: analysis.hasPermissionsPolicy,
550 hasFeaturePolicy: analysis.hasFeaturePolicy,
551 rawPermissionsPolicy,
552 rawFeaturePolicy,
553 directives: analyzedDirectives,
554 directiveCount: analysis.directiveCount,
555 highRiskFeaturesManaged: analyzedDirectives.filter((d) => d.isHighRisk).map((d) => d.feature),
556 highRiskFeaturesMissing: [...HIGH_RISK_FEATURES].filter(
557 (f) => !analyzedDirectives.some((d) => d.feature === f),
558 ),
559 highRiskFeaturesAllowedAll: analyzedDirectives.filter(
560 (d) => d.isHighRisk && d.allowAll,
561 ).map((d) => d.feature),
562 score: scored.score,
563 grade: scored.grade,
564 issues: scored.issues,
565 recommendations: scored.recommendations,
566 error: null,
567 };
568}
569
570function errorResult(inputUrl, errorMsg, finalUrl, https, httpStatus) {
571 return {
572 inputUrl,
573 normalizedInputUrl: null,
574 finalUrl: finalUrl || (typeof inputUrl === 'string' ? inputUrl : null),
575 https: https ?? false,
576 ok: false,
577 checkedAt: new Date().toISOString(),
578 httpStatus: httpStatus ?? null,
579 hasPermissionsPolicy: false,
580 hasFeaturePolicy: false,
581 rawPermissionsPolicy: null,
582 rawFeaturePolicy: null,
583 directives: [],
584 directiveCount: 0,
585 highRiskFeaturesManaged: [],
586 highRiskFeaturesMissing: [...HIGH_RISK_FEATURES],
587 highRiskFeaturesAllowedAll: [],
588 score: 0,
589 grade: 'F',
590 issues: ['The request failed before headers could be inspected. Verify the URL is reachable and try again.'],
591 recommendations: ['Verify the URL is public, reachable, and returns a response.'],
592 error: errorMsg,
593 };
594}
595
596
597
598
599
600const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
601
602if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
603 await Actor.init();
604 try {
605 const input = await Actor.getInput();
606 const result = await auditPermissionsPolicy(input || {});
607 await Actor.pushData(result);
608 await Actor.setValue('OUTPUT', result);
609 Actor.log.info('Permissions-Policy audit complete', { finalUrl: result.finalUrl, score: result.score, grade: result.grade, directiveCount: result.directiveCount });
610 } finally {
611 await Actor.exit();
612 }
613}