11import { getIp } from '@better-auth/core/utils/ip'
2+ import { AuditAction , AuditResourceType , recordAudit } from '@sim/audit'
23import { db } from '@sim/db'
34import { member , organization } from '@sim/db/schema'
45import { createLogger } from '@sim/logger'
@@ -72,23 +73,45 @@ async function getOrgAllowlist(organizationId: string): Promise<CompiledAllowlis
7273
7374interface HandshakeLike {
7475 headers : Record < string , string | string [ ] | undefined >
75- address : string
76+ }
77+
78+ /**
79+ * Per-member throttle for socket-denial audit events (this is a separate
80+ * process from the app, so it keeps its own map) — one event per member per
81+ * window is enough to show who is blocked without a reconnect loop flooding
82+ * the log.
83+ */
84+ const DENIAL_AUDIT_WINDOW_MS = 5 * 60 * 1000
85+ const lastDenialAuditAt = new Map < string , number > ( )
86+
87+ function recordSocketDenial ( userId : string , organizationId : string , clientIp : string | null ) : void {
88+ const last = lastDenialAuditAt . get ( userId )
89+ if ( last && Date . now ( ) - last < DENIAL_AUDIT_WINDOW_MS ) return
90+ lastDenialAuditAt . set ( userId , Date . now ( ) )
91+ recordAudit ( {
92+ workspaceId : null ,
93+ actorId : userId ,
94+ action : AuditAction . ORG_IP_ACCESS_DENIED ,
95+ resourceType : AuditResourceType . ORGANIZATION ,
96+ resourceId : organizationId ,
97+ description : clientIp
98+ ? `Denied realtime connection from ${ clientIp } by the IP allowlist`
99+ : 'Denied realtime connection by the IP allowlist (client IP unresolvable)' ,
100+ metadata : { clientIp, surface : 'realtime' } ,
101+ } )
76102}
77103
78104/**
79105 * Socket-handshake counterpart of the app's org IP-allowlist enforcement.
80106 * Resolves the client IP with Better Auth's trusted-proxy resolver from the
81- * handshake headers (falling back to the socket peer address when no
82- * forwarding headers are present), then checks the member org's allowlist.
83- * Fail-closed like the app side: an active policy with an unresolvable
84- * client IP denies the connection.
107+ * handshake headers, then checks the member org's allowlist. Shares the
108+ * app's posture: fail-closed on an unresolvable client IP (active policy +
109+ * no derivable trusted IP → deny), fail-open on an unexpected/DB error.
85110 *
86111 * Plan gating is intentionally absent here — `apps/realtime` cannot import
87- * billing code, and over-denial is the safe direction for a security
88- * control (the app-side check makes the opposite, fail-open call on DB
89- * errors because a blip must not lock an org out of the product). A
90- * downgraded org's stored-but-enabled policy therefore keeps gating sockets
91- * until the org disables it; the app is the sole writer of the settings.
112+ * billing code. A downgraded org's stored-but-enabled policy therefore keeps
113+ * gating sockets until the org disables it (the app is the sole writer of
114+ * the settings), which is the safe direction for a security control.
92115 */
93116export async function isSocketAllowedByNetworkPolicy (
94117 userId : string ,
@@ -109,20 +132,33 @@ export async function isSocketAllowedByNetworkPolicy(
109132 if ( typeof value === 'string' ) headers . set ( key , value )
110133 else if ( Array . isArray ( value ) ) headers . set ( key , value . join ( ', ' ) )
111134 }
112- const clientIp =
113- getIp ( new Request ( 'http://socket.internal/' , { headers } ) , IP_RESOLUTION_OPTIONS ) ??
114- ( handshake . address || null )
135+ // Trust only the trusted-proxy-resolved address — the same resolution the
136+ // app uses. No fallback to the raw socket peer: matching the app's
137+ // fail-closed-on-unresolvable-IP behavior is more important than salvaging
138+ // a direct-connect address the app-side check would never have accepted.
139+ const clientIp = getIp (
140+ new Request ( 'http://socket.internal/' , { headers } ) ,
141+ IP_RESOLUTION_OPTIONS
142+ )
115143
116144 if ( ! clientIp ) {
117145 logger . warn ( 'Denying socket: network policy active but client IP unresolvable' , {
118146 userId,
119147 organizationId,
120148 } )
149+ recordSocketDenial ( userId , organizationId , null )
150+ return false
151+ }
152+ if ( ! isAddressAllowed ( clientIp , allowlist ) ) {
153+ recordSocketDenial ( userId , organizationId , clientIp )
121154 return false
122155 }
123- return isAddressAllowed ( clientIp , allowlist )
156+ return true
124157 } catch ( error ) {
125- logger . error ( 'Network policy check failed; denying socket' , { userId, error } )
126- return false
158+ // Fail OPEN on an unexpected/DB error, matching the app-side network-policy
159+ // loader — a transient blip must not lock members out of collaboration.
160+ // The primary boundary (policy loaded, IP not allowed) stays fail-closed.
161+ logger . error ( 'Network policy check failed; allowing socket' , { userId, error } )
162+ return true
127163 }
128164}
0 commit comments