fix(gate): refuse IP literals and non-https URLs before probing them - #64
Conversation
The published-surface gate runs on pull_request and fetches every http(s) URL it finds in the README, so a fork could point a link at a loopback, private or link-local address and have the CI runner probe an endpoint the author cannot reach themselves. Every bare IP literal is now refused, v4 and v6, public and private: enumerating ranges means enumerating them again for the next notation, and a link in the documentation points at a hostname. The private ranges keep their own message because naming the range is the more useful diagnosis when a link does hit one. Non-https URLs go the same way. Rejected as reported findings rather than silent skips — a published README linking to a private address is a documentation defect in its own right. The consumer link is created as a junction instead of a directory symlink, which needs elevation or Developer Mode on Windows, and the link-check section header now says what it actually enforces: a bad HTTP status fails, an unreachable host is only a note. release.yml no longer invokes check:published: prepublishOnly already runs it and the step above invokes prepublishOnly, so repeating it re-probed every README link for no new information. URL joins the declared globals for the plain-JavaScript tooling files.
There was a problem hiding this comment.
Pull request overview
This PR hardens the published-surface gate by refusing non-https URLs and documentation links that target IP literals before any network probe occurs, reducing SSRF risk during CI runs on untrusted PRs.
Changes:
- Adds URL pre-validation to the README link checker (rejects IP literals, local names, and non-
httpsURLs). - Adds a timeout to
git fetch --tagsand improves Windows portability for the consumer scaffold symlink. - Removes redundant
pnpm check:publishedinvocation from the release workflow and declaresURLas an ESLint global for tooling scripts.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| scripts/check-published-surface.mjs | Adds URL/host hardening for link probing, bounds tag fetch time, and improves Windows path/symlink handling. |
| eslint.config.mjs | Declares URL as a known global for .mjs/.cjs tooling linting. |
| .github/workflows/release.yml | Avoids re-running check:published since it already runs in prepublishOnly. |
A fully-qualified name keeps its trailing dot through the URL parser, so `localhost.` and `foo.localhost.` compared unequal to the loopback names and reached the fetch — a one-character bypass of the guard. The dot is stripped before the name is compared. 0.0.0.0 was reported as a loopback address. It is the unspecified address; the URL was already refused, only the diagnosis was wrong.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
scripts/check-published-surface.mjs:98
- The message returned for
.localhosts is misleading:.local(mDNS) is not inherently a loopback name, so reporting it as loopback can confuse diagnostics when the gate refuses a.localURL.
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local')) {
return 'is a loopback name'
}
…ectly The guard refused IP literals but not a hostname pointed at one, so a link to a name with a static 169.254.169.254 record still reached the CI runner. Each hostname is resolved first and refused if any address it answers with is loopback, private, link-local or unique-local. This narrows the hole rather than closing it: the fetch resolves the name a second time, so a record that changes between the two calls still gets through. Pinning the resolved address into the connection is more machinery than a documentation gate warrants — a static private record is the case worth stopping, and this stops it. A name that does not resolve is left to the fetch, which reports an unreachable host as a note. .local is mDNS, not loopback, and said so incorrectly.
|
Applied the suppressed comment on |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
scripts/check-published-surface.mjs:146
dns.promises.lookup()is awaited without any timeout. If the runner’s resolver hangs (e.g., broken / slow DNS), one lookup can stallPromise.all(...)indefinitely, blockingprepublishOnly/CI even though the subsequentfetch()probe is time-bounded. Consider bounding the lookup and treating a timeout the same as “could not resolve” (return null) so the gate can continue andfetch()can emit its existing note on unreachable hosts.
let addresses
try {
addresses = await lookup(h, { all: true })
} catch {
return null
}
The fetch carries AbortSignal.timeout, the lookup carried nothing, so a resolver that hangs would stall Promise.all indefinitely and take CI with it. A lookup that cannot answer in five seconds tells us nothing anyway: it falls through to the fetch, which reports an unreachable host as a note rather than failing the gate.
|
Applied the suppressed comment on the unbounded Verified both paths still behave — the DNS rejection still fires ( |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
scripts/check-published-surface.mjs:93
privateAddressReason()treats only the single IPv4 address0.0.0.0as unspecified, and does not classify other addresses in0.0.0.0/8("this network") or the IPv6 unspecified address::. That means a hostname could resolve to e.g.0.0.0.1/::and slip past the resolved-address guard even though those are non-routable/special-use addresses the gate should not probe.
const [a, b] = [Number(v4[1]), Number(v4[2])]
if (a === 127) return 'a loopback address'
if (h === '0.0.0.0') return 'the unspecified address'
if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)) {
return 'a private address'
…0/8 and :: The link section claimed to verify every link in the README but collected only http(s) targets, so a relative link to a renamed file shipped unnoticed — the ones most likely to rot, and the cheapest to verify. Fragments are stripped before the path is tested. A tsx fence was never collected, so a React example importing the package was silently outside the gate. The fence language is kept now and decides the file extension, with jsx enabled in the throwaway consumer. privateAddressReason classified only the single address 0.0.0.0 and missed the rest of 0.0.0.0/8 as well as the IPv6 unspecified address. The lookup promise that loses the timeout race is caught, so a late rejection cannot surface as an unhandled rejection.
|
Applied the suppressed comment on Same commit carries three findings the review raised on the sibling PRs, since the script is shared byte-for-byte across the ten libraries:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
scripts/check-published-surface.mjs:211
- Relative-link validation uses
existsSync(join(ROOT, r)), butpath.join()drops the prefix whenris absolute (e.g./docs/x.md), and..segments can escape the repo root (e.g.../../etc/hostname). That can make the gate report a link as “existing in the repository” while actually probing the runner filesystem outside the checkout.
for (const r of relative) {
if (!existsSync(join(ROOT, r))) fail('links', `${r} does not exist in the repository`)
}
A README target with .. segments resolved out of the repository and had the runner stat arbitrary paths, which matters because this gate runs on pull_request. Targets are resolved and confined first; a leading slash is read as repository-root relative, which is what a reader means by it. A link that escapes the repository is broken documentation either way — the file it names is not in the package.
|
Applied — the
So |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
scripts/check-published-surface.mjs:160
- The DNS preflight timeout uses
Promise.race, but the losinglookup()promise is not cancelled and can keep the Node event loop alive if the resolver hangs. That undermines the “bounded” guarantee and can still stall CI even though the race resolves.
// Bounded like the fetch below is. A resolver that hangs would otherwise
// stall Promise.all indefinitely and take CI with it, and a lookup that
// cannot answer in five seconds tells us nothing anyway. `unref` so a
// pending timer never holds the process open.
addresses = await Promise.race([
Hardening for
scripts/check-published-surface.mjs, found by Copilot while reviewing the same script innest-cache(bymaxone/nest-cache#42) and synced here byte-for-byte.Why
The gate runs on
pull_requestandfetch()es everyhttp(s)URL it finds in the README. Nothing stopped a fork from puttinghttps://169.254.169.254/latest/meta-dataor a loopback address in the README and having the CI runner probe an endpoint the author cannot reach themselves.What changed
Every bare IP literal is refused — v4 and v6, public and private — before any request is made. The first version of this guard enumerated the IPv4 private ranges plus
::1, which still let unique-local (fd00::/8), link-local (fe80::/10) and IPv4-mapped (::ffff:…) literals through. Enumerating ranges means enumerating them again for the next notation, so the class goes instead: a link in the documentation points at a hostname. A URL parser brackets every IPv6 literal, so the brackets are the test — no address notation to recognise. The private ranges keep their own message because naming the range is the more useful diagnosis when a link does hit one. Non-httpsURLs are refused the same way.Rejected as reported findings, not silent skips: a published README linking to a private address is a documentation defect either way.
Red-checked one class at a time, each by appending a link to the README:
Also in this change
'junction'rather than a'dir'symlink. A directory symlink needs elevation or Developer Mode on Windows, which would makeprepublishOnlyunrunnable there; Node ignores the type on POSIX.catcharound thefetchreturnsnull, so a transport error is a note and not a failure — deliberately, since an offline runner is not a defect in the documentation. The header now says exactly that.release.ymlno longer invokespnpm check:published.prepublishOnlyalready runs it and the step above invokespnpm prepublishOnly, so the second run re-probed every README link and recompiled the snippets for no new information — adding time and one more chance of a network flake to the one job that must not fail spuriously. This was flagged earlier and is fixed here alongside the rest.URLjoins the declared globals for the.mjs/.cjstooling files;no-undefonly knows the globals it is told about.No published artifact changes —
scripts/,.github/andeslint.config.mjsare all outsidepackage.json→files, so no version bump.