diff --git a/scripts/live-promise-spot-check.mjs b/scripts/live-promise-spot-check.mjs index 635d52a8..b02a3ecd 100644 --- a/scripts/live-promise-spot-check.mjs +++ b/scripts/live-promise-spot-check.mjs @@ -291,6 +291,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 2079c871..f9ebe72f 100644 --- a/worker/routes/public-check.js +++ b/worker/routes/public-check.js @@ -271,7 +271,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 7d9f4177..16391c8c 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 without storing the target host", async () => { @@ -77,6 +85,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, /no report or URL is stored/i); assert.match(html, /short-lived anonymous rate-limit counters/i);