1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'CorsAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8const DEFAULT_PROBE_ORIGIN = 'https://probe.cors-auditor.local';
9const DEFAULT_PROBE_METHOD = 'POST';
10
11const AC_HEADERS = [
12 'access-control-allow-origin',
13 'access-control-allow-credentials',
14 'access-control-allow-methods',
15 'access-control-allow-headers',
16 'access-control-expose-headers',
17 'access-control-max-age',
18 'vary',
19];
20
21
22
23
24
25
26
27
28function isPrivateIPv4(ip) {
29 const parts = ip.split('.').map(Number);
30 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
31 const [a, b] = parts;
32 return a === 10
33 || (a === 172 && b >= 16 && b <= 31)
34 || (a === 192 && b === 168)
35 || a === 127
36 || a === 0
37 || (a === 169 && b === 254);
38}
39
40function isPrivateIPv6(ip) {
41 const normalized = ip.toLowerCase();
42 return normalized === '::1'
43 || normalized.startsWith('fc')
44 || normalized.startsWith('fd')
45 || normalized.startsWith('fe80:');
46}
47
48export async function normalizeAndValidateUrl(rawUrl) {
49 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
50 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
51 throw new Error('Only HTTP and HTTPS URLs are supported');
52 }
53
54 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
55 const url = new URL(withScheme);
56 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
57 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
58
59 const literalType = net.isIP(url.hostname);
60 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
61 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
62
63 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
64 for (const record of records) {
65 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
66 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
67 }
68 return url;
69}
70
71
72export function normalizeProbeOrigin(raw) {
73 if (!raw || typeof raw !== 'string' || !raw.trim()) return DEFAULT_PROBE_ORIGIN;
74 const trimmed = raw.trim();
75 if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed) && !/^https?:\/\//i.test(trimmed)) {
76 throw new Error('probeOrigin must be an http(s) URL or bare hostname');
77 }
78 if (/^https?:\/\//i.test(trimmed)) {
79 const u = new URL(trimmed);
80 if (!['http:', 'https:'].includes(u.protocol)) throw new Error('probeOrigin must be http(s)');
81 if (u.username || u.password) throw new Error('probeOrigin must not include credentials');
82
83 return `${u.protocol}//${u.host}`;
84 }
85 return `https://${trimmed}`;
86}
87
88
89
90
91
92function headerMap(response) {
93 const map = {};
94 response.headers.forEach((value, key) => {
95 map[key.toLowerCase()] = value;
96 });
97 return map;
98}
99
100async function fetchOnce(url, options, timeoutSeconds) {
101 const controller = new AbortController();
102 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
103 try {
104 const response = await fetch(url, { ...options, redirect: 'manual', signal: controller.signal });
105 return response;
106 } finally {
107 clearTimeout(timeout);
108 }
109}
110
111async function fetchWithOrigin(initialUrl, method, probeOrigin, extraHeaders, timeoutSeconds, redirectsRemaining = 3) {
112 await normalizeAndValidateUrl(initialUrl.href);
113 const headers = {
114 'user-agent': USER_AGENT,
115 accept: '*/*',
116 origin: probeOrigin,
117 ...extraHeaders,
118 };
119 const response = await fetchOnce(initialUrl, { method, headers }, timeoutSeconds);
120
121 if ([301, 302, 303, 307, 308].includes(response.status)) {
122 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
123 const location = response.headers.get('location');
124 if (!location) throw new Error('Redirect without Location header');
125 const next = new URL(location, initialUrl.href);
126 await normalizeAndValidateUrl(next.href);
127 return fetchWithOrigin(next, method, probeOrigin, extraHeaders, timeoutSeconds, redirectsRemaining - 1);
128 }
129
130
131 try { await response.arrayBuffer(); } catch { }
132
133 return {
134 ok: response.ok,
135 status: response.status,
136 finalUrl: response.url || initialUrl.href,
137 https: (response.url || initialUrl.href).startsWith('https://'),
138 headers: headerMap(response),
139 };
140}
141
142
143
144
145
146export function parseHeaderList(value) {
147 if (!value) return [];
148 return value.split(',')
149 .map((s) => s.trim().toLowerCase())
150 .filter(Boolean);
151}
152
153export function parseMaxAge(value) {
154 if (!value) return null;
155 const n = Number(value);
156 if (Number.isNaN(n)) return null;
157 return Math.max(0, Math.floor(n));
158}
159
160export function classifyAllowOrigin(value, probeOrigin) {
161
162 if (!value) return { allowOrigin: null, isWildcard: false, isNullEcho: false, isReflected: false, isStatic: false, isMissing: true };
163 const trimmed = value.trim();
164 if (trimmed === '*') return { allowOrigin: '*', isWildcard: true, isNullEcho: false, isReflected: false, isStatic: false, isMissing: false };
165 if (trimmed === 'null') return { allowOrigin: 'null', isWildcard: false, isNullEcho: true, isReflected: false, isStatic: false, isMissing: false };
166
167 const isReflected = trimmed === probeOrigin;
168
169 return { allowOrigin: trimmed, isWildcard: false, isNullEcho: false, isReflected, isStatic: !isReflected, isMissing: false };
170}
171
172
173export function analyzeCorsSimple(simpleHeaders, probeOrigin) {
174 const allow = classifyAllowOrigin(simpleHeaders['access-control-allow-origin'], probeOrigin);
175 const allowsCredentials = /^true$/i.test(simpleHeaders['access-control-allow-credentials'] || '');
176 const expose = parseHeaderList(simpleHeaders['access-control-expose-headers']);
177 const vary = parseHeaderList(simpleHeaders['vary']);
178 const varyOrigin = vary.includes('origin');
179 return {
180 allowOriginSimple: allow,
181 allowsCredentials,
182 exposeHeaders: expose,
183 vary,
184 varyOrigin,
185 };
186}
187
188export function analyzeCorsPreflight(preflightHeaders, probeOrigin) {
189 const allow = classifyAllowOrigin(preflightHeaders['access-control-allow-origin'], probeOrigin);
190 const allowsCredentials = /^true$/i.test(preflightHeaders['access-control-allow-credentials'] || '');
191 const allowMethods = parseHeaderList(preflightHeaders['access-control-allow-methods']);
192 const allowHeaders = parseHeaderList(preflightHeaders['access-control-allow-headers']);
193 const maxAge = parseMaxAge(preflightHeaders['access-control-max-age']);
194 const vary = parseHeaderList(preflightHeaders['vary']);
195 const varyOrigin = vary.includes('origin');
196 return {
197 allowOriginPreflight: allow,
198 allowsCredentialsPreflight: allowsCredentials,
199 allowMethods,
200 allowHeaders,
201 maxAge,
202 vary,
203 varyOriginPreflight: varyOrigin,
204 };
205}
206
207
208
209
210
211
212function evalAllowOrigin(summary, preflight, mode) {
213 const ac = mode === 'preflight' ? preflight.allowOriginPreflight : summary.allowOriginSimple;
214 const credentials = mode === 'preflight' ? preflight.allowsCredentialsPreflight : summary.allowsCredentials;
215
216 if (ac.isMissing) {
217 return {
218 status: 'missing',
219 note: `No Access-Control-Allow-Origin on ${mode} response. The browser will block cross-origin reads.`,
220 recommendation: `If this resource is meant to be read cross-origin, return Access-Control-Allow-Origin with an explicit trusted origin (not '*').`,
221 };
222 }
223
224 if (ac.isWildcard && credentials) {
225 return {
226 status: 'warn',
227 note: `Access-Control-Allow-Origin: * combined with Access-Control-Allow-Credentials: true is invalid; browsers reject it and credentials fall back to same-origin.`,
228 recommendation: `Echo a specific trusted origin in Access-Control-Allow-Origin instead of '*' when credentials are required.`,
229 };
230 }
231
232
233 if (ac.isWildcard) {
234 return { status: 'good', note: `Access-Control-Allow-Origin: * (public, no credentials).` };
235 }
236
237 if (ac.isNullEcho) {
238 return {
239 status: 'warn',
240 note: `Access-Control-Allow-Origin: null is treated as a permissive origin by browsers and is often exploitable from sandboxed iframes and local files.`,
241 recommendation: `Return a concrete trusted origin instead of 'null' on the ${mode} response.`,
242 };
243 }
244
245 if (ac.isReflected && mode === 'simple' && !summary.varyOrigin) {
246 return {
247 status: 'warn',
248 note: `Server reflects the request Origin but Vary does not include Origin. CDN caches may serve the wrong Allow-Origin to other clients.`,
249 recommendation: `Add 'Origin' to the Vary header on responses that reflect the request Origin.`,
250 };
251 }
252
253 if (ac.isReflected && credentials) {
254
255 return { status: 'good', note: `Origin is reflected on the ${mode} response and credentials are allowed. Confirm the server validates Origin against an allow-list.` };
256 }
257
258 if (ac.isReflected) {
259 return { status: 'good', note: `Origin is reflected on the ${mode} response; no credentials allowed.` };
260 }
261
262
263 return { status: 'good', note: `Access-Control-Allow-Origin: ${ac.allowOrigin} (static origin) on the ${mode} response.` };
264}
265
266function evalAllowCredentials(summary, preflight) {
267 const simple = summary.allowsCredentials;
268 const pre = preflight.allowsCredentialsPreflight;
269 if (!simple && !pre) return { status: 'info', note: 'No Access-Control-Allow-Credentials header (credentials not allowed cross-origin).' };
270
271 if (summary.allowOriginSimple.isWildcard || preflight.allowOriginPreflight.isWildcard) {
272
273 return {
274 status: 'warn',
275 note: `Access-Control-Allow-Credentials: true is set but Allow-Origin is '*', which browsers reject.`,
276 recommendation: `Echo a specific trusted origin when credentials are required.`,
277 };
278 }
279 return { status: 'good', note: 'Access-Control-Allow-Credentials: true allows cookies, Authorization, and client TLS certs cross-origin.' };
280}
281
282function evalAllowMethods(preflight, requestedMethod) {
283 const m = preflight.allowMethods;
284 if (!m.length) {
285 return {
286 status: 'missing',
287 note: 'No Access-Control-Allow-Methods on preflight response. Preflight will fail for non-simple methods.',
288 recommendation: 'Return Access-Control-Allow-Methods with the methods your API supports.',
289 };
290 }
291 const requested = requestedMethod.toLowerCase();
292
293 const everything = m.includes('*');
294 if (everything) {
295 return {
296 status: 'warn',
297 note: `Access-Control-Allow-Methods: '*' is non-standard; browsers may ignore it. Set explicit methods.`,
298 recommendation: 'Replace Allow-Methods: * with explicit method names (GET, POST, PUT, PATCH, DELETE, OPTIONS).',
299 };
300 }
301 if (requested && !m.includes(requested) && !m.includes('*')) {
302 return {
303 status: 'warn',
304 note: `Preflight was for ${requested.toUpperCase()} but Allow-Methods is ${m.join(', ').toUpperCase()}. The requested method will be blocked.`,
305 recommendation: `Add ${requested.toUpperCase()} to Access-Control-Allow-Methods, or restrict probeMethod to a method the API actually supports.`,
306 };
307 }
308 return { status: 'good', note: `Access-Control-Allow-Methods: ${m.join(', ').toUpperCase()}.` };
309}
310
311function evalAllowHeaders(preflight) {
312 const h = preflight.allowHeaders;
313 if (!h.length) {
314
315 return { status: 'info', note: 'No Access-Control-Allow-Headers on preflight response. Only simple request headers (Accept, Accept-Language, Content-Language, Content-Type) are permitted preflight.' };
316 }
317 if (h.includes('*')) {
318 return {
319 status: 'warn',
320 note: `Access-Control-Allow-Headers: '*' is non-standard; many browsers ignore it for client headers.`,
321 recommendation: 'List explicit header names in Access-Control-Allow-Headers.',
322 };
323 }
324 return { status: 'good', note: `Access-Control-Allow-Headers: ${h.join(', ')}.` };
325}
326
327function evalExposeHeaders(summary) {
328 const h = summary.exposeHeaders;
329 if (!h.length) return { status: 'info', note: 'No Access-Control-Expose-Headers. Cross-origin scripts can only read a limited set of simple response headers.' };
330 if (h.includes('*')) {
331 return {
332 status: 'warn',
333 note: `Access-Control-Expose-Headers: '*' exposes all headers but is non-standard; some browsers ignore it.`,
334 recommendation: 'List explicit header names in Access-Control-Expose-Headers.',
335 };
336 }
337 return { status: 'good', note: `Access-Control-Expose-Headers: ${h.join(', ')}.` };
338}
339
340function evalMaxAge(preflight) {
341 if (preflight.maxAge === null) return { status: 'info', note: 'No Access-Control-Max-Age. Browsers will re-issue preflight on every request, increasing latency.' };
342
343 if (preflight.maxAge < 600) {
344 return {
345 status: 'warn',
346 note: `Access-Control-Max-Age: ${preflight.maxAge}s is short; browsers re-preflight frequently.`,
347 recommendation: 'Raise Access-Control-Max-Age (e.g., 600-86400 seconds) when the CORS policy is stable.',
348 };
349 }
350 return { status: 'good', note: `Access-Control-Max-Age: ${preflight.maxAge}s.` };
351}
352
353function evalVaryOrigin(summary, preflight) {
354 if (summary.allowOriginSimple.isMissing) return { status: 'info', note: 'No Access-Control-Allow-Origin on simple response; Vary not relevant.' };
355 if (!summary.allowOriginSimple.isReflected && !preflight.allowOriginPreflight.isReflected) {
356 return { status: 'info', note: 'Allow-Origin is static (not reflected); Vary: Origin is not required.' };
357 }
358 if (summary.varyOrigin || preflight.varyOriginPreflight) {
359 return { status: 'good', note: 'Vary includes Origin, so reflection caches correctly.' };
360 }
361 return {
362 status: 'warn',
363 note: 'Allow-Origin is reflected but Vary does not include Origin. CDN caches may pin one client\'s Allow-Origin and serve it to others.',
364 recommendation: 'Add "Origin" to the Vary header whenever you reflect the request Origin.',
365 };
366}
367
368
369
370
371
372const CHECKS = [
373 { name: 'Access-Control-Allow-Origin (simple)', header: 'allow-origin-simple', weight: 25, fn: (s, p, req) => evalAllowOrigin(s, p, 'simple') },
374 { name: 'Access-Control-Allow-Origin (preflight)', header: 'allow-origin-preflight', weight: 20, fn: (s, p, req) => evalAllowOrigin(s, p, 'preflight') },
375 { name: 'Access-Control-Allow-Credentials', header: 'allow-credentials', weight: 15, fn: (s) => evalAllowCredentials(s, s.preflight || {}) },
376 { name: 'Access-Control-Allow-Methods', header: 'allow-methods', weight: 15, fn: (s, p, req) => evalAllowMethods(p, req.probeMethod) },
377 { name: 'Access-Control-Allow-Headers', header: 'allow-headers', weight: 10, fn: (s, p) => evalAllowHeaders(p) },
378 { name: 'Access-Control-Expose-Headers', header: 'expose-headers', weight: 5, fn: (s) => evalExposeHeaders(s) },
379 { name: 'Access-Control-Max-Age', header: 'max-age', weight: 5, fn: (s, p) => evalMaxAge(p) },
380 { name: 'Vary: Origin', header: 'vary-origin', weight: 5, fn: (s, p) => evalVaryOrigin(s, p) },
381];
382
383export function buildReports(simpleSummary, preflightSummary, probeMethod) {
384 const reports = [];
385 let earned = 0;
386 let possible = 0;
387 for (const check of CHECKS) {
388 const e = check.fn(simpleSummary, preflightSummary, { probeMethod });
389 if (e.status !== 'info') possible += check.weight;
390 if (e.status === 'good') earned += check.weight;
391 else if (e.status === 'warn') earned += Math.round(check.weight * 0.5);
392 reports.push({
393 name: check.name,
394 header: check.header,
395 status: e.status,
396 note: e.note,
397 weight: check.weight,
398 recommendation: e.recommendation || null,
399 });
400 }
401 return { reports, earned, possible };
402}
403
404export function scoreAudit(earned, possible) {
405 if (possible === 0) return 0;
406 return Math.min(100, Math.max(0, Math.round((earned / possible) * 100)));
407}
408
409export function gradeFromScore(score) {
410 if (score >= 95) return 'A+';
411 if (score >= 85) return 'A';
412 if (score >= 75) return 'B';
413 if (score >= 65) return 'C';
414 if (score >= 50) return 'D';
415 if (score >= 30) return 'E';
416 return 'F';
417}
418
419export function buildRecommendations(reports, simpleSummary, preflightSummary) {
420 const recs = new Set();
421 for (const r of reports) {
422 if (r.recommendation) recs.add(r.recommendation);
423 }
424
425 const simple = simpleSummary.allowOriginSimple;
426 const pre = preflightSummary.allowOriginPreflight;
427 if (simple.isWildcard && simpleSummary.allowsCredentials) {
428 recs.add('Wildcard Allow-Origin with credentials is invalid; echo an allow-listed origin instead.');
429 }
430 if (simple.isReflected && !simpleSummary.varyOrigin) {
431 recs.add('Add "Origin" to the Vary header whenever the Allow-Origin is reflected from the request.');
432 }
433 if (simple.isMissing && pre.isMissing) {
434 recs.add('No CORS headers on either simple or preflight response. If cross-origin access is intended, set Access-Control-Allow-Origin.');
435 }
436 if (recs.size === 0) {
437 recs.add('CORS configuration looks consistent and well-scoped. Re-run after API deploys or origin changes to catch regressions.');
438 }
439 return [...recs];
440}
441
442
443
444
445
446export async function auditCors(input) {
447 const startUrl = await normalizeAndValidateUrl(input.startUrl);
448 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
449 const probeOrigin = normalizeProbeOrigin(input.probeOrigin);
450 let probeMethod = (input.probeMethod || DEFAULT_PROBE_METHOD).trim().toUpperCase();
451 if (!/^[A-Z]+$/.test(probeMethod)) throw new Error('probeMethod must be a single HTTP method token (e.g., POST)');
452
453
454 const simple = await fetchWithOrigin(startUrl, 'GET', probeOrigin, {}, timeoutSeconds);
455
456
457 let preflight = null;
458 try {
459 preflight = await fetchWithOrigin(
460 startUrl,
461 'OPTIONS',
462 probeOrigin,
463 { 'access-control-request-method': probeMethod, 'access-control-request-headers': 'content-type' },
464 timeoutSeconds,
465 );
466 } catch (err) {
467 preflight = { ok: false, status: null, finalUrl: startUrl.href, https: startUrl.protocol === 'https:', headers: {}, error: err.message };
468 }
469
470 if (simple.error) {
471 return {
472 inputUrl: input.startUrl,
473 finalUrl: simple.finalUrl || startUrl.href,
474 https: Boolean(simple.https),
475 status: null,
476 preflightStatus: null,
477 allowOrigin: null,
478 allowOriginPreflight: null,
479 allowsCredentials: false,
480 isWildcard: false,
481 isReflected: false,
482 isNullEcho: false,
483 allowMethods: [],
484 allowHeaders: [],
485 exposeHeaders: [],
486 maxAge: null,
487 varyOrigin: false,
488 headers: [],
489 issues: [],
490 score: 0,
491 grade: 'F',
492 checkedAt: new Date().toISOString(),
493 recommendations: ['The request failed before headers could be inspected. Verify the URL is reachable and try again.'],
494 error: simple.error,
495 };
496 }
497
498 const simpleSummary = analyzeCorsSimple(simple.headers, probeOrigin);
499 const preflightSummary = preflight && !preflight.error
500 ? analyzeCorsPreflight(preflight.headers, probeOrigin)
501 : {
502 allowOriginPreflight: { allowOrigin: null, isWildcard: false, isNullEcho: false, isReflected: false, isStatic: false, isMissing: true },
503 allowsCredentialsPreflight: false,
504 allowMethods: [],
505 allowHeaders: [],
506 maxAge: null,
507 vary: [],
508 varyOriginPreflight: false,
509 };
510
511 simpleSummary.preflight = preflightSummary;
512
513 const { reports, earned, possible } = buildReports(simpleSummary, preflightSummary, probeMethod);
514 const score = scoreAudit(earned, possible);
515 const grade = gradeFromScore(score);
516 const recommendations = buildRecommendations(reports, simpleSummary, preflightSummary);
517
518 const issues = reports
519 .filter((r) => r.status === 'warn' || r.status === 'missing')
520 .map((r) => `${r.name}: ${r.note}`);
521
522 const simpleAllow = simpleSummary.allowOriginSimple;
523 const preAllow = preflightSummary.allowOriginPreflight;
524
525 return {
526 inputUrl: input.startUrl,
527 finalUrl: simple.finalUrl,
528 https: simple.https,
529 status: simple.status,
530 preflightStatus: preflight && !preflight.error ? preflight.status : null,
531 allowOrigin: simpleAllow.allowOrigin,
532 allowOriginPreflight: preAllow.allowOrigin,
533 allowsCredentials: Boolean(simpleSummary.allowsCredentials || preflightSummary.allowsCredentialsPreflight),
534 isWildcard: Boolean(simpleAllow.isWildcard || preAllow.isWildcard),
535 isReflected: Boolean(simpleAllow.isReflected || preAllow.isReflected),
536 isNullEcho: Boolean(simpleAllow.isNullEcho || preAllow.isNullEcho),
537 allowMethods: preflightSummary.allowMethods,
538 allowHeaders: preflightSummary.allowHeaders,
539 exposeHeaders: simpleSummary.exposeHeaders,
540 maxAge: preflightSummary.maxAge,
541 varyOrigin: simpleSummary.varyOrigin || preflightSummary.varyOriginPreflight,
542 headers: reports,
543 issues,
544 score,
545 grade,
546 checkedAt: new Date().toISOString(),
547 recommendations,
548 };
549}
550
551
552
553
554
555const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
556
557if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
558 await Actor.init();
559 try {
560 const input = await Actor.getInput();
561 const result = await auditCors(input || {});
562 await Actor.pushData(result);
563 await Actor.setValue('OUTPUT', result);
564 Actor.log.info('CORS audit complete', { finalUrl: result.finalUrl, score: result.score, grade: result.grade });
565 } finally {
566 await Actor.exit();
567 }
568}