Skip to content

Commit 83ba26e

Browse files
committed
fix(network-policy): enforce at customSession get-session chokepoint (covers client session), match realtime break-glass to isTruthy
1 parent bad68ac commit 83ba26e

2 files changed

Lines changed: 53 additions & 26 deletions

File tree

apps/realtime/src/middleware/network-policy.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,10 @@ export async function isSocketAllowedByNetworkPolicy(
118118
handshake: HandshakeLike
119119
): Promise<boolean> {
120120
// Read at call time so the break-glass works without a realtime restart.
121-
if (process.env.DISABLE_ORG_IP_ALLOWLIST === 'true') return true
121+
// Mirror the app's isTruthy() string semantics ('true'/'1') so the same env
122+
// value disables enforcement identically across both processes.
123+
const disableFlag = process.env.DISABLE_ORG_IP_ALLOWLIST
124+
if (disableFlag === 'true' || disableFlag === '1') return true
122125

123126
try {
124127
const organizationId = await getMemberOrganizationId(userId)

apps/sim/lib/auth/auth.ts

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,20 @@ if (validStripeKey) {
197197
})
198198
}
199199

200+
/**
201+
* Error code tagged on the FORBIDDEN thrown by the get-session network-policy
202+
* check, so the `getSessionImpl` wrapper can translate exactly that denial to
203+
* a null session (signed-out) without swallowing unrelated errors.
204+
*/
205+
const ORG_IP_RESTRICTED_CODE = 'ORG_IP_RESTRICTED'
206+
207+
function isOrgIpRestrictedError(error: unknown): boolean {
208+
return (
209+
error instanceof APIError &&
210+
(error.body as { code?: string } | undefined)?.code === ORG_IP_RESTRICTED_CODE
211+
)
212+
}
213+
200214
/**
201215
* Reverse-proxy hops trusted for forwarded-IP resolution. When configured,
202216
* Better Auth walks the x-forwarded-for chain right to left, skips these
@@ -1046,10 +1060,29 @@ export const auth = betterAuth({
10461060
oneTimeToken({
10471061
expiresIn: 24 * 60, // 24 hours in minutes (better-auth's expiresIn unit)
10481062
}),
1049-
customSession(async ({ user, session }) => ({
1050-
user,
1051-
session,
1052-
})),
1063+
customSession(async ({ user, session }, ctx) => {
1064+
// Single get-session chokepoint for the org IP allowlist: this handler
1065+
// runs for the server `auth.api.getSession` path AND the browser
1066+
// `client.getSession` HTTP call, so a blocked member is signed out on
1067+
// both. Throwing FORBIDDEN (consistent with the sign-in and
1068+
// access-control denials) yields a 403 for the HTTP client and is
1069+
// translated to a null session by the `getSessionImpl` wrapper for
1070+
// server callers. Impersonation sessions are platform tooling and exempt.
1071+
const impersonatedBy = (session as { impersonatedBy?: string | null }).impersonatedBy
1072+
if (user?.id && !impersonatedBy) {
1073+
const network = await enforceOrgNetworkPolicy(user.id, () =>
1074+
getTrustedClientIp(new Request('http://localhost/', { headers: ctx.headers }))
1075+
)
1076+
if (!network.allowed) {
1077+
logger.warn('Session denied by org network policy', { userId: user.id })
1078+
throw new APIError('FORBIDDEN', {
1079+
code: ORG_IP_RESTRICTED_CODE,
1080+
message: network.reason ?? 'Access restricted. Please contact your administrator.',
1081+
})
1082+
}
1083+
}
1084+
return { user, session }
1085+
}),
10531086
emailOTP({
10541087
sendVerificationOTP: async (data) => {
10551088
if (!isEmailVerificationEnabled) {
@@ -3642,29 +3675,20 @@ async function getSessionImpl() {
36423675
return createAnonymousSession()
36433676
}
36443677

3678+
// Org IP-allowlist enforcement lives in the `customSession` get-session
3679+
// handler, which runs for BOTH this server path and the browser
3680+
// `client.getSession` HTTP call. It throws FORBIDDEN with
3681+
// ORG_IP_RESTRICTED_CODE on a blocked member; translate that to a null
3682+
// session here so server callers see a clean signed-out state rather than
3683+
// an exception (the HTTP client receives the 403 and treats it as no
3684+
// session). Any other error propagates unchanged.
36453685
const hdrs = await headers()
3646-
const session = await auth.api.getSession({
3647-
headers: hdrs,
3648-
})
3649-
3650-
// Org IP allowlists are enforced at this chokepoint so every route and
3651-
// layout that calls getSession directly re-checks the policy, not just
3652-
// hybrid-auth routes. A denied member is treated as signed out.
3653-
// Impersonation sessions are platform tooling and exempt.
3654-
const impersonatedBy = session
3655-
? (session.session as { impersonatedBy?: string | null }).impersonatedBy
3656-
: null
3657-
if (session?.user?.id && !impersonatedBy) {
3658-
const network = await enforceOrgNetworkPolicy(session.user.id, () =>
3659-
getTrustedClientIp(new Request('http://localhost/', { headers: hdrs }))
3660-
)
3661-
if (!network.allowed) {
3662-
logger.warn('Session denied by org network policy', { userId: session.user.id })
3663-
return null
3664-
}
3686+
try {
3687+
return await auth.api.getSession({ headers: hdrs })
3688+
} catch (error) {
3689+
if (isOrgIpRestrictedError(error)) return null
3690+
throw error
36653691
}
3666-
3667-
return session
36683692
}
36693693

36703694
export const getSession = cache(getSessionImpl)

0 commit comments

Comments
 (0)