1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'ChangePasswordWellKnownAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8const WELL_KNOWN_PATH = '/.well-known/change-password';
9
10
11
12
13
14
15
16
17function isPrivateIPv4(ip) {
18 const parts = ip.split('.').map(Number);
19 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
20 const [a, b] = parts;
21 return a === 10
22 || (a === 172 && b >= 16 && b <= 31)
23 || (a === 192 && b === 168)
24 || a === 127
25 || a === 0
26 || (a === 169 && b === 254);
27}
28
29function isPrivateIPv6(ip) {
30 const normalized = ip.toLowerCase();
31 return normalized === '::1'
32 || normalized.startsWith('fc')
33 || normalized.startsWith('fd')
34 || normalized.startsWith('fe80:');
35}
36
37export async function normalizeAndValidateUrl(rawUrl) {
38 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('URL is required');
39 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
40 throw new Error('Only HTTP and HTTPS URLs are supported');
41 }
42 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
43 const url = new URL(withScheme);
44 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
45 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
46
47
48 const hostLiteral = url.hostname.startsWith('[') && url.hostname.endsWith(']')
49 ? url.hostname.slice(1, -1)
50 : url.hostname;
51
52 const literalType = net.isIP(hostLiteral);
53 if (literalType === 4 && isPrivateIPv4(hostLiteral)) throw new Error('Private IPv4 targets are blocked');
54 if (literalType === 6 && isPrivateIPv6(hostLiteral)) throw new Error('Private IPv6 targets are blocked');
55
56 const records = literalType ? [{ address: hostLiteral, family: literalType }] : await dns.lookup(url.hostname, { all: true });
57 for (const record of records) {
58 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
59 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
60 }
61 return url;
62}
63
64
65
66
67
68
69
70
71
72
73async function fetchOnce(targetUrl, timeoutSeconds) {
74 await normalizeAndValidateUrl(targetUrl.href);
75 const controller = new AbortController();
76 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
77 try {
78 const response = await fetch(targetUrl, {
79 redirect: 'manual',
80 signal: controller.signal,
81 headers: { 'user-agent': USER_AGENT, accept: 'text/html,application/xhtml+xml,*/*;q=0.1' },
82 });
83 let bodySample = '';
84 try {
85 const buf = await response.arrayBuffer();
86 bodySample = Buffer.from(buf).toString('utf8', 0, Math.min(buf.byteLength, 2048));
87 } catch { }
88 return {
89 ok: true,
90 status: response.status,
91 headers: response.headers,
92 location: response.headers.get('location'),
93 contentType: response.headers.get('content-type'),
94 bodySample,
95 finalUrl: response.url || targetUrl.href,
96 error: null,
97 };
98 } catch (error) {
99 return {
100 ok: false,
101 status: null,
102 headers: null,
103 location: null,
104 contentType: null,
105 bodySample: '',
106 finalUrl: targetUrl.href,
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
128
129const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
130
131export function classifyWellKnownResponse(fetchResult, wellKnownUrl, redirectLocation) {
132 if (!fetchResult.ok) {
133 return { classification: 'unreachable', isRedirect: false, isDirectServe: false };
134 }
135 if (fetchResult.status === 404 || fetchResult.status === 410) {
136 return { classification: 'not-found', isRedirect: false, isDirectServe: false };
137 }
138 if (REDIRECT_STATUSES.has(fetchResult.status)) {
139 if (!fetchResult.location) {
140 return { classification: 'redirect-missing-location', isRedirect: true, isDirectServe: false };
141 }
142 return { classification: 'redirect', isRedirect: true, isDirectServe: false };
143 }
144 if (fetchResult.status >= 200 && fetchResult.status < 300) {
145 return { classification: 'direct-serve', isRedirect: false, isDirectServe: true };
146 }
147 return { classification: 'unexpected-status', isRedirect: false, isDirectServe: false };
148}
149
150
151
152
153
154export function scoreAudit(analysis) {
155 const issues = [];
156 const recommendations = [];
157 const {
158 classification, https, sameOriginRedirect, redirectIsHttps,
159 redirectTargetReachable, redirectTargetStatus, contentType,
160 } = analysis;
161
162 if (classification === 'unreachable') {
163 issues.push('The well-known URL could not be reached (network error, timeout, or TLS failure).');
164 recommendations.push('Verify the host is reachable over HTTPS and serves /.well-known/change-password.');
165 return finalize(0, issues, recommendations);
166 }
167
168 if (classification === 'not-found') {
169 issues.push('No resource was found at /.well-known/change-password (404/410). The site does not support the well-known change-password convention, so password managers cannot deep-link users to the password-change page.');
170 recommendations.push('Serve /.well-known/change-password as a redirect (HTTP 301/302/307/308 with a Location header) to the site\'s password-change page, per the W3C "A Well-Known URL for Changing Passwords" specification.');
171 return finalize(10, issues, recommendations);
172 }
173
174 if (classification === 'unexpected-status') {
175 issues.push(`The well-known URL returned an unexpected HTTP status (${analysis.status}). Neither a redirect nor a successful direct page was served.`);
176 recommendations.push('Return either a 3xx redirect with Location to the password-change page, or a 200 response that serves the password-change page directly.');
177 return finalize(20, issues, recommendations);
178 }
179
180 if (classification === 'redirect-missing-location') {
181 issues.push('The well-known URL responded with a redirect status code but no Location header. Password managers cannot follow a redirect without one.');
182 recommendations.push('Include a Location header pointing to the password-change page on every 3xx response from /.well-known/change-password.');
183 return finalize(25, issues, recommendations);
184 }
185
186 let score = 0;
187
188 if (classification === 'redirect') {
189 score = 85;
190 if (!https) {
191 score -= 15;
192 issues.push('The well-known URL was requested over plain HTTP. Password managers and the spec expect HTTPS.');
193 recommendations.push('Serve /.well-known/change-password only over HTTPS.');
194 }
195 if (redirectIsHttps === false) {
196 score -= 20;
197 issues.push('The redirect Location points to a plain HTTP URL, downgrading the connection away from HTTPS.');
198 recommendations.push('Point the redirect Location at an HTTPS URL.');
199 }
200 if (sameOriginRedirect === false) {
201 score -= 10;
202 issues.push('The redirect Location points to a different host than the one audited. This is allowed by the spec but is unusual and worth verifying intentionally.');
203 recommendations.push('Confirm the cross-host redirect target is the correct, trusted password-change page for this site.');
204 }
205 if (redirectTargetReachable === false) {
206 score -= 25;
207 issues.push(`Following the redirect target did not return a successful response (status: ${redirectTargetStatus ?? 'unreachable'}). Password managers following the redirect would land on a broken page.`);
208 recommendations.push('Verify the redirect target page itself loads successfully (HTTP 2xx).');
209 }
210 } else if (classification === 'direct-serve') {
211 score = 60;
212 issues.push('The well-known URL returned a 200 response directly instead of redirecting to a password-change page. This deviates from the spec\'s redirect model, though many password managers will still treat a 200 response as a usable landing page.');
213 recommendations.push('Prefer redirecting /.well-known/change-password (3xx + Location) to the canonical password-change page rather than serving content directly, for maximum compatibility with the specification.');
214 if (!https) {
215 score -= 15;
216 issues.push('The well-known URL was requested over plain HTTP.');
217 recommendations.push('Serve /.well-known/change-password only over HTTPS.');
218 }
219 if (contentType && !/text\/html/i.test(contentType)) {
220 score -= 10;
221 issues.push(`The direct response has Content-Type "${contentType}", not an HTML page. Password managers expect to land on a human-usable password-change form.`);
222 recommendations.push('Serve an HTML password-change page at this well-known path.');
223 }
224 }
225
226 return finalize(score, issues, recommendations);
227}
228
229function finalize(score, issues, recommendations) {
230 const bounded = Math.max(0, Math.min(100, score));
231 const grade = bounded >= 95 ? 'A+'
232 : bounded >= 85 ? 'A'
233 : bounded >= 75 ? 'B'
234 : bounded >= 65 ? 'C'
235 : bounded >= 50 ? 'D'
236 : bounded >= 30 ? 'E'
237 : 'F';
238
239 if (bounded >= 85 && issues.length === 0) {
240 recommendations.push('The /.well-known/change-password convention is correctly implemented. Periodically re-check after deploys or identity-provider migrations.');
241 }
242 if (recommendations.length === 0 && issues.length === 0) {
243 recommendations.push('No issues detected.');
244 }
245 return { score: bounded, grade, issues, recommendations };
246}
247
248
249
250
251
252
253
254
255
256export async function auditChangePasswordWellKnown(input) {
257 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
258 const followRedirectTarget = input.followRedirectTarget !== false;
259
260 let startUrl;
261 try {
262 startUrl = await normalizeAndValidateUrl(input.startUrl);
263 } catch (validationError) {
264 return errorResult(input.startUrl, validationError.message);
265 }
266
267 const wellKnownUrl = new URL(WELL_KNOWN_PATH, `${startUrl.protocol}//${startUrl.host}/`);
268
269 let fetched;
270 try {
271 fetched = await fetchOnce(wellKnownUrl, timeoutSeconds);
272 } catch (fetchError) {
273 return errorResult(input.startUrl, fetchError.message, wellKnownUrl.href);
274 }
275
276 if (!fetched.ok) {
277 return errorResult(input.startUrl, fetched.error, wellKnownUrl.href);
278 }
279
280 const { classification, isRedirect, isDirectServe } = classifyWellKnownResponse(fetched, wellKnownUrl, fetched.location);
281
282 let redirectTargetUrl = null;
283 let redirectIsHttps = null;
284 let sameOriginRedirect = null;
285 let redirectTargetReachable = null;
286 let redirectTargetStatus = null;
287 let redirectTargetError = null;
288
289 if (isRedirect && classification === 'redirect' && fetched.location) {
290 try {
291 redirectTargetUrl = new URL(fetched.location, wellKnownUrl.href);
292 redirectIsHttps = redirectTargetUrl.protocol === 'https:';
293 sameOriginRedirect = redirectTargetUrl.host === wellKnownUrl.host;
294
295 if (followRedirectTarget) {
296 const targetFetch = await fetchOnce(redirectTargetUrl, timeoutSeconds);
297 if (targetFetch.ok) {
298 redirectTargetReachable = targetFetch.status >= 200 && targetFetch.status < 400;
299 redirectTargetStatus = targetFetch.status;
300 } else {
301 redirectTargetReachable = false;
302 redirectTargetError = targetFetch.error;
303 }
304 }
305 } catch (targetError) {
306 redirectTargetReachable = false;
307 redirectTargetError = targetError.message;
308 }
309 }
310
311 const analysis = {
312 classification,
313 status: fetched.status,
314 https: wellKnownUrl.protocol === 'https:',
315 sameOriginRedirect,
316 redirectIsHttps,
317 redirectTargetReachable,
318 redirectTargetStatus,
319 contentType: fetched.contentType,
320 };
321
322 const scored = scoreAudit(analysis);
323
324 return {
325 inputUrl: input.startUrl,
326 wellKnownUrl: wellKnownUrl.href,
327 https: analysis.https,
328 ok: true,
329 checkedAt: new Date().toISOString(),
330 httpStatus: fetched.status,
331 classification,
332 isRedirect,
333 isDirectServe,
334 redirectLocation: fetched.location,
335 redirectTargetUrl: redirectTargetUrl ? redirectTargetUrl.href : null,
336 redirectIsHttps,
337 sameOriginRedirect,
338 redirectTargetChecked: followRedirectTarget && isRedirect && classification === 'redirect',
339 redirectTargetReachable,
340 redirectTargetStatus,
341 redirectTargetError,
342 contentType: fetched.contentType,
343 score: scored.score,
344 grade: scored.grade,
345 issues: scored.issues,
346 recommendations: scored.recommendations,
347 error: null,
348 };
349}
350
351function errorResult(inputUrl, errorMsg, wellKnownUrl) {
352 return {
353 inputUrl,
354 wellKnownUrl: wellKnownUrl || null,
355 https: null,
356 ok: false,
357 checkedAt: new Date().toISOString(),
358 httpStatus: null,
359 classification: 'unreachable',
360 isRedirect: false,
361 isDirectServe: false,
362 redirectLocation: null,
363 redirectTargetUrl: null,
364 redirectIsHttps: null,
365 sameOriginRedirect: null,
366 redirectTargetChecked: false,
367 redirectTargetReachable: null,
368 redirectTargetStatus: null,
369 redirectTargetError: null,
370 contentType: null,
371 score: 0,
372 grade: 'F',
373 issues: ['The request failed before the well-known path could be inspected. Verify the URL is reachable and try again.'],
374 recommendations: ['Verify the URL is public, reachable over HTTP/HTTPS, and returns a response.'],
375 error: errorMsg,
376 };
377}
378
379
380
381
382
383const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
384
385if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
386 await Actor.init();
387 try {
388 const input = await Actor.getInput();
389 const result = await auditChangePasswordWellKnown(input || {});
390 await Actor.pushData(result);
391 await Actor.setValue('OUTPUT', result);
392 Actor.log.info('Change-password well-known audit complete', {
393 classification: result.classification,
394 score: result.score,
395 grade: result.grade,
396 });
397 } finally {
398 await Actor.exit();
399 }
400}