Skip to content

fix(security): close two ALLOWED_DOMAINS bypasses and add an SSRF guard - #153

Open
manantlerio wants to merge 1 commit into
everywall:mainfrom
manantlerio:fix/allowlist-bypass-and-ssrf
Open

manantlerio wants to merge 1 commit into
everywall:mainfrom
manantlerio:fix/allowlist-bypass-and-ssrf

Conversation

@manantlerio

Copy link
Copy Markdown

Summary

Three related security fixes, with tests. First off, thanks for ladder, it is a
genuinely useful little tool. I read the source before self-hosting it and found
that the ALLOWED_DOMAINS allowlist can be bypassed two different ways, and
that there is no restriction on where a fetch can be pointed. Happy to split
this into separate PRs or drop part 3 if you would rather take these one at a
time.

1. ALLOWED_DOMAINS matched on a prefix

fetchSite checked the host with StringInSlice, which is strings.HasPrefix:

if len(allowedDomains) > 0 && !StringInSlice(u.Host, allowedDomains) {

So ALLOWED_DOMAINS=nytimes.com also allowed nytimes.com.example.net,
which is a hostname anyone can register and point wherever they like. It also
compared against u.Host, so a port defeated an otherwise exact entry.

Confirmed against the current main:

StringInSlice("nytimes.com.evil.tld", []string{"nytimes.com"}) // true

Host matching now goes through domainAllowed, which requires an exact match or
a match on a dot boundary, lowercases both sides, and tolerates a trailing root
dot and a port. StringInSlice is left as it is: it is still the correct prefix
test for rule paths, which is now its only caller.

2. ALLOWED_DOMAINS_RULESET=true allowed everything

RuleSet.Domains() appended rule.Domain unconditionally:

domains = append(domains, rule.Domain)
domains = append(domains, rule.Domains...)

Rules written with only the plural domains: key have an empty Domain field.
Three of the bundled rulesets are written that way (nytimes-com.yaml,
_multi-conde-nast.yaml, _multi-metroland-media-group.yaml), so an empty
string ended up in the allowlist, and strings.HasPrefix(anything, "") is
always true. Turning the option on therefore removed the restriction it was
meant to add. Against current main:

Domains() => ["example.com", "", "www.nytimes.com", "www.time.com", "", ""]

Domains() now skips blanks, and the allowlist ignores blanks independently, so
neither half can quietly allow everything on its own.

The same class of bug applied to an unset ALLOWED_DOMAINS:
strings.Split("", ",") returns []string{""}, which became a match-everything
entry rather than an empty list. It reached the right outcome by accident. It is
now an explicit empty list.

3. No SSRF guard

ladder fetches server-side, so anything that can reach it can ask it to read
http://localhost, the rest of the network it is running on, or a cloud
provider's instance metadata service at 169.254.169.254, and get the response
body back. That matters most in the deployment the compose file encourages: a
container sitting on a network next to other services.

Page fetches now use a transport whose dialer refuses loopback, private,
link-local, CGNAT, multicast and unspecified addresses. The check runs on the
address actually being dialled, immediately before connect, rather than on the
URL as written, so it also covers redirects and hostnames that resolve into a
private range.

ALLOW_PRIVATE_ADDRESSES=true opts out, for people deliberately pointing ladder
at hosts on their own network.

FLARESOLVERR_HOST and remote RULESET loading are deliberately untouched:
those hosts come from the operator's configuration rather than from a request,
and are normally private.

Also

The allowlist is now re-applied on every redirect hop. Previously an allowed
domain could redirect anywhere and only the first request was ever checked. The
chain is bounded at the same 10 the stdlib uses.

Compatibility

  • Behaviour change: fetching private addresses is refused by default. This
    is the one thing in here that could surprise an existing user, and it is
    opt-out with a single env var. Say the word if you would rather it defaulted
    the other way and I will flip it.
  • If you route ladder's traffic through an HTTP proxy on a private address, that
    dial is blocked too, and ALLOW_PRIVATE_ADDRESSES=true is the escape hatch.
    Noted in the README.
  • ALLOWED_DOMAINS now rejects hostnames it used to accept. That is the point,
    but it will look like a change to anyone who was relying on the loose match.
  • No change to the default configuration with ALLOWED_DOMAINS unset.
  • README.zh-CN.md has the same env var table and has not been touched, since I
    do not want to drop English into the translation. Happy to add it if you point
    me at the wording you want.

Tests

go vet, gofmt and go build are clean, and go test ./... passes.

New tests live in handlers/security_test.go rather than being added to the
existing handlers/*.test.go files, because those are named with a dot rather
than an underscore and so are never compiled as tests by go test. Worth a
separate look at some point, since they are not running today.

Both allowlist tests were checked against unpatched main first and fail there,
so they are real regression tests rather than tests written to fit the fix.

…ddresses

The domain allowlist could be bypassed two different ways, and there was no
guard on where a fetch could be pointed. All three are fixed here, with tests.

1. ALLOWED_DOMAINS matched on a prefix

   fetchSite used StringInSlice, which is strings.HasPrefix. An allowlist of
   "nytimes.com" therefore also permitted "nytimes.com.example.net", a hostname
   anyone can register. It also compared against u.Host, so a port defeated an
   exact entry.

   Host matching now goes through domainAllowed, which requires an exact match
   or a match on a dot boundary, lowercases both sides, and tolerates a
   trailing root dot and a port. StringInSlice is left alone: it is still the
   right prefix test for rule paths, which is its only remaining caller.

2. ALLOWED_DOMAINS_RULESET allowed everything

   RuleSet.Domains appended rule.Domain unconditionally, and rules written with
   only the plural `domains:` key have an empty Domain field. Three of the
   bundled rulesets are written that way, so an empty string landed in the
   allowlist, and HasPrefix(anything, "") is true. Turning the option on
   removed the restriction it was supposed to add.

   Domains now skips blank entries, and the allowlist ignores blanks anyway, so
   neither half can silently allow everything on its own. The same applies to
   an unset ALLOWED_DOMAINS: strings.Split("", ",") returns []string{""}, which
   used to become a match-everything entry rather than an empty list.

3. No SSRF guard

   ladder fetches server-side, so anything that can reach it could ask it to
   read http://localhost, the rest of the network it runs on, or a cloud
   provider's instance metadata service at 169.254.169.254 and get the response
   body back. That matters most in the common deployment, a container sitting
   alongside other services.

   Page fetches now use a transport whose dialer refuses loopback, private,
   link-local, CGNAT, multicast and unspecified addresses. The check runs on
   the address actually being dialled, immediately before connect, so it covers
   redirects and hostnames that resolve into a private range rather than only
   the URL as written. ALLOW_PRIVATE_ADDRESSES=true opts out for people
   pointing ladder at their own network on purpose.

   FlareSolverr and remote ruleset loading are deliberately untouched: those
   hosts come from the operator's configuration, not from a request, and are
   normally private.

The allowlist is also re-applied on every redirect hop, which it was not
before, and the redirect chain is bounded at the same 10 the stdlib uses.

Tests are in handlers/security_test.go rather than added to the existing
handlers/*.test.go files, because those are named with a dot rather than an
underscore and so are never picked up by `go test`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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