1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'RedirectChainAuditor/0.1 (+https://apify.com)';
7const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
8
9function isPrivateIPv4(ip) {
10 const parts = ip.split('.').map(Number);
11 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
12 const [a, b] = parts;
13 return a === 10
14 || (a === 172 && b >= 16 && b <= 31)
15 || (a === 192 && b === 168)
16 || a === 127
17 || a === 0
18 || (a === 169 && b === 254);
19}
20
21function isPrivateIPv6(ip) {
22 const normalized = ip.toLowerCase();
23 return normalized === '::1'
24 || normalized.startsWith('fc')
25 || normalized.startsWith('fd')
26 || normalized.startsWith('fe80:');
27}
28
29export async function normalizeAndValidateUrl(rawUrl) {
30 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
31 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
32 throw new Error('Only HTTP and HTTPS URLs are supported');
33 }
34
35 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
36 const url = new URL(withScheme);
37 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
38 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
39
40 const literalType = net.isIP(url.hostname);
41 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
42 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
43
44 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
45 for (const record of records) {
46 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
47 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
48 }
49 return url;
50}
51
52function clampInteger(value, fallback, min, max) {
53 const parsed = Number(value);
54 if (!Number.isFinite(parsed)) return fallback;
55 return Math.min(Math.max(Math.trunc(parsed), min), max);
56}
57
58function scoreRedirectChain(chain, finalStatus, finalUrl, inputUrl, error) {
59 const issues = [];
60 const recommendations = [];
61 let score = 100;
62
63 if (error) {
64 return {
65 score: 0,
66 grade: 'F',
67 issues: [error],
68 recommendations: ['Verify the URL is public, reachable, and does not redirect to a blocked or invalid target.'],
69 };
70 }
71
72 if (chain.length > 1) {
73 issues.push(`${chain.length - 1} redirect hop(s)`);
74 if (chain.length - 1 > 2) {
75 score -= 25;
76 recommendations.push('Reduce the redirect chain to one or two hops for faster crawls and page loads.');
77 } else {
78 score -= 10;
79 }
80 }
81
82 const temporary = chain.filter((hop) => [302, 303, 307].includes(hop.status));
83 if (temporary.length) {
84 score -= 15;
85 issues.push('Temporary redirect in chain');
86 recommendations.push('Use 301 or 308 redirects for permanent URL migrations.');
87 }
88
89 const start = new URL(inputUrl);
90 const final = finalUrl ? new URL(finalUrl) : null;
91 if (start.protocol === 'https:' && final?.protocol === 'http:') {
92 score -= 30;
93 issues.push('HTTPS downgrades to HTTP');
94 recommendations.push('Keep redirects on HTTPS to avoid security and SEO loss.');
95 }
96 if (finalStatus && finalStatus >= 400) {
97 score -= 40;
98 issues.push(`Final status is ${finalStatus}`);
99 recommendations.push('Redirect users and crawlers to a live 2xx destination.');
100 }
101
102 const seen = new Set();
103 for (const hop of chain) {
104 if (seen.has(hop.url)) {
105 score -= 50;
106 issues.push('Redirect loop detected');
107 recommendations.push('Remove looping redirect rules.');
108 break;
109 }
110 seen.add(hop.url);
111 }
112
113 const bounded = Math.max(0, score);
114 const grade = bounded >= 90 ? 'A' : bounded >= 75 ? 'B' : bounded >= 60 ? 'C' : bounded >= 45 ? 'D' : 'F';
115 return { score: bounded, grade, issues, recommendations };
116}
117
118async function fetchHop(url, timeoutSeconds) {
119 await normalizeAndValidateUrl(url.href);
120 const controller = new AbortController();
121 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
122 try {
123 const response = await fetch(url, {
124 method: 'GET',
125 redirect: 'manual',
126 signal: controller.signal,
127 headers: { 'user-agent': USER_AGENT, accept: 'text/html,*/*;q=0.1' },
128 });
129 return {
130 status: response.status,
131 location: response.headers.get('location') || '',
132 contentType: response.headers.get('content-type') || '',
133 };
134 } finally {
135 clearTimeout(timeout);
136 }
137}
138
139export async function auditRedirectChain(input) {
140 const startUrl = await normalizeAndValidateUrl(input.startUrl);
141 const maxRedirects = clampInteger(input.maxRedirects, 10, 1, 20);
142 const timeoutSeconds = clampInteger(input.timeoutSeconds, 10, 3, 30);
143 const checkedAt = new Date().toISOString();
144 const chain = [];
145 let current = startUrl;
146 let error = null;
147 let finalStatus = null;
148 let finalUrl = startUrl.href;
149
150 try {
151 for (let index = 0; index <= maxRedirects; index += 1) {
152 const hop = await fetchHop(current, timeoutSeconds);
153 finalStatus = hop.status;
154 finalUrl = current.href;
155 const row = { url: current.href, status: hop.status, location: hop.location || null };
156 chain.push(row);
157
158 if (!REDIRECT_STATUSES.has(hop.status)) break;
159 if (index === maxRedirects) throw new Error('Too many redirects');
160 if (!hop.location) throw new Error('Redirect without Location header');
161 const next = new URL(hop.location, current.href);
162 await normalizeAndValidateUrl(next.href);
163 row.nextUrl = next.href;
164 current = next;
165 }
166 } catch (caught) {
167 error = caught.message;
168 }
169
170 const scored = scoreRedirectChain(chain, finalStatus, finalUrl, startUrl.href, error);
171 return {
172 inputUrl: input.startUrl,
173 normalizedInputUrl: startUrl.href,
174 finalUrl,
175 ok: !error && finalStatus >= 200 && finalStatus < 400,
176 status: finalStatus,
177 checkedAt,
178 hopCount: Math.max(0, chain.length - 1),
179 chain,
180 score: scored.score,
181 grade: scored.grade,
182 issues: scored.issues,
183 recommendations: scored.recommendations,
184 error,
185 };
186}
187
188const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
189
190if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
191 await Actor.init();
192 try {
193 const input = await Actor.getInput();
194 const result = await auditRedirectChain(input || {});
195 await Actor.pushData(result);
196 await Actor.setValue('OUTPUT', result);
197 Actor.log.info('Redirect chain audit complete', { finalUrl: result.finalUrl, score: result.score, grade: result.grade });
198 } finally {
199 await Actor.exit();
200 }
201}