From e56d3dc61ae8159a1d9619956c5d3735deea9910 Mon Sep 17 00:00:00 2001 From: saltbo Date: Wed, 10 Jun 2026 01:18:35 -0400 Subject: [PATCH] feat(server): auto-trust loopback and LAN origins without TRUSTED_ORIGINS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sign-in via 127.0.0.1 or a LAN IP failed with "Invalid origin" unless the user manually configured TRUSTED_ORIGINS. better-auth's trustedOrigins now uses the function form: origins on localhost, 127.0.0.0/8, ::1, or RFC 1918 private ranges are trusted per request. Browsers set the Origin header themselves, so a private address in it proves the page was served from the user's own machine or LAN — safe to trust for CSRF purposes. Also set advanced.disableOriginCheck: false explicitly: better-auth silently disables the origin check under NODE_ENV=test, so no test ever exercised real CSRF behavior. Test helpers now send an Origin header on cookie-bearing requests, like real browsers do. Co-Authored-By: Claude Fable 5 --- server/auth.integration.test.ts | 35 +++++++++++++++++-- server/auth.ts | 14 +++++++- server/lib/local-origin.test.ts | 35 +++++++++++++++++++ server/lib/local-origin.ts | 32 +++++++++++++++++ server/middleware/auth.integration.test.ts | 1 + server/routes/auth.integration.test.ts | 2 +- .../routes/ihost-config.integration.test.ts | 2 +- server/test/setup.ts | 6 ++-- 8 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 server/lib/local-origin.test.ts create mode 100644 server/lib/local-origin.ts diff --git a/server/auth.integration.test.ts b/server/auth.integration.test.ts index 76a64a37..7e2f0b90 100644 --- a/server/auth.integration.test.ts +++ b/server/auth.integration.test.ts @@ -522,7 +522,8 @@ describe('sendInvitationEmail — buildInvitationEmailHtml via invite-member wit const res = await ctx.app.request('/api/auth/organization/invite-member', { method: 'POST', - headers: { 'Content-Type': 'application/json', Cookie: cookie }, + // Cookie-bearing requests must carry an Origin, like real browser requests + headers: { 'Content-Type': 'application/json', Cookie: cookie, Origin: 'http://localhost:3000' }, body: JSON.stringify({ email: 'invitee@example.com', role: 'member', organizationId: orgId }), }) expect(res.status).toBe(200) @@ -547,7 +548,7 @@ describe('sendInvitationEmail — buildInvitationEmailHtml via invite-member wit await ctx.app.request('/api/auth/organization/invite-member', { method: 'POST', - headers: { 'Content-Type': 'application/json', Cookie: cookie }, + headers: { 'Content-Type': 'application/json', Cookie: cookie, Origin: 'http://localhost:3000' }, body: JSON.stringify({ email: 'newmember@example.com', role: 'member', organizationId: orgId }), }) @@ -700,3 +701,33 @@ describe('OAuth username generation — before hook', () => { expect(row.username).toMatch(/^ab-[a-z0-9]{6}$/) }) }) + +describe('origin check — loopback and LAN origins are trusted without config', () => { + // better-auth only enforces the Origin check on requests that carry cookies, + // so attach a dummy cookie to make validateOrigin run. + async function signInWithOrigin(ctx: TestCtx, origin: string) { + return ctx.app.request('/api/auth/sign-in/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin, Cookie: 'zp.dummy=1' }, + body: JSON.stringify({ email: 'origin@example.com', password: 'password123456' }), + }) + } + + it.each([ + 'http://127.0.0.1:3000', + 'http://192.168.1.50:3000', + 'http://10.0.0.5:8080', + ])('allows sign-in with Origin %s when TRUSTED_ORIGINS is not set', async (origin) => { + const ctx = await createTestApp() + await signUp(ctx, 'origin@example.com') + const res = await signInWithOrigin(ctx, origin) + expect(res.status).toBe(200) + }) + + it('still rejects sign-in from an unknown public origin', async () => { + const ctx = await createTestApp() + await signUp(ctx, 'origin@example.com') + const res = await signInWithOrigin(ctx, 'https://evil.example.com') + expect(res.status).toBe(403) + }) +}) diff --git a/server/auth.ts b/server/auth.ts index 2b1812ca..1a8bb566 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -21,6 +21,7 @@ import { } from '../shared/oauth-providers' import * as authSchema from './db/auth-schema' import { orgQuotaEntitlements, orgQuotas, systemOptions } from './db/schema' +import { isLocalNetworkOrigin } from './lib/local-origin' import { hashPassword, verifyPassword as verifyPasswordHash } from './lib/password' import type { Database, Platform } from './platform/interface' import { recordActivity } from './services/activity' @@ -148,9 +149,20 @@ export async function createAuth( database: drizzleAdapter(db, { provider: 'sqlite', schema: authSchema }), secret, baseURL, - trustedOrigins, + // Function form: better-auth merges the result with baseURL per request. + // Loopback/LAN origins are trusted automatically so self-hosted users can + // log in via 127.0.0.1 or a LAN IP without configuring TRUSTED_ORIGINS. + trustedOrigins: (request?: Request) => { + const origin = request?.headers.get('origin') + const list = trustedOrigins ?? [] + return origin && isLocalNetworkOrigin(origin) ? [...list, origin] : list + }, advanced: { cookiePrefix: 'zp', + // Explicitly enable the origin check (production default). Without this, + // better-auth silently disables it under NODE_ENV=test, so tests would + // never exercise the real CSRF/origin behavior. + disableOriginCheck: false, }, emailAndPassword: { enabled: true, diff --git a/server/lib/local-origin.test.ts b/server/lib/local-origin.test.ts new file mode 100644 index 00000000..a2fa427e --- /dev/null +++ b/server/lib/local-origin.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { isLocalNetworkOrigin } from './local-origin' + +describe('isLocalNetworkOrigin', () => { + it.each([ + 'http://localhost:5185', + 'http://app.localhost:3000', + 'http://127.0.0.1:5185', + 'http://127.8.8.8', + 'http://[::1]:5185', + 'http://10.0.0.5:8080', + 'http://172.16.0.1', + 'http://172.31.255.254:443', + 'http://192.168.1.100:5185', + 'https://192.168.1.100', + ])('trusts %s', (origin) => { + expect(isLocalNetworkOrigin(origin)).toBe(true) + }) + + it.each([ + 'http://example.com', + 'https://zpan.example.com', + 'http://11.0.0.1', // public, adjacent to 10/8 + 'http://172.32.0.1', // outside 172.16/12 + 'http://192.169.0.1', // outside 192.168/16 + 'http://10.evil.com', // domain that merely starts with a private prefix + 'http://192.168.1.1.evil.com', + 'ftp://127.0.0.1', // non-http(s) scheme + 'null', + 'not a url', + '', + ])('rejects %s', (origin) => { + expect(isLocalNetworkOrigin(origin)).toBe(false) + }) +}) diff --git a/server/lib/local-origin.ts b/server/lib/local-origin.ts new file mode 100644 index 00000000..a7d40020 --- /dev/null +++ b/server/lib/local-origin.ts @@ -0,0 +1,32 @@ +/** + * Returns true when the origin points at localhost, the loopback interface, + * or an RFC 1918 private network address. + * + * Why trusting these is safe for CSRF purposes: browsers set the Origin + * header themselves and a public website cannot forge it. An Origin on a + * loopback or private address therefore proves the page was served from the + * user's own machine or LAN — the self-hosted scenario where requiring a + * manual TRUSTED_ORIGINS entry is pure friction. + */ +export function isLocalNetworkOrigin(origin: string): boolean { + let url: URL + try { + url = new URL(origin) + } catch { + return false + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false + const host = url.hostname + if (host === 'localhost' || host.endsWith('.localhost')) return true + if (host === '[::1]') return true + return isPrivateIpv4(host) +} + +function isPrivateIpv4(host: string): boolean { + if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return false + const [a, b] = host.split('.').map(Number) + if (a > 255 || b > 255) return false + if (a === 127 || a === 10) return true + if (a === 192 && b === 168) return true + return a === 172 && b >= 16 && b <= 31 +} diff --git a/server/middleware/auth.integration.test.ts b/server/middleware/auth.integration.test.ts index 8eeab844..65a1015e 100644 --- a/server/middleware/auth.integration.test.ts +++ b/server/middleware/auth.integration.test.ts @@ -122,6 +122,7 @@ async function setActiveOrg(app: TestApp, cookies: string, orgId: string): Promi headers: { 'Content-Type': 'application/json', Cookie: cookies, + Origin: 'http://localhost:3000', }, body: JSON.stringify({ organizationId: orgId }), }) diff --git a/server/routes/auth.integration.test.ts b/server/routes/auth.integration.test.ts index 33d1bc05..07574f51 100644 --- a/server/routes/auth.integration.test.ts +++ b/server/routes/auth.integration.test.ts @@ -336,7 +336,7 @@ describe('Auth API', () => { const createRes = await app.request('/api/auth/organization/create', { method: 'POST', - headers: { 'Content-Type': 'application/json', Cookie: cookie }, + headers: { 'Content-Type': 'application/json', Cookie: cookie, Origin: 'http://localhost:3000' }, body: JSON.stringify({ name: 'Team Quota', slug: 'team-quota' }), }) expect(createRes.status).toBe(200) diff --git a/server/routes/ihost-config.integration.test.ts b/server/routes/ihost-config.integration.test.ts index deac3854..051338ba 100644 --- a/server/routes/ihost-config.integration.test.ts +++ b/server/routes/ihost-config.integration.test.ts @@ -48,7 +48,7 @@ async function insertMember(db: TestDb, organizationId: string, userId: string, async function setActiveOrg(app: TestApp, cookies: string, orgId: string): Promise { const res = await app.request('/api/auth/organization/set-active', { method: 'POST', - headers: { 'Content-Type': 'application/json', Cookie: cookies }, + headers: { 'Content-Type': 'application/json', Cookie: cookies, Origin: 'http://localhost:3000' }, body: JSON.stringify({ organizationId: orgId }), }) const setCookies = res.headers.getSetCookie() diff --git a/server/test/setup.ts b/server/test/setup.ts index f0b29125..a7fdb6f5 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -579,7 +579,7 @@ export async function adminHeaders(app: ReturnType) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'admin@example.com', password: 'password123456' }), }) - return { Cookie: signInRes.headers.getSetCookie().join('; ') } + return { Cookie: signInRes.headers.getSetCookie().join('; '), Origin: 'http://localhost:3000' } } export async function authedHeaders( @@ -593,7 +593,9 @@ export async function authedHeaders( body: JSON.stringify({ name: 'Test User', email, password }), }) const cookies = signUpRes.headers.getSetCookie() - return { Cookie: cookies.join('; ') } + // Origin matches the test app's baseURL: cookie-bearing requests to + // better-auth endpoints fail the origin check without it, like in a browser + return { Cookie: cookies.join('; '), Origin: 'http://localhost:3000' } } const { secretKey: TEST_LICENSE_SECRET, publicKey: TEST_LICENSE_PUBLIC } = generateKeys('public')