Repository navigation
Add opt-in reverse-proxy header auth (e.g. OpenHost SSO) - #2060
CarlKho-Minerva wants to merge 3 commits into
Conversation
| TRUSTED_PROXY_AUTH_EMAIL: z | ||
| .string() | ||
| .optional() |
There was a problem hiding this comment.
Since this is expected to be an email address, adding .email() would catch a malformed value at startup (e.g. a username instead of an email) instead of letting it silently fail at runtime when the DB lookup returns no rows.
| TRUSTED_PROXY_AUTH_EMAIL: z | |
| .string() | |
| .optional() | |
| TRUSTED_PROXY_AUTH_EMAIL: z | |
| .string() | |
| .email() | |
| .optional() |
| if ( | ||
| serverEnv().TRUSTED_PROXY_AUTH_HEADER && | ||
| serverEnv().TRUSTED_PROXY_AUTH_EMAIL | ||
| ) { | ||
| console.warn( | ||
| "[auth] trusted-proxy sign-in is ENABLED. Only run this behind a reverse proxy that strips client-sent copies of the trust header, or a login bypass is possible.", | ||
| ); | ||
| } |
There was a problem hiding this comment.
This console.warn lives inside the providers getter, but NextAuth's getServerSession builds its options with Object.assign({}, authOptions(), { providers: [] }), which invokes that getter. authOptions() is reconstructed on every getSession()/getCurrentUser() call (packages/database/auth/session.ts, packages/web-backend/src/Auth.ts), so _providers is undefined each time and the warning fires on every authenticated request rather than once at boot. A security warning that logs per-request becomes noise operators tune out. Guarding it with a module-level flag (e.g. let _trustedProxyWarned = false) checked here would make it fire once.
| ...(serverEnv().TRUSTED_PROXY_AUTH_HEADER && | ||
| serverEnv().TRUSTED_PROXY_AUTH_EMAIL |
There was a problem hiding this comment.
This opt-in condition is the security boundary for the provider. apps/web/__tests__/unit/auth-options.test.ts already tests the same enable/partially-configured pattern for Apple — a parallel case asserting trusted-proxy is registered only when both TRUSTED_PROXY_AUTH_HEADER and TRUSTED_PROXY_AUTH_EMAIL are set (and absent otherwise) would lock this in and catch a regression that always registers the provider.
|
Thanks. This now conflicts with main's auth-options.ts. Please rebase, add a test for the enabled and partially configured cases like the existing Apple provider test, and re-trigger Greptile. We need Greptile 5/5 on the latest commit and green CI before we review. |
Lets self-hosters who front Cap with an authenticating reverse proxy sign in via
that proxy instead of Cap's email code. A CredentialsProvider is added to the
providers array only when TRUSTED_PROXY_AUTH_HEADER and TRUSTED_PROXY_AUTH_EMAIL
are both set; it signs in the one configured account when the trusted header
equals "true", and fails closed otherwise. Same opt-in pattern as Grafana
auth.proxy / Gitea reverse-proxy auth.
Hardening:
- Optional TRUSTED_PROXY_AUTH_SECRET, compared in constant time and length-blind
(sha256 both sides, then timingSafeEqual), so a request reaching Cap outside the
proxy can't spoof the header.
- A boot-time console.warn whenever the provider is enabled.
Works with Cap's existing jwt/session callbacks unchanged — the jwt callback
already re-fetches by token.email and stamps id + sessionVersion. Typechecked
against next-auth 4.24.5 under --strict.
Trigger via signIn("trusted-proxy"); a login-page button is a small follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per tembo's review: add .email() so a malformed value (e.g. a bare username) fails loudly at startup instead of silently returning no DB rows at runtime. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…est opt-in - Gate the trusted-proxy warning at module scope; authOptions() is rebuilt per request, so the per-instance warning logged on every session lookup. - Replace the env non-null assertions in authorize with a fail-closed guard. - Test that the provider registers only when both TRUSTED_PROXY_AUTH_HEADER and TRUSTED_PROXY_AUTH_EMAIL are set, mirroring the Apple provider test, plus authorize's fail-closed paths and the once-per-process warning.
ab22dc9 to
e13b79e
Compare
| // Defense-in-depth: when a shared secret is configured, the proxy | ||
| // must also send it, so a request that reaches Cap outside the | ||
| // proxy can't spoof the header. Constant-time and length-blind. | ||
| const secret = env.TRUSTED_PROXY_AUTH_SECRET; |
There was a problem hiding this comment.
P1: The trusted-proxy provider permits account takeover when its optional shared secret is unset.
With the shared secret unset, a caller can send the trusted header directly and receive a session for the configured account.
Require the proxy secret whenever this provider is enabled, and keep the Cap origin inaccessible except through the proxy.
AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.
<file name="packages/database/auth/auth-options.ts">
<violation number="1" location="packages/database/auth/auth-options.ts:178">
<priority>P1</priority>
<title>The trusted-proxy provider permits account takeover when its optional shared secret is unset.</title>
<evidence>When TRUSTED_PROXY_AUTH_HEADER and TRUSTED_PROXY_AUTH_EMAIL are set, the new CredentialsProvider is registered (lines 153–165). Its authorize callback accepts the configured header value at line 173, but the check for x-trusted-proxy-secret runs only when TRUSTED_PROXY_AUTH_SECRET is truthy (lines 178–188); with the secret unset, a request carrying the assertion proceeds to load and return the configured existing user's identity (lines 190–207). NextAuth v4's Credentials provider calls authorize with credentials submitted via HTTP POST and treats a returned user as a successful sign-in (official v4 docs: https://next-auth.js.org/providers/credentials). The web route dispatches non-WorkOS auth requests directly to NextAuth (apps/web/app/api/auth/[...nextauth]/route.ts:78–80). The repository's standard docker-compose.yml publishes the web container port on the host (lines 35–36), so when this opt-in mode is used with that published origin reachable alongside a reverse proxy, an unauthenticated remote caller can send the trusted assertion directly to Cap and sign in as the configured account unless the optional secret is configured. The lockfile resolves next-auth to 4.24.14. In the base implementation, auth-options.ts had no CredentialsProvider and only the pre-existing email/OAuth/WorkOS authentication routes; this newly added route lets a caller authenticate without owning the account's email or external identity.</evidence>
<recommendation>Require a non-empty TRUSTED_PROXY_AUTH_SECRET whenever trusted-proxy auth is enabled and reject requests unless it matches; configure the proxy to overwrite/strip client copies of both trust headers. Also keep the Cap origin private or firewall it so requests cannot bypass the authenticating proxy.</recommendation>
</violation>
</file>
|
hey @richiemcilroy! rebased onto main. the conflict was just main's new conditional workos block, so i kept that and put trusted-proxy right after it added tests the same shape as the apple one: the provider only shows up when both TRUSTED_PROXY_AUTH_HEADER and TRUSTED_PROXY_AUTH_EMAIL are set, and it's gone if either is missing. threw in a few for authorize failing closed too (no header, wrong value, unknown user, bad secret) also fixed the tembo stuff. the warning was firing on every request haha, now it's once per process ci looks like it's waiting on a maintainer to approve the runs since it's from a fork 🙏 @greptileai please re-review the latest commit |
e13b79e to
c5bd88c
Compare
|
@CarlKho-Minerva is attempting to deploy a commit to the Cap Software Inc Team on Vercel. A member of the Team first needs to authorize it. |
c5bd88c to
e13b79e
Compare
Lets self-hosters who front Cap with an authenticating reverse proxy sign in via that proxy instead of Cap's email code. A CredentialsProvider is added to the providers array only when TRUSTED_PROXY_AUTH_HEADER and TRUSTED_PROXY_AUTH_EMAIL are both set; it signs in the one configured account when the trusted header equals "true", and fails closed otherwise. Same opt-in pattern as Grafana auth.proxy / Gitea reverse-proxy auth.
Hardening:
Works with Cap's existing jwt/session callbacks unchanged — the jwt callback already re-fetches by token.email and stamps id + sessionVersion. Typechecked against next-auth 4.24.5 under --strict.
Trigger via signIn("trusted-proxy"); a login-page button is a small follow-up.
The PR appears safe to merge.
Summary
Adds opt-in reverse-proxy authentication:
trusted-proxycredentials provider when the required header and account email are configured.Reviews (2) · Last reviewed commit: "address review: warn once, fail closed w..." · Reviewed by Greptile