From 6dc9e4ffee64774a5a68f95107ebc05f8e1d3daa Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 15 Jul 2026 20:29:59 +0530 Subject: [PATCH] feat: reject socket-scoped tokens on REST (the other half of realtime delegation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delegated socket token (aud=IIOS_REALTIME_AUDIENCE) was only narrow in one direction: the gateway accepts ONLY that audience, but REST never checked the actor token's audience — so a leaked browser socket token still drove privileged REST, i.e. a full actor token with extra steps. RealtimeTokenRestGuard closes it, registered GLOBALLY (APP_GUARD) because every REST route is a target — only 2 of 15 controllers sit behind ContextAttestationGuard, so inbox/media/interactions/ ai/... would otherwise stay open. It is a decode-only REJECT filter, never an authenticator: - does not verify signatures (controllers still call SessionVerifier); stripping `aud` to bypass it invalidates the signature downstream, - no bearer -> pass through (health, metrics, HMAC adapter webhooks), - ws context -> pass through (the gateway enforces the mirror rule), - unset IIOS_REALTIME_AUDIENCE -> no-op, the same switch that turns on the socket half. So one env now enables the whole boundary: socket accepts only iios-message, REST refuses it. 8 tests; typecheck + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/platform/platform.module.ts | 13 +++- .../src/platform/realtime-token.guard.spec.ts | 68 +++++++++++++++++++ .../src/platform/realtime-token.guard.ts | 43 ++++++++++++ 3 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 packages/iios-service/src/platform/realtime-token.guard.spec.ts create mode 100644 packages/iios-service/src/platform/realtime-token.guard.ts diff --git a/packages/iios-service/src/platform/platform.module.ts b/packages/iios-service/src/platform/platform.module.ts index c69c2a1..e2409cf 100644 --- a/packages/iios-service/src/platform/platform.module.ts +++ b/packages/iios-service/src/platform/platform.module.ts @@ -1,10 +1,19 @@ import { Global, Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; import { PLATFORM_PORTS, LocalDevPorts } from './platform-ports'; +import { RealtimeTokenRestGuard } from './realtime-token.guard'; -/** Binds the platform ports (P1: the permissive in-service default). */ +/** + * Binds the platform ports (P1: the permissive in-service default), and registers the + * realtime-delegation REST guard globally — a socket-scoped token must not drive REST on ANY + * route, so it can't be per-controller (see realtime-token.guard). + */ @Global() @Module({ - providers: [{ provide: PLATFORM_PORTS, useClass: LocalDevPorts }], + providers: [ + { provide: PLATFORM_PORTS, useClass: LocalDevPorts }, + { provide: APP_GUARD, useClass: RealtimeTokenRestGuard }, + ], exports: [PLATFORM_PORTS], }) export class PlatformModule {} diff --git a/packages/iios-service/src/platform/realtime-token.guard.spec.ts b/packages/iios-service/src/platform/realtime-token.guard.spec.ts new file mode 100644 index 0000000..6cae345 --- /dev/null +++ b/packages/iios-service/src/platform/realtime-token.guard.spec.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { ForbiddenException, type ExecutionContext } from '@nestjs/common'; +import jwt from 'jsonwebtoken'; +import { RealtimeTokenRestGuard } from './realtime-token.guard'; + +// Realtime delegation, REST half: a socket-scoped token (aud=iios-message) opens the /message +// socket and NOTHING else. The gateway enforces the mirror rule; this guard is the REST side. + +const SECRET = 'dev-crm-secret'; +const token = (payload: Record) => jwt.sign(payload, SECRET, { algorithm: 'HS256', expiresIn: '5m' }); + +/** An HTTP ExecutionContext carrying the given authorization header. */ +function httpCtx(authorization?: string): ExecutionContext { + return { + getType: () => 'http', + switchToHttp: () => ({ getRequest: () => ({ headers: authorization ? { authorization } : {} }) }), + } as unknown as ExecutionContext; +} +const wsCtx = () => ({ getType: () => 'ws' }) as unknown as ExecutionContext; + +const guard = new RealtimeTokenRestGuard(); + +describe('RealtimeTokenRestGuard (socket tokens must not drive REST)', () => { + afterEach(() => { delete process.env.IIOS_REALTIME_AUDIENCE; }); + + it('rejects a socket-scoped token (aud=iios-message) on REST', () => { + process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; + const ctx = httpCtx(`Bearer ${token({ sub: 'pp_1', appId: 'crm-web', aud: 'iios-message' })}`); + expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException); + }); + + it('allows a normal actor token (no aud)', () => { + process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; + expect(guard.canActivate(httpCtx(`Bearer ${token({ sub: 'pp_1', appId: 'crm-web' })}`))).toBe(true); + }); + + it('allows a REST-audience token (aud=iios-core)', () => { + process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; + expect(guard.canActivate(httpCtx(`Bearer ${token({ sub: 'pp_1', aud: 'iios-core' })}`))).toBe(true); + }); + + it('rejects when the socket audience is one of several in an aud array', () => { + process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; + const ctx = httpCtx(`Bearer ${token({ sub: 'pp_1', aud: ['iios-core', 'iios-message'] })}`); + expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException); + }); + + it('is a no-op when IIOS_REALTIME_AUDIENCE is unset (same switch as the socket half)', () => { + const ctx = httpCtx(`Bearer ${token({ sub: 'pp_1', aud: 'iios-message' })}`); + expect(guard.canActivate(ctx)).toBe(true); + }); + + it('passes through unauthenticated routes (health, metrics, HMAC adapter webhooks)', () => { + process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; + expect(guard.canActivate(httpCtx())).toBe(true); + expect(guard.canActivate(httpCtx('Hmac abc123'))).toBe(true); // non-bearer scheme + }); + + it('never applies to the socket itself (ws context)', () => { + process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; + expect(guard.canActivate(wsCtx())).toBe(true); + }); + + it('passes a malformed bearer through (auth is the controller\'s job, not this filter\'s)', () => { + process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; + expect(guard.canActivate(httpCtx('Bearer not-a-jwt'))).toBe(true); + }); +}); diff --git a/packages/iios-service/src/platform/realtime-token.guard.ts b/packages/iios-service/src/platform/realtime-token.guard.ts new file mode 100644 index 0000000..fade300 --- /dev/null +++ b/packages/iios-service/src/platform/realtime-token.guard.ts @@ -0,0 +1,43 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import jwt from 'jsonwebtoken'; +import type { Request } from 'express'; + +/** + * Realtime delegation — the REST half. + * + * The browser's socket token is deliberately narrow: `aud = IIOS_REALTIME_AUDIENCE` (e.g. + * iios-message), minutes-long, and good for the /message socket ONLY. The gateway enforces the + * mirror of this rule (it accepts ONLY that audience). Without this guard the narrowing is + * one-directional: REST doesn't check the actor token's audience, so a leaked socket token would + * still drive privileged REST — i.e. it would be a full actor token with extra steps. + * + * Applied GLOBALLY (APP_GUARD) on purpose: every REST route is a target, not just the ones behind + * ContextAttestationGuard (inbox, media, interactions, ai, … would otherwise stay open). + * + * It is a decode-only REJECT filter, never an authenticator: + * - it does not verify signatures (each controller still calls SessionVerifier) — cheap, and it + * can't be bypassed by stripping `aud`, because that invalidates the signature downstream; + * - no bearer token → pass through (health, metrics, HMAC adapter webhooks are not its business); + * - unset IIOS_REALTIME_AUDIENCE → no-op (same switch that turns on the socket half). + */ +@Injectable() +export class RealtimeTokenRestGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + if (context.getType() !== 'http') return true; // the socket enforces its own (mirror) rule + + const socketAudience = process.env.IIOS_REALTIME_AUDIENCE?.trim(); + if (!socketAudience) return true; + + const auth = context.switchToHttp().getRequest().headers['authorization']; + const raw = Array.isArray(auth) ? auth[0] : auth; + if (typeof raw !== 'string' || !/^Bearer\s+/i.test(raw)) return true; + + const decoded = jwt.decode(raw.replace(/^Bearer\s+/i, ''), { json: true }); + const aud = decoded?.aud; + const isSocketToken = aud === socketAudience || (Array.isArray(aud) && aud.includes(socketAudience)); + if (isSocketToken) { + throw new ForbiddenException(`socket-scoped token (aud="${socketAudience}") cannot be used for REST`); + } + return true; + } +}