From c24a4b3208343039cd39f35955545c8a6c3a5eea Mon Sep 17 00:00:00 2001 From: Nish <257724087+nish3451@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:24:19 +0530 Subject: [PATCH] fix: reject non-http URL schemes on the anonymous check instead of mangling them The public one-page check normalized any non-http scheme into garbage instead of rejecting it: ftp://example.com became https://ftp//example.com (422 with a cryptic net::ERR_CONNECTION_RESET error) and mailto:hello@example.com silently audited example.com with a mangled displayed URL. The shared normalizeUrl also accepted embedded credentials. Every surface that uses normalizeUrl (public check, audits, developer API, local server) now rejects unsupported schemes and credential-bearing URLs with the standard invalid-URL error. The /check form input type=url also blocked scheme-less entries ("example.com") client-side with no message even though the server normalizes them; the input now accepts plain text with inputmode=url so the server's normalization runs. - normalizeUrl throws on explicit non-http(s) schemes and embedded credentials - /check input is type=text inputmode=url, pinned in route tests - unit pins for normalizeUrl and route pins for validatePublicCheckUrl - live-promise spot-check gains a scheme-rejection probe (red against the currently deployed Worker, green after deploy) --- scripts/live-promise-spot-check.mjs | 11 +++++++++++ scripts/live-promise-spot-check.test.mjs | 8 ++++---- shared/audit-engine.js | 7 +++++++ shared/audit-engine.test.mjs | 19 ++++++++++++++++++- worker/routes/public-check.js | 2 +- worker/routes/public-check.test.mjs | 13 +++++++++++++ 6 files changed, 54 insertions(+), 6 deletions(-) diff --git a/scripts/live-promise-spot-check.mjs b/scripts/live-promise-spot-check.mjs index b0cc43a9..ae958d52 100644 --- a/scripts/live-promise-spot-check.mjs +++ b/scripts/live-promise-spot-check.mjs @@ -284,6 +284,17 @@ export function publicSurfaceSpotChecks(baseUrl) { expectations: [ { reason: "route rejects invalid input with a JSON error", match: '"error"' } ] + }, + { + path: "/api/public-check", + name: "anonymous one-page check route rejects non-http URL schemes instead of mangling them", + method: "POST", + body: JSON.stringify({ url: "ftp://example.com" }), + acceptStatuses: [400], + contentType: "application/json", + expectations: [ + { reason: "route rejects unsupported schemes before any browser render", match: "Enter a valid public website URL." } + ] } ]; } diff --git a/scripts/live-promise-spot-check.test.mjs b/scripts/live-promise-spot-check.test.mjs index 6da242da..1e6e8d6c 100644 --- a/scripts/live-promise-spot-check.test.mjs +++ b/scripts/live-promise-spot-check.test.mjs @@ -39,7 +39,7 @@ const surfaces = { version: "0.9.0", scope: "runtime_config_and_schema_readiness" }), - "/api/public-check": () => jsonBody({ error: "Enter a valid public http(s) URL." }, 400) + "/api/public-check": () => jsonBody({ error: "Enter a valid public website URL." }, 400) }; function pageFetcher(overrides = {}) { @@ -84,7 +84,7 @@ test("live spot-check covers the seven promised public pages", () => { test("live spot-check covers the public machine surfaces", () => { assert.deepEqual( publicSurfaceSpotChecks(origin).map((check) => check.path), - ["/llms.txt", "/sitemap.xml", "/robots.txt", "/api/health", "/api/deep-health", "/api/public-check"] + ["/llms.txt", "/sitemap.xml", "/robots.txt", "/api/health", "/api/deep-health", "/api/public-check", "/api/public-check"] ); }); @@ -101,7 +101,7 @@ test("live spot-check covers the www-to-apex canonical redirect", () => { test("live spot-check passes against the shipped public page copy", async () => { const results = await spotCheckPublicPages({ baseUrl: origin, fetcher: pageFetcher() }); - assert.equal(results.length, 15); + assert.equal(results.length, 16); for (const result of results) { assert.deepEqual(result.failures, [], `${result.path} must pass: ${result.name}`); } @@ -131,7 +131,7 @@ test("live spot-check flags a stale Worker serving the SPA fallback", async () = const url = new URL(rawUrl); const method = options.method || "GET"; if (method === "POST") { - return jsonBody({ error: "Enter a valid public http(s) URL." }, 400); + return jsonBody({ error: "Enter a valid public website URL." }, 400); } if (url.pathname === "/check") { return new Response(spaShell, { headers: { "content-type": "text/html" } }); diff --git a/shared/audit-engine.js b/shared/audit-engine.js index 4fd1c45b..f098355d 100644 --- a/shared/audit-engine.js +++ b/shared/audit-engine.js @@ -2721,8 +2721,15 @@ function absolute(value, base) { export function normalizeUrl(input) { const trimmed = String(input || "").trim(); + const explicitScheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(trimmed); + if (explicitScheme && !/^https?$/i.test(explicitScheme[1])) { + throw new Error(`Unsupported URL scheme "${explicitScheme[1]}".`); + } const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; const url = new URL(withProtocol); + if (url.username || url.password) { + throw new Error("URLs with embedded credentials are not supported."); + } url.hash = ""; return url.href; } diff --git a/shared/audit-engine.test.mjs b/shared/audit-engine.test.mjs index 8f33ddbc..5ed19bc0 100644 --- a/shared/audit-engine.test.mjs +++ b/shared/audit-engine.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { createAuditEngine, isThrottledResource } from "./audit-engine.js"; +import { createAuditEngine, isThrottledResource, normalizeUrl } from "./audit-engine.js"; import { buildBacklinkAudit } from "./backlink-audit.js"; import { buildCrawlInventory } from "./crawl-inventory.js"; import { buildLocalSeoAudit } from "./local-seo-audit.js"; @@ -759,3 +759,20 @@ test("rendered-vs-static guards stay truthful for the live script-rendered fixtu `expected an internal-links guard, got: ${guardTitles.join(" | ")}` ); }); + +test("normalizeUrl adds https to scheme-less input and keeps valid http(s) URLs intact", () => { + assert.equal(normalizeUrl("example.com/about?q=1"), "https://example.com/about?q=1"); + assert.equal(normalizeUrl(" example.com "), "https://example.com/"); + assert.equal(normalizeUrl("//example.com/about"), "https://example.com/about"); + assert.equal(normalizeUrl("https://example.com/a?b=c#frag"), "https://example.com/a?b=c"); + assert.equal(normalizeUrl("HTTP://EXAMPLE.COM/a"), "http://example.com/a"); + assert.equal(normalizeUrl("example.com:8080/page"), "https://example.com:8080/page"); +}); + +test("normalizeUrl rejects non-http schemes and embedded credentials instead of mangling them", () => { + assert.throws(() => normalizeUrl("ftp://example.com"), /Unsupported URL scheme "ftp"/); + assert.throws(() => normalizeUrl("javascript://example.com/x"), /Unsupported URL scheme "javascript"/); + assert.throws(() => normalizeUrl("mailto:hello@example.com"), /embedded credentials/i); + assert.throws(() => normalizeUrl("https://user:pass@example.com/"), /embedded credentials/i); + assert.throws(() => normalizeUrl("https://user@example.com/"), /embedded credentials/i); +}); diff --git a/worker/routes/public-check.js b/worker/routes/public-check.js index 68347e4d..9d332491 100644 --- a/worker/routes/public-check.js +++ b/worker/routes/public-check.js @@ -266,7 +266,7 @@ export function checkHtml(origin) {
- +

One page per check. Rate-limited per network and per site to protect the service and the pages it checks. Use only pages you own or are authorized to audit.

diff --git a/worker/routes/public-check.test.mjs b/worker/routes/public-check.test.mjs index c8c911dd..be6e505e 100644 --- a/worker/routes/public-check.test.mjs +++ b/worker/routes/public-check.test.mjs @@ -21,10 +21,18 @@ test("public check URL validation rejects malformed and private targets", () => assert.equal(validatePublicCheckUrl("http://[::1]/").ok, false); assert.equal(validatePublicCheckUrl("http://my-site.local/").ok, false); assert.equal(validatePublicCheckUrl("https://my-site.internal/").ok, false); + assert.equal(validatePublicCheckUrl("ftp://example.com/").ok, false); + assert.equal(validatePublicCheckUrl("ftp://example.com/file.pdf").ok, false); + assert.equal(validatePublicCheckUrl("mailto:hello@example.com").ok, false); + assert.equal(validatePublicCheckUrl("https://user:pass@example.com/").ok, false); const ok = validatePublicCheckUrl("example.com/about?q=1"); assert.equal(ok.ok, true); assert.equal(ok.url, "https://example.com/about?q=1"); + + const protocolRelative = validatePublicCheckUrl("//example.com/about"); + assert.equal(protocolRelative.ok, true); + assert.equal(protocolRelative.url, "https://example.com/about"); }); test("public check quota buckets cover per-network and per-site windows", () => { @@ -62,6 +70,11 @@ test("public check page is searchable and hands off into private access", () => assert.ok(visibleWordCount(html) >= 250, "check page should not look thin to rendered audits"); assert.match(html, /rel="canonical" href="https:\/\/seofixkit\.com\/check"/); assert.match(html, /id="check-form"/); + assert.match( + html, + /id="url-input" name="url" type="text" inputmode="url"/, + "the URL input must not block scheme-less entries client-side" + ); assert.match(html, /https:\/\/seofixkit\.com\/api\/public-check/); assert.match(html, /nothing about your check is stored/i); assert.match(html, /Request private access/);