mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }),
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user