Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions scripts/live-promise-spot-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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." }
]
}
];
}
Expand Down
8 changes: 4 additions & 4 deletions scripts/live-promise-spot-check.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) {
Expand Down Expand Up @@ -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"]
);
});

Expand All @@ -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}`);
}
Expand Down Expand Up @@ -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" } });
Expand Down
7 changes: 7 additions & 0 deletions shared/audit-engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject colon-only URI schemes before prepending HTTPS

Fresh evidence in this revision is that the new scheme matcher still requires ://: normalizeUrl("tel:1234") currently returns https://tel:1234/, which publicAuditUrlStatus accepts. Consequently, /api/public-check proceeds through quota accounting and attempts a browser audit instead of returning the intended 400 response; detect the leading scheme: form regardless of whether it includes slashes.

Useful? React with 👍 / 👎.

if (explicitScheme && !/^https?$/i.test(explicitScheme[1])) {
Comment on lines +2724 to +2725

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject schemes that omit the double slash

The scheme check only recognizes forms containing ://, but URI schemes do not universally use that delimiter. For example, tel:1234 bypasses this check, is normalized to https://tel:1234/, and passes publicAuditUrlStatus, so the anonymous check still attempts a browser render and returns a runtime error instead of the intended 400 response. Detect the scheme from the leading name: portion before adding HTTPS.

Useful? React with 👍 / 👎.

throw new Error(`Unsupported URL scheme "${explicitScheme[1]}".`);
}
const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
Comment on lines +2724 to 2728

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- target context ---'
sed -n '2670,2775p' shared/audit-engine.js

printf '%s\n' '--- relevant symbols and call sites ---'
rg -n -C 3 'explicitScheme|withProtocol|new URL|DNS|dns|audit' shared/audit-engine.js

printf '%s\n' '--- related tests and documentation ---'
rg -n -i -C 3 'example\.com:8080|unsupported URL scheme|javascript:|mailto:|data:|URL validation|audit-engine' . \
  -g '!node_modules' -g '!dist' -g '!build'

Repository: nish3451/seo-fix-kit

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- audit-engine URL tests ---'
rg -n -C 5 'normalizeUrl|parseAuditCompetitorUrls|publicAuditUrlStatus|competitorUrls' shared/audit-engine.test.mjs

printf '%s\n' '--- URL guard implementation ---'
sed -n '2770,2830p' shared/audit-engine.js

printf '%s\n' '--- standalone behavior probe ---'
node - <<'JS'
const inputs = [
  "javascript:/alert(1)",
  "mailto:/hello",
  "data:/text/plain",
  "example.com:8080/page",
  "javascript:123",
  "javascript:123/page",
  "https://example.com/page",
  "ftp://example.com/page"
];

const currentScheme = /^([a-z][a-z0-9+.-]*):\\/\\//i;
const currentHttp = /^https?:\\/\\//i;
const proposedHostPort = /^[a-z0-9.-]+:\\d+(?:[/?#]|$)/i;
const proposedScheme = /^([a-z][a-z0-9+.-]*):/i;

for (const input of inputs) {
  const trimmed = input.trim();
  const currentMatch = currentScheme.exec(trimmed);
  const currentRejected = Boolean(currentMatch && !/^https?$/i.test(currentMatch[1]));
  const currentValue = currentRejected ? null : (currentHttp.test(trimmed) ? trimmed : `https://${trimmed}`);
  let currentParsed = null;
  try {
    currentParsed = currentValue && new URL(currentValue);
  } catch {}
  const proposedMatch = proposedScheme.exec(trimmed);
  const proposedRejected = Boolean(
    proposedMatch &&
    !proposedHostPort.test(trimmed) &&
    !/^https?$/i.test(proposedMatch[1])
  );
  const proposedValue = proposedRejected ? null : (currentHttp.test(trimmed) ? trimmed : `https://${trimmed}`);
  let proposedParsed = null;
  try {
    proposedParsed = proposedValue && new URL(proposedValue);
  } catch {}
  console.log(JSON.stringify({
    input,
    current: currentParsed && { href: currentParsed.href, protocol: currentParsed.protocol, hostname: currentParsed.hostname, port: currentParsed.port },
    proposed: proposedRejected ? { rejected: true } : proposedParsed && { href: proposedParsed.href, protocol: proposedParsed.protocol, hostname: proposedParsed.hostname, port: proposedParsed.port }
  }));
}
JS

Repository: nish3451/seo-fix-kit

Length of output: 4512


🏁 Script executed:

#!/bin/bash
set -eu

node - <<'JS'
const inputs = [
  "javascript:/alert(1)",
  "mailto:/hello",
  "data:/text/plain",
  "example.com:8080/page",
  "javascript:123",
  "javascript:123/page",
  "https://example.com/page",
  "ftp://example.com/page"
];

const currentScheme = /^([a-z][a-z0-9+.-]*):\/\//i;
const currentHttp = /^https?:\/\//i;
const proposedHostPort = /^[a-z0-9.-]+:\d+(?:[/?#]|$)/i;
const proposedScheme = /^([a-z][a-z0-9+.-]*):/i;

for (const input of inputs) {
  const trimmed = input.trim();

  const currentMatch = currentScheme.exec(trimmed);
  const currentRejected = Boolean(currentMatch && !/^https?$/i.test(currentMatch[1]));
  const currentValue = currentRejected
    ? null
    : (currentHttp.test(trimmed) ? trimmed : `https://${trimmed}`);
  let currentParsed = null;
  try {
    currentParsed = currentValue && new URL(currentValue);
  } catch {}

  const proposedMatch = proposedScheme.exec(trimmed);
  const proposedRejected = Boolean(
    proposedMatch &&
    !proposedHostPort.test(trimmed) &&
    !/^https?$/i.test(proposedMatch[1])
  );
  const proposedValue = proposedRejected
    ? null
    : (currentHttp.test(trimmed) ? trimmed : `https://${trimmed}`);
  let proposedParsed = null;
  try {
    proposedParsed = proposedValue && new URL(proposedValue);
  } catch {}

  console.log(JSON.stringify({
    input,
    current: currentRejected
      ? { rejected: true, reason: "scheme check" }
      : currentParsed && {
          href: currentParsed.href,
          protocol: currentParsed.protocol,
          hostname: currentParsed.hostname,
          port: currentParsed.port
        },
    proposed: proposedRejected
      ? { rejected: true, reason: "scheme check" }
      : proposedParsed && {
          href: proposedParsed.href,
          protocol: proposedParsed.protocol,
          hostname: proposedParsed.hostname,
          port: proposedParsed.port
        }
  }));
}
JS

Repository: nish3451/seo-fix-kit

Length of output: 1805


Reject non-HTTP schemes before adding https://.

The scheme:// check lets javascript:/alert(1), mailto:/hello, and data:/text/plain become HTTPS URLs for hosts named javascript, mailto, and data. Apply the check to the full scheme: prefix. Keep the example.com:8080/page host-port form, but do not let the exception admit scheme-like inputs such as javascript:123.

🧰 Tools
🪛 OpenGrep (1.26.0)

[ERROR] 2724-2724: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shared/audit-engine.js` around lines 2724 - 2728, Update the URL
normalization logic around explicitScheme and withProtocol to detect any
non-HTTP scheme using the full scheme: prefix, including single-slash and
no-slash forms, before prepending https://. Preserve host:port inputs such as
example.com:8080/page, but ensure the exception does not allow scheme-like
values such as javascript:123.

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;
}
Expand Down
19 changes: 18 additions & 1 deletion shared/audit-engine.test.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
});
Comment on lines +772 to +778

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e

printf '%s\n' '--- candidate files ---'
fd -i 'audit-engine|normalize' . | head -80

printf '%s\n' '--- normalizeUrl definitions and uses ---'
rg -n -C 8 'normalizeUrl|Unsupported URL scheme|embedded credentials' . --glob '!node_modules' --glob '!dist' --glob '!build'

Repository: nish3451/seo-fix-kit

Length of output: 29231


🏁 Script executed:

#!/bin/bash
set -e

printf '%s\n' '--- files ---'
git ls-files | rg '(^|/)(shared/)?audit-engine|normalize|package.json'

printf '%s\n' '--- relevant source ---'
rg -n -C 12 'function normalizeUrl|const normalizeUrl|normalizeUrl\s*=|Unsupported URL scheme|embedded credentials' . \
  --glob '!node_modules' --glob '!dist' --glob '!build'

Repository: nish3451/seo-fix-kit

Length of output: 9796


🏁 Script executed:

#!/bin/bash
set -e
rg -n -C 12 'normalizeUrl|Unsupported URL scheme|embedded credentials' . \
  --glob '!node_modules' --glob '!dist' --glob '!build'

Repository: nish3451/seo-fix-kit

Length of output: 40170


🏁 Script executed:

#!/bin/bash
set -e

node <<'JS'
function currentNormalizeUrl(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;
}

for (const value of [
  "ftp://example.com",
  "javascript://example.com/x",
  "mailto:hello@example.com",
  "mailto:/hello",
  "javascript:/alert(1)",
  "https://user:pass@example.com/",
  "https://user@example.com/"
]) {
  try {
    console.log(JSON.stringify({ value, result: currentNormalizeUrl(value) }));
  } catch (error) {
    console.log(JSON.stringify({ value, error: error.message }));
  }
}
JS

Repository: nish3451/seo-fix-kit

Length of output: 746


Reject explicit non-HTTP schemes before adding https://. Extend scheme detection to cover mailto: and single-slash forms, while preserving example.com:8080/page as a host with a port. Expect Unsupported URL scheme "mailto" and add cases for mailto:/hello and javascript:/alert(1).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shared/audit-engine.test.mjs` around lines 772 - 778, Update normalizeUrl’s
scheme detection to reject explicit non-HTTP schemes, including mailto and
single-slash forms, before prepending https://; emit Unsupported URL scheme
"<scheme>" for these inputs. Preserve example.com:8080/page as a host with a
port, and extend the normalizeUrl tests with mailto:, mailto:/hello, and
javascript:/alert(1) cases.

2 changes: 1 addition & 1 deletion worker/routes/public-check.js
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ export function checkHtml(origin) {
<form class="check-form" id="check-form" aria-label="One-page URL check">
<label for="url-input">Public page URL</label>
<div class="row">
<input id="url-input" name="url" type="url" inputmode="url" autocomplete="off" placeholder="https://example.com/about" required />
<input id="url-input" name="url" type="text" inputmode="url" autocomplete="off" placeholder="https://example.com/about" required />
<button id="check-button" type="submit">Check this page</button>
</div>
<p class="form-note">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.</p>
Expand Down
13 changes: 13 additions & 0 deletions worker/routes/public-check.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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/);
Expand Down
Loading