feat(server): auto-trust loopback and LAN origins without TRUSTED_ORIGINS

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 <noreply@anthropic.com>
This commit is contained in:
saltbo
2026-06-10 01:18:35 -04:00
parent d2f3a34f05
commit e56d3dc61a
8 changed files with 120 additions and 7 deletions
+33 -2
View File
@@ -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)
})
})
+13 -1
View File
@@ -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,
+35
View File
@@ -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)
})
})
+32
View File
@@ -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
}
@@ -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 }),
})
+1 -1
View File
@@ -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)
@@ -48,7 +48,7 @@ async function insertMember(db: TestDb, organizationId: string, userId: string,
async function setActiveOrg(app: TestApp, cookies: string, orgId: string): Promise<string> {
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()
+4 -2
View File
@@ -579,7 +579,7 @@ export async function adminHeaders(app: ReturnType<typeof createApp>) {
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')