fix(auth): reject SQL LIKE metacharacters in ForwardAuth email claim#18
Merged
Merged
Conversation
The ForwardAuth (AUTH_TYPE=SSO) branch of validateAuthentication looked
users up with User.findOne({ where: { email: { [Op.iLike]: email } } }).
ILIKE treats % and _ in the supplied value as wildcards, so a request
with "x-auth-request-email: %@%.%" produces ILIKE '%@%.%' — which
matches every row. The first matched user (typically the bootstrap
admin) was then issued a 3-month JWT cookie.
Switching to exact match (where: { email }) strips all special meaning
from those characters. Email is already .toLowerCase().trim()'d before
the lookup, matching the existing User.findByEmail pattern; emails are
stored canonically lowercased so case-insensitive matching is not
required.
Not exploitable in production: oauth2-proxy intercepts unauthenticated
requests before they reach Outline and overwrites x-auth-request-*
headers from the verified OIDC identity. Defense-in-depth fix at the
code level in case backend reachability ever changes.
Adds a regression test asserting wildcard input does not impersonate
an existing user.
2267e00 to
7582a00
Compare
awais786
added a commit
that referenced
this pull request
May 16, 2026
The test fed `%@%.%` to assert that SQL wildcard characters in the proxy email header don't match existing users via Op.iLike. But that input isn't a syntactically valid email — Sequelize's isEmail validator on the User model rejects it before User.create runs, so the test crashes with SequelizeValidationError on the "provision new user" path rather than reaching its assertions. Switch to `attacker%_@evil.example.com` — RFC-5321 allows `%` and `_` in the local part so isEmail accepts it, and the `%` is still a SQL LIKE wildcard that would match other users under Op.iLike. The security property under test (exact-match lookup, not Op.iLike) is preserved; the test now actually exercises both the lookup miss AND the new-user provisioning that follows. Also adds a positive assertion that the provisioned user's email is the exact wildcard string — verifying we didn't resolve to a pattern-matched existing user. This was a pre-existing flake on test-server shard 3 that was masked by parallel-shard cancellation; the test never actually passed since PR #18 added it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Summary
Closes a SQL
LIKEwildcard match in the ForwardAuth (AUTH_TYPE=SSO) branch ofvalidateAuthenticationby switching the user-lookup fromOp.iLiketo exact match.Root cause
The lookup used
User.findOne({ where: { email: { [Op.iLike]: email } } }).ILIKEtreats%and_in the supplied value as wildcards, sox-auth-request-email: %@%.%producesILIKE '%@%.%'which matches every row. The first matched user (typically the bootstrap admin) is then issued a 3-month JWT cookie.Fix
user = await User.scope("withTeam").findOne({ - where: { - email: { [Op.iLike]: email }, - }, + where: { email }, });Exact match strips all special meaning from
%and_— they become ordinary characters andwhere: { email: "%@%.%" }finds nobody. Email is already.toLowerCase().trim()'d before the lookup, matching the existingUser.findByEmailpattern; emails are stored canonically lowercased so case-insensitive matching isn't required.Reachability in production
Verified the bug is not exploitable from the public internet against
docs.foss.arbisoft.com:POST /api/auth.infowithx-auth-request-email: %@%.%and no session returnsHTTP/2 302redirecting to Cognito. oauth2-proxy intercepts unauthenticated requests before they reach Outline, and overwritesx-auth-request-*headers from the verified OIDC identity for authenticated requests.This is a defense-in-depth fix at the code level. If the backend is ever directly reachable (k8s NetworkPolicy change, dev environment, internal pivot) or oauth2-proxy is replaced, the code is no longer the failure point.
Test plan
yarn test server/middlewares/authentication.test.ts— new regression test asserts wildcard input does not impersonate an existing user; all existing ForwardAuth tests still pass (Cognito numeric IDs, real emails, bare-username +DEFAULT_EMAIL_DOMAINcoercion).Notes / out of scope