Skip to content

fix(target-url): treat trailing-dot hostnames as loopback#37

Open
lxcario wants to merge 1 commit into
TestSprite:mainfrom
lxcario:fix/target-url-trailing-dot
Open

fix(target-url): treat trailing-dot hostnames as loopback#37
lxcario wants to merge 1 commit into
TestSprite:mainfrom
lxcario:fix/target-url-trailing-dot

Conversation

@lxcario

@lxcario lxcario commented Jun 25, 2026

Copy link
Copy Markdown

What

assertNotLocal (the --target-url pre-flight guard) rejects http://localhost but accepts http://localhost. — the trailing-dot (fully-qualified) form of the same loopback name.

Why this matters

localhost. resolves to the loopback address just like localhost, so test create --target-url http://localhost. / test run --target-url … passes the guard when the equivalent http://localhost is correctly rejected. The guard's localhost special-case is simply incomplete for the FQDN form.

Reproduction

http://localhost       -> REJECTED  (correct)
http://localhost.      -> ACCEPTED  (bug — trailing-dot loopback)
http://localhost.:8080 -> ACCEPTED  (bug)
http://localhost%2e    -> ACCEPTED  (bug — decodes to localhost.)
http://127.0.0.1.      -> REJECTED  (control — WHATWG URL normalizes IP literals, so numeric forms are already handled)

Reproduced directly against assertNotLocal. The http://127.0.0.1. control shows the numeric-IP path is already fine; only the named-host trailing-dot form slips through.

Root cause

src/lib/target-url.ts, assertNotLocal:

const host = parsed.hostname.toLowerCase();
...
if (host === 'localhost' || host === '0.0.0.0') { ... }

The WHATWG URL parser dot-normalizes IP literals but keeps the trailing dot on named hosts, so localhost. never equals localhost.

Fix

Strip a single trailing dot from the hostname before the comparison:

const host = parsed.hostname.toLowerCase().replace(/\.$/, '');

IP literals are unaffected (already dot-normalized by new URL()); only named hosts change.

Tests

Added 4 tests to src/lib/target-url.spec.ts: 3 trailing-dot loopback variants (localhost., localhost.:8080, localhost%2e) now blocked, plus 1 no-false-positive check that a legitimate public FQDN with a trailing dot (https://example.com.) is still allowed. The 3 "blocked" tests fail on main before this fix and pass after.

  • Before (main): Tests 16 failed | 1359 passed | 72 skipped
  • After (this branch): Tests 16 failed | 1363 passed | 72 skipped (+4 new tests)

The 16 failures are pre-existing and environment-specific (Windows path/line-ending), unrelated to this change — see #4.

Verification

  • npm test: same 16 pre-existing (environment-specific) failures as baseline, zero new
  • npm run typecheck: pass
  • npm run lint: pass

Note on scope

assertNotLocal is documented as a best-effort client-side pre-flight check, not the security boundary (the backend enforces that). This PR doesn't change that posture — it just closes a gap in the guard's own existing special-case for localhost, since localhost. (trailing dot, RFC 952 / 1123 FQDN form) resolves to the same loopback address but wasn't being normalized before the comparison. Happy to close this if the maintainers consider trailing-dot normalization out of scope for this guard.

assertNotLocal lowercased the hostname but did not strip a trailing dot, so http://localhost. (the FQDN form of localhost, RFC 6761) and http://localhost%2e bypassed the host === 'localhost' loopback check. IP literals are already dot-normalized by the WHATWG URL parser, so only named hosts were affected. Strips one trailing dot before the comparison. Adds 4 regression tests (3 blocked variants + 1 public-FQDN no-false-positive).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant