Skip to content

fix(rulesengine): match domains case-insensitively and ignore a trailing dot - #217

Open
a-palamarchuk wants to merge 1 commit into
coder:mainfrom
a-palamarchuk:fix/host-match-case-trailing-dot
Open

a-palamarchuk wants to merge 1 commit into
coder:mainfrom
a-palamarchuk:fix/host-match-case-trailing-dot

Conversation

@a-palamarchuk

Copy link
Copy Markdown

Problem

domain= rules compare host labels byte for byte (rulesengine/engine.go, matches). DNS names are case-insensitive (RFC 4343), and a trailing dot only marks a name as fully qualified, so these all name the same host but are treated differently:

Rule Request host Result on main
domain=github.com GitHub.com denied
domain=github.com github.com. denied
domain=GitHub.com github.com denied: the rule parses but never matches lowercase traffic
domain=*.github.com API.github.com allowed, because * matches any case while literal labels don't

This fails closed, so it's a usability problem, not a bypass. But users see 403s for hosts they did allow, and a rule typed with capitals silently does nothing.
Since #201, inject targets use the same matcher, so --session-id-inject-target also became case-sensitive. #201's table lists this as a side effect of unifying the two matchers.

A related edge case in the same code: * matches an empty label, so domain=*.github.com matches .github.com and a..github.com. Those names don't resolve (Go's resolver rejects them), so this isn't exploitable, but a wildcard shouldn't match an invalid name.

Change

  • matches normalizes the request host before splitting it into labels: it removes one trailing dot and lowercases ASCII letters.
  • Rule labels are compared without regard to ASCII case. The parser is unchanged, so parsed rules keep their original spelling and the grammar doesn't change. domain=github.com. is still rejected at parse time.
  • A host with an empty label no longer matches any host pattern. As a result, domain=* no longer matches a request with an empty host; on main it matched, and the forward then failed.
  • Case folding is ASCII-only, through a small toLowerASCII helper. strings.ToLower and strings.EqualFold use Unicode folding, which maps U+212A KELVIN SIGN to k. domain=kubernetes.io would then match Kubernetes.io (written with the Kelvin sign), a name that resolves differently or not at all.
  • docs/architecture.md policy model: one new bullet.

Paths and methods stay case-sensitive (RFC 3986 and RFC 9110). Inject targets become case-insensitive again, as they were before #201.

Testing

New cases in TestRoundTripExtraRules, which parse a real rule string and evaluate a URL:

  • Match: an uppercase request host, an uppercase rule, and a trailing dot on an exact domain and on a wildcard subdomain.
  • No match: a trailing dot on the base domain against *.github.com, two trailing dots, empty leading and inner labels, a Kelvin-sign lookalike, and a path that differs only in case.

The four match cases and the two empty-label cases fail on main. The other four no-match cases guard existing behavior and pass both before and after.

End to end with landjail and --allow 'domain=example.com'. The same script ran against a main build and a build of this branch; the outputs are placed side by side:

                             main        this branch
  http://example.com/        status=200  status=200
  http://Example.com/        status=403  status=200
  http://EXAMPLE.COM/        status=403  status=200
  http://example.com./       status=403  status=200
  https://example.com/       status=200  status=200
  https://Example.com/       status=403  status=200
  https://example.com./      status=200  status=200

The last row passes on main only because curl drops the trailing dot from Host for HTTPS. Other clients may not.

AI disclosure

I used Claude Code to investigate the matcher and draft the fix and tests. I reviewed the diff and ran the tests and the reproduction above myself.

…ing dot

Domain rules compared host labels byte for byte, so `domain=github.com`
denied requests for `GitHub.com` or `github.com.`, and a rule written as
`domain=GitHub.com` never matched lowercase traffic. All of these are the
same DNS name. A `*` label also matched an empty label, so
`domain=*.github.com` matched `.github.com` and `a..github.com`.

Before matching, remove one trailing dot from the request host and
lowercase ASCII letters on both sides. Folding is ASCII-only (RFC 4343):
Unicode folding would map U+212A KELVIN SIGN to "k" and let a rule match
a host that resolves to a different name. Hosts with an empty label no
longer match. Paths and methods stay case-sensitive, and the rule grammar
is unchanged.

Co-Authored-By: Claude Opus 5.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