1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'MixedContentAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8const DEFAULT_MAX_HTML_BYTES = 1024 * 1024;
9const MAX_HTML_BYTES = 2 * 1024 * 1024;
10const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
11
12
13
14const ACTIVE_TAGS = {
15 script: { attr: 'src' },
16 link: { attr: 'href', mediaIncludes: true },
17 iframe: { attr: 'src' },
18 object: { attr: 'data' },
19 embed: { attr: 'src' },
20 video: { attr: 'src', mediaIncludes: true },
21 audio: { attr: 'src', mediaIncludes: true },
22 source: { attr: 'src' },
23};
24const PASSIVE_TAGS = {
25 img: { attr: 'src' },
26 image: { attr: 'href' },
27};
28
29function isPrivateIPv4(ip) {
30 const parts = ip.split('.').map(Number);
31 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
32 const [a, b] = parts;
33 return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a === 127 || a === 0 || (a === 169 && b === 254);
34}
35
36function isPrivateIPv6(ip) {
37 const normalized = ip.toLowerCase();
38 return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:');
39}
40
41export async function normalizeAndValidateUrl(rawUrl) {
42 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
43 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) throw new Error('Only HTTP and HTTPS URLs are supported');
44 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
45 const url = new URL(withScheme);
46 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
47 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
48
49 const literalType = net.isIP(url.hostname);
50 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
51 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
52
53 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
54 for (const record of records) {
55 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
56 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
57 }
58 return url;
59}
60
61function clampInteger(value, fallback, min, max) {
62 const parsed = Number(value);
63 if (!Number.isFinite(parsed)) return fallback;
64 return Math.min(Math.max(Math.trunc(parsed), min), max);
65}
66
67function decodeHtmlEntities(value) {
68 return (value || '').replace(/&/gi, '&').replace(/"/gi, '"').replace(/'|'/gi, "'").replace(/</gi, '<').replace(/>/gi, '>').trim();
69}
70
71function parseAttributes(tag) {
72 const attrs = {};
73 const attrPattern = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("[^"]*"|'[^']*'|[^\s"'>/]+)/g;
74 for (const match of tag.matchAll(attrPattern)) attrs[match[1].toLowerCase()] = decodeHtmlEntities(match[2].replace(/^["']|["']$/g, ''));
75 return attrs;
76}
77
78function resolveUrl(rawRef, baseUrl) {
79 try { return new URL(rawRef, baseUrl); } catch { return null; }
80}
81
82
83
84export function extractInsecureResources(html, baseUrl) {
85 const base = new URL(baseUrl);
86 const pageOrigin = base.origin;
87 const seen = new Set();
88 const resources = [];
89
90 const tagPattern = /<([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\/?>/gi;
91
92 for (const match of html.matchAll(tagPattern)) {
93 const tagLower = match[1].toLowerCase();
94 const tagText = match[0];
95 const attrs = parseAttributes(tagText);
96
97 let isActive = false;
98 let attrName = null;
99 if (ACTIVE_TAGS[tagLower]) {
100 isActive = true;
101 attrName = ACTIVE_TAGS[tagLower].attr;
102 } else if (PASSIVE_TAGS[tagLower]) {
103 isActive = false;
104 attrName = PASSIVE_TAGS[tagLower].attr;
105 } else {
106 continue;
107 }
108
109
110
111 const ref = attrs[attrName];
112 if (!ref) continue;
113 const resolved = resolveUrl(ref, base);
114 if (!resolved) continue;
115 if (!['http:', 'https:'].includes(resolved.protocol)) continue;
116 if (resolved.protocol !== 'http:') continue;
117 if (seen.has(resolved.href)) continue;
118 seen.add(resolved.href);
119 resources.push({
120 tag: tagLower,
121 url: resolved.href,
122 attribute: attrName,
123 active: isActive,
124 sameOrigin: resolved.origin === pageOrigin,
125
126 httpsUrl: resolved.href.replace(/^http:\/\//i, 'https://'),
127 });
128 }
129
130 return resources;
131}
132
133async function fetchHtml(initialUrl, timeoutSeconds, maxHtmlBytes, redirectsRemaining = 3) {
134 await normalizeAndValidateUrl(initialUrl.href);
135 const controller = new AbortController();
136 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
137 try {
138 const response = await fetch(initialUrl, { redirect: 'manual', signal: controller.signal, headers: { 'user-agent': USER_AGENT, accept: 'text/html,application/xhtml+xml,*/*;q=0.1' } });
139 if (REDIRECT_STATUSES.has(response.status)) {
140 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
141 const location = response.headers.get('location');
142 if (!location) throw new Error('Redirect without Location header');
143 return fetchHtml(new URL(location, initialUrl.href), timeoutSeconds, maxHtmlBytes, redirectsRemaining - 1);
144 }
145 const reader = response.body?.getReader();
146 if (!reader) throw new Error('Response body is not readable');
147 const chunks = [];
148 let received = 0;
149 while (true) {
150 const { done, value } = await reader.read();
151 if (done) break;
152 received += value.byteLength;
153 if (received > maxHtmlBytes) throw new Error(`HTML response exceeds ${maxHtmlBytes} byte limit`);
154 chunks.push(value);
155 }
156 return { ok: response.ok, status: response.status, finalUrl: response.url || initialUrl.href, html: Buffer.concat(chunks).toString('utf8'), error: null };
157 } catch (error) {
158 return { ok: false, status: null, finalUrl: initialUrl.href, html: '', error: error.message };
159 } finally {
160 clearTimeout(timeout);
161 }
162}
163
164function scoreResults(page, resources) {
165 if (page.error) return { score: 0, grade: 'F', issues: [page.error], recommendations: ['Verify the page URL is public, reachable, and returns HTML.'] };
166 const active = resources.filter((r) => r.active);
167 const passive = resources.filter((r) => !r.active);
168 let score = 100;
169 const issues = [];
170 const recommendations = [];
171 if (active.length) {
172 score -= Math.min(60, active.length * 12);
173 issues.push(`${active.length} active mixed-content resource(s) loaded over insecure http://`);
174 recommendations.push('Move scripts, stylesheets, iframes, and media to https:// URLs or host them on the same HTTPS origin.');
175 }
176 if (passive.length) {
177 score -= Math.min(25, passive.length * 4);
178 issues.push(`${passive.length} passive mixed-content resource(s) loaded over insecure http://`);
179 recommendations.push('Upgrade images and media to https:// URLs to remove browser warnings.');
180 }
181 if (!resources.length && page.finalUrl.startsWith('https://')) {
182 recommendations.push('No mixed content detected. Continue serving all subresources over HTTPS.');
183 }
184 const bounded = Math.max(0, score);
185 const grade = bounded >= 90 ? 'A' : bounded >= 75 ? 'B' : bounded >= 60 ? 'C' : bounded >= 45 ? 'D' : 'F';
186 return { score: bounded, grade, issues, recommendations };
187}
188
189export async function auditMixedContent(input) {
190 const startUrl = await normalizeAndValidateUrl(input.startUrl);
191 const timeoutSeconds = clampInteger(input.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS, 3, 30);
192 const maxHtmlBytes = clampInteger(input.maxHtmlBytes, DEFAULT_MAX_HTML_BYTES, 100000, MAX_HTML_BYTES);
193 const page = await fetchHtml(startUrl, timeoutSeconds, maxHtmlBytes);
194 const resources = page.error ? [] : extractInsecureResources(page.html, page.finalUrl);
195 const activeCount = resources.filter((r) => r.active).length;
196 const passiveCount = resources.filter((r) => !r.active).length;
197 const scored = scoreResults(page, resources);
198 return {
199 inputUrl: input.startUrl,
200 normalizedInputUrl: startUrl.href,
201 finalUrl: page.finalUrl,
202 ok: !page.error && page.ok && activeCount === 0 && passiveCount === 0,
203 checkedAt: new Date().toISOString(),
204 score: scored.score,
205 grade: scored.grade,
206 activeCount,
207 passiveCount,
208 totalInsecure: resources.length,
209 resources,
210 issues: scored.issues,
211 recommendations: scored.recommendations,
212 error: page.error,
213 };
214}
215
216const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
217
218if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
219 await Actor.init();
220 try {
221 const result = await auditMixedContent(await Actor.getInput() || {});
222 await Actor.pushData(result);
223 await Actor.setValue('OUTPUT', result);
224 Actor.log.info('Mixed content audit complete', { finalUrl: result.finalUrl, active: result.activeCount, passive: result.passiveCount, score: result.score, grade: result.grade });
225 } finally {
226 await Actor.exit();
227 }
228}