Skip to content

A dot is not a hostname - #148

Merged
ralyodio merged 1 commit into
mainfrom
reject-unhostlike-urls
Aug 21, 2026
Merged

A dot is not a hostname#148
ralyodio merged 1 commit into
mainfrom
reject-unhostlike-urls

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

normalizeUrl guarded the host with hostname.includes('.'). new URL() is far more permissive about hosts than DNS is — quotes, parens, = and & are not forbidden host code points — so all of these parsed, all contained a dot, and all became feeds:

pasted token stored as
version="1.0" https://version="1.0"/
text="gg.deals https://text="gg.deals/
zombies.) https://zombies.)/
z. https://z./

~3,700 such rows are in production. The bulk-upload scanner splits pasted text on whitespace and offers every token as a URL, so pasting raw OPML instead of a URL list fed the markup itself in — XML attributes, and sentences whose last word ended in a full stop. Each is then re-crawled forever on a cadence and fails blocked-host; they were most of a 960/hour spike in that error.

Why the check goes here

normalizeUrl is the one gate every entry path shares — web submit, OPML import, the queue drain, discovery — so one check covers all of them. The scanner itself can't do it: it runs in the browser, and importing @rssamplifier/feed into client code fails the build on node:dns.

Scope

It rejects what cannot be a hostname, not what is merely unusual. IDN is already punycode by then; a port and a dotted quad still pass; private ranges go on being refused later by isPublicHost. The second test is the half that would break the directory if this were too strict, so it's spelled out explicitly.

Full workspace suite green (11 packages, 0 failures).

Note: this stops new junk. The ~3,700 existing rows are a separate cleanup.

🤖 Generated with Claude Code

`normalizeUrl` guarded the host with `hostname.includes('.')`, and `new URL()`
is far more permissive about hosts than DNS is: quotes, parens, `=` and `&` are
not forbidden host code points. So `version="1.0"`, `text="gg.deals`, `zombies.)`
and `z.` all parsed, all contained a dot, and all became feeds.

That is how ~3,700 rows like `https://version="1.0"/` and `https://zombies.)/`
reached the directory. The bulk-upload scanner splits pasted text on whitespace
and offers every token as a URL, so pasting raw OPML instead of a URL list fed
the markup itself in -- XML attributes, and sentences whose last word ended in a
full stop. Each one is then re-crawled forever on a cadence and fails
`blocked-host`; they were most of a 960/hour spike in that error.

The check goes in `normalizeUrl` rather than in the scanner because it is the
one gate every entry path shares -- web submit, OPML import, the queue drain and
discovery -- and because the scanner runs in the browser, where importing
`@rssamplifier/feed` fails the build on `node:dns`.

It rejects what cannot be a hostname, not what is merely unusual: IDN is already
punycode by then, a port and a dotted quad still pass, and private ranges go on
being refused later by `isPublicHost`. The second test is the half that would
break the directory if this were too strict, so it is spelled out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio merged commit 9eddf78 into main Aug 21, 2026
3 checks passed
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