Skip to content

Commit 88c0b15

Browse files
committed
fix(network): resolve IPv6 at /128 for allowlist matching (was masked to /64); document self-host proxy/break-glass/fail-open
1 parent a073439 commit 88c0b15

4 files changed

Lines changed: 94 additions & 8 deletions

File tree

apps/docs/content/docs/en/platform/enterprise/ip-access.mdx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ title: IP Access
33
description: Restrict sign-in and API access to your organization's allowed IP ranges
44
---
55

6+
import { Callout } from 'fumadocs-ui/components/callout'
67
import { FAQ } from '@/components/ui/faq'
78

89
IP Access lets organization owners and admins on Enterprise plans restrict where members can use Sim from. When the allowlist is enabled, sign-in, session use, API-key requests, and live collaboration connections are only accepted from the listed addresses.
@@ -30,6 +31,22 @@ Deployed chats, public form shares, webhooks, and scheduled executions are **not
3031

3132
Policy changes and denied access attempts are recorded in the [audit log](/platform/enterprise/audit-logs) — denials include the member and the blocked address, throttled per member so a polling client cannot flood the log.
3233

34+
IPv6 addresses are matched at full precision, so you can allow an exact host (`2001:db8::1`) or a subnet (`2001:db8::/64`). Because IPv6 clients often rotate within their `/64`, allowing the `/64` is usually the more robust choice.
35+
36+
---
37+
38+
## Self-hosting
39+
40+
<Callout type="warning">
41+
IP access fails **closed**: when a policy is active and Sim cannot determine a trustworthy client address, the request is denied. A misconfigured proxy chain will therefore lock out your organization. Configure the two settings below before enabling a policy.
42+
</Callout>
43+
44+
- **`AUTH_TRUSTED_PROXIES`** — the reverse-proxy IPs or CIDR ranges in front of Sim (your load balancer / ingress), comma-separated, for example `AUTH_TRUSTED_PROXIES=10.0.0.0/16`. Sim walks the `x-forwarded-for` chain right to left, skips these trusted hops, and uses the first untrusted address as the client IP — so forwarded headers cannot be forged. If this is unset (or wrong) behind a multi-hop proxy, no client address can be derived and every request is denied while a policy is active. The settings page's lockout guard also refuses to *enable* a policy when your own address is unresolvable, which is the first symptom of a missing value.
45+
- **`NODE_ENV=production`** — outside production, the auth layer resolves every client to `127.0.0.1`, so an allowlist that does not contain `127.0.0.1` would deny everyone. Run production deployments with `NODE_ENV=production`.
46+
- **`DISABLE_ORG_IP_ALLOWLIST=true`** — the break-glass. Setting it (read at request time, no restart needed) skips IP-allowlist enforcement across the app and realtime processes, restoring access if a policy or proxy misconfiguration locks the organization out. Remove it once the allowlist is corrected.
47+
48+
Enforcement reads the compiled policy from a short-lived in-process cache, so a policy change (or a plan change) takes effect within about a minute across the fleet. If the database is briefly unreachable the policy resolver **fails open** (allows the request) rather than locking the whole organization out of the product on a transient blip — the primary boundary (an active policy that rejects an address) stays fail-closed.
49+
3350
---
3451

3552
## FAQ
@@ -44,7 +61,7 @@ Policy changes and denied access attempts are recorded in the [audit log](/platf
4461
{
4562
question: 'Can I lock myself out?',
4663
answer:
47-
'Not in a single save — the settings page rejects any list that excludes your current IP. If your network changes afterwards, another admin on an allowed network (or Sim support) can update the list.',
64+
'Not in a single save — the settings page rejects any list that excludes your current IP. If your network changes afterwards, another admin on an allowed network can update the list. Self-hosted operators can set DISABLE_ORG_IP_ALLOWLIST=true as a break-glass to restore access (see Self-hosting above).',
4865
},
4966
{
5067
question: 'Does this affect deployed chats and webhooks?',

apps/sim/lib/auth/auth.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -675,11 +675,16 @@ export const auth = betterAuth({
675675
}
676676

677677
// Org IP allowlists must block session establishment, not just
678-
// later requests. `session.ipAddress` is Better Auth's
679-
// trusted-proxy-resolved client address. Thrown APIErrors must
680-
// propagate — deliberately outside the try below.
678+
// later requests. Resolve the client IP the same way the request
679+
// path does (full-precision, via getTrustedClientIp) rather than
680+
// Better Auth's `session.ipAddress`, which is masked to /64 for
681+
// rate-limit keying and would reject exact IPv6 hosts. Thrown
682+
// APIErrors must propagate — deliberately outside the try below.
681683
if (!session.impersonatedBy) {
682-
const network = await enforceOrgNetworkPolicy(session.userId, () => session.ipAddress)
684+
const signInHeaders = await headers()
685+
const network = await enforceOrgNetworkPolicy(session.userId, () =>
686+
getTrustedClientIp(new Request('http://localhost/', { headers: signInHeaders }))
687+
)
683688
if (!network.allowed) {
684689
logger.warn('Blocking session creation by org network policy', {
685690
userId: session.userId,
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Integration test for the client-IP RESOLUTION layer that the matcher's own
5+
* unit tests bypass: Better Auth's `getIp` (which masks IPv6 to `ipv6Subnet`)
6+
* composed with `buildIpResolutionOptions` and the allowlist matcher. Guards
7+
* against IPv6 addresses being silently collapsed to /64 before matching,
8+
* which would make exact-host (/128) entries impossible in production.
9+
*/
10+
import { getIp } from '@better-auth/core/utils/ip'
11+
import {
12+
buildIpResolutionOptions,
13+
compileAllowlist,
14+
isAddressAllowed,
15+
parseTrustedProxies,
16+
} from '@sim/platform-authz/network'
17+
import { describe, expect, it } from 'vitest'
18+
19+
const OPTIONS = buildIpResolutionOptions(parseTrustedProxies(undefined))
20+
21+
function resolve(forwardedFor: string): string | null {
22+
const headers = new Headers({ 'x-forwarded-for': forwardedFor })
23+
return getIp(new Request('http://localhost/', { headers }), OPTIONS)
24+
}
25+
26+
describe('network-policy IP resolution', () => {
27+
it('resolves IPv6 at full /128 precision (not masked to /64)', () => {
28+
// If ipv6Subnet defaulted to 64, this would return 2001:db8:1234:5678::.
29+
const resolved = resolve('2001:db8:1234:5678::1')
30+
expect(resolved).not.toBeNull()
31+
expect(isAddressAllowed(resolved as string, compileAllowlist(['2001:db8:1234:5678::1']))).toBe(
32+
true
33+
)
34+
})
35+
36+
it('does not let a different host in the same /64 match an exact /128 entry', () => {
37+
const resolved = resolve('2001:db8:1234:5678::99')
38+
expect(isAddressAllowed(resolved as string, compileAllowlist(['2001:db8:1234:5678::1']))).toBe(
39+
false
40+
)
41+
// …but a /64 entry covers the whole subnet.
42+
expect(
43+
isAddressAllowed(resolved as string, compileAllowlist(['2001:db8:1234:5678::/64']))
44+
).toBe(true)
45+
})
46+
47+
it('resolves IPv4 unchanged', () => {
48+
const resolved = resolve('203.0.113.7')
49+
expect(resolved).toBe('203.0.113.7')
50+
expect(isAddressAllowed(resolved as string, compileAllowlist(['203.0.113.0/24']))).toBe(true)
51+
})
52+
})

packages/platform-authz/src/network.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,14 +214,26 @@ export function parseTrustedProxies(raw: string | undefined): string[] {
214214

215215
/**
216216
* Builds the `advanced.ipAddress` options object Better Auth's `getIp`
217-
* expects from a parsed trusted-proxy list.
217+
* expects from a parsed trusted-proxy list, for NETWORK-POLICY resolution.
218+
*
219+
* `ipv6Subnet: 128` overrides Better Auth's `/64` default so IPv6 client
220+
* addresses are matched at full host precision — the allowlist supports exact
221+
* IPv6 hosts and `/128` entries, and without this every IPv6 address would be
222+
* masked to its `/64` and could never match a more specific entry. This
223+
* options object is passed only to the network-policy `getIp` calls; Better
224+
* Auth's own session-IP and rate-limit keying (configured on the auth
225+
* instance) keep the `/64` default, so IPv6 rate-limit collapsing is
226+
* unaffected.
218227
*/
219228
export function buildIpResolutionOptions(trustedProxies: readonly string[]): {
220-
advanced: { ipAddress: { trustedProxies?: string[] } }
229+
advanced: { ipAddress: { trustedProxies?: string[]; ipv6Subnet: number } }
221230
} {
222231
return {
223232
advanced: {
224-
ipAddress: trustedProxies.length > 0 ? { trustedProxies: [...trustedProxies] } : {},
233+
ipAddress: {
234+
ipv6Subnet: 128,
235+
...(trustedProxies.length > 0 ? { trustedProxies: [...trustedProxies] } : {}),
236+
},
225237
},
226238
}
227239
}

0 commit comments

Comments
 (0)