Skip to content

Commit 5491cae

Browse files
authored
fix(bb-realtime): harden subscription token validation to prevent connect token reuse (#180)
1 parent 3c365e3 commit 5491cae

9 files changed

Lines changed: 202 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@aws-blocks/bb-realtime": patch
3+
---
4+
5+
Harden subscription token validation. Connect tokens now use a `$connect` suffix that prevents them from being reused as channel subscription tokens via prefix matching. Channel tokens remain valid as connect tokens. Backward-compatible during rollout.

packages/bb-realtime/src/aws-middleware.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ function isRealtimeDescriptor(data: unknown): data is AwsRealtimeDescriptor {
227227
return typeof data === 'object' && data !== null
228228
&& (data as any).__blocks === 'realtime/channel'
229229
&& typeof (data as any).wsUrl === 'string'
230+
&& typeof (data as any).connectToken === 'string'
230231
&& typeof (data as any).token === 'string';
231232
}
232233

packages/bb-realtime/src/index.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import { describe, it, beforeEach } from 'node:test';
55
import assert from 'node:assert';
6+
import { createHmac } from 'node:crypto';
67
import { Realtime, RealtimeErrors } from './index.js';
78
import { LOCAL_TOKEN_SECRET } from './local-dev.js';
89
import { mintChannelToken, validateChannelToken } from './utils.js';
@@ -512,6 +513,20 @@ describe('Realtime', () => {
512513
assert.strictEqual(result, null);
513514
});
514515

516+
it('validateChannelToken rejects token with missing channel field when requestedChannel is provided', () => {
517+
// Craft a validly-signed token that has no channel field
518+
const payload = { exp: Math.floor(Date.now() / 1000) + 3600 };
519+
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
520+
const sig = createHmac('sha256', 'secret123').update(JSON.stringify(payload)).digest('base64url');
521+
const token = `${payloadB64}.${sig}`;
522+
// Without requestedChannel, it should pass (connection use)
523+
const connResult = validateChannelToken(token, 'secret123');
524+
assert.ok(connResult, 'token without channel should validate for connection');
525+
// With requestedChannel, it must fail — tokens must declare what they authorize
526+
const subResult = validateChannelToken(token, 'secret123', '/ns/chat/room-1');
527+
assert.strictEqual(subResult, null, 'token without channel field must NOT authorize channel subscriptions');
528+
});
529+
515530
// ── Channel Handle Token ─────────────────────────────────────────────
516531

517532
it('getChannel() toJSON includes a valid token', async () => {

packages/bb-realtime/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import type {
2525
SubscribeOptions,
2626
} from './types.js';
2727
import { RealtimeErrors } from './errors.js';
28-
import { blocksError, validateSchema, mintChannelToken, validateChannelPath, validatePublishSize } from './utils.js';
28+
import { blocksError, validateSchema, mintChannelToken, mintConnectToken, validateChannelPath, validatePublishSize } from './utils.js';
2929
import { getBroadcastBus, LOCAL_TOKEN_SECRET } from './local-dev.js';
3030
import { Logger } from '@aws-blocks/bb-logger';
3131
import type { ChildLogger } from '@aws-blocks/bb-logger';
@@ -147,6 +147,7 @@ export const Realtime: {
147147
const fullChannel = `${ns.prefix}/${channel}`;
148148
validateChannelPath(fullChannel);
149149
const token = mintChannelToken(fullChannel, LOCAL_TOKEN_SECRET);
150+
const connectToken = mintConnectToken(this.fullId, LOCAL_TOKEN_SECRET);
150151
return {
151152
subscribe(handlerOrOptions: ((message: unknown) => void) | SubscribeOptions, _options?: never): RealtimeSubscription {
152153
const handler = typeof handlerOrOptions === 'function' ? handlerOrOptions : handlerOrOptions.onMessage;
@@ -161,6 +162,7 @@ export const Realtime: {
161162
__blocks: 'realtime/channel' as const,
162163
channel: fullChannel,
163164
wsUrl: (globalThis as any).__BLOCKS_REALTIME_WS_URL__,
165+
connectToken,
164166
token,
165167
};
166168
},

packages/bb-realtime/src/security.test.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,12 @@ describe('Security: mintConnectToken secret guard', () => {
101101
const token = mintConnectToken('test-app-rt', 'valid-secret');
102102
assert.ok(token);
103103
assert.ok(token.includes('.'), 'token should have payload.signature format');
104-
// connect token authorizes any sub-channel under the scope prefix
105-
const result = validateChannelToken(token, 'valid-secret', 'test-app-rt/events/room-1');
106-
assert.ok(result, 'connect token should authorize sub-channels');
104+
// connect tokens should not authorize channel subscriptions
105+
const subResult = validateChannelToken(token, 'valid-secret', 'test-app-rt/events/room-1');
106+
assert.strictEqual(subResult, null, 'connect token must NOT authorize channel subscriptions');
107+
// but it still validates without a requestedChannel (for connection establishment)
108+
const connResult = validateChannelToken(token, 'valid-secret');
109+
assert.ok(connResult, 'connect token should validate for connection establishment');
107110
});
108111
});
109112

@@ -152,7 +155,11 @@ describe('Security: getChannel() secret unavailability (AWS runtime simulation)'
152155
assert.ok(chResult, 'channel token should validate');
153156
assert.strictEqual(chResult!.channel, fullChannel);
154157

158+
// connect tokens should not authorize channel subscriptions
155159
const connResult = validateChannelToken(connectToken, secret, fullChannel);
156-
assert.ok(connResult, 'connect token should authorize the channel');
160+
assert.strictEqual(connResult, null, 'connect token must NOT authorize channel subscriptions');
161+
// but validates for connection (no requestedChannel)
162+
const connValidation = validateChannelToken(connectToken, secret);
163+
assert.ok(connValidation, 'connect token should validate for connection');
157164
});
158165
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/**
5+
* Token scope isolation tests.
6+
*
7+
* Validates that connect tokens (scoped to the instance prefix) cannot be
8+
* used to authorize channel subscriptions. Channel tokens scoped to a specific
9+
* channel should still work as connect tokens since they already prove the
10+
* holder has access to something within the instance.
11+
*/
12+
import { describe, it } from 'node:test';
13+
import assert from 'node:assert';
14+
import { mintChannelToken, mintConnectToken, validateChannelToken } from './utils.js';
15+
16+
const SECRET = 'test-secret-key';
17+
const INSTANCE_PREFIX = 'myapp-rt';
18+
const CHANNEL = `${INSTANCE_PREFIX}/chat/room-1`;
19+
20+
describe('Token scope isolation: connect tokens cannot subscribe to channels', () => {
21+
it('channel token validates for its own channel', () => {
22+
const token = mintChannelToken(CHANNEL, SECRET);
23+
const result = validateChannelToken(token, SECRET, CHANNEL);
24+
assert.ok(result, 'channel token should validate for its specific channel');
25+
assert.strictEqual(result!.channel, CHANNEL);
26+
});
27+
28+
it('connect token MUST NOT validate for a channel subscription', () => {
29+
const connectToken = mintConnectToken(INSTANCE_PREFIX, SECRET);
30+
// This is the critical assertion: a connect token should NOT authorize
31+
// subscription to a specific channel under the instance prefix.
32+
const result = validateChannelToken(connectToken, SECRET, CHANNEL);
33+
assert.strictEqual(result, null, 'connect token must NOT authorize channel subscriptions');
34+
});
35+
36+
it('connect token should not validate for any sub-channel', () => {
37+
const connectToken = mintConnectToken(INSTANCE_PREFIX, SECRET);
38+
// Try several channels under the instance prefix
39+
const channels = [
40+
`${INSTANCE_PREFIX}/events/room-1`,
41+
`${INSTANCE_PREFIX}/notifications/user-123`,
42+
`${INSTANCE_PREFIX}/cursors/doc-456`,
43+
];
44+
for (const ch of channels) {
45+
const result = validateChannelToken(connectToken, SECRET, ch);
46+
assert.strictEqual(result, null, `connect token must NOT authorize ${ch}`);
47+
}
48+
});
49+
50+
it('channel token works as a connect token (no requestedChannel)', () => {
51+
const channelToken = mintChannelToken(CHANNEL, SECRET);
52+
// When validating for connection (no requestedChannel), any valid token
53+
// from this instance should be accepted
54+
const result = validateChannelToken(channelToken, SECRET);
55+
assert.ok(result, 'channel token should be valid as a connect token');
56+
});
57+
58+
it('new-style connect token validates without requestedChannel (connection use)', () => {
59+
const connectToken = mintConnectToken(INSTANCE_PREFIX, SECRET);
60+
// Connect tokens should still work for connection establishment
61+
const result = validateChannelToken(connectToken, SECRET);
62+
assert.ok(result, 'connect token should validate for connection establishment');
63+
});
64+
65+
it('channel token for one channel cannot subscribe to a different channel', () => {
66+
const token = mintChannelToken(`${INSTANCE_PREFIX}/chat/room-1`, SECRET);
67+
const result = validateChannelToken(token, SECRET, `${INSTANCE_PREFIX}/chat/room-2`);
68+
assert.strictEqual(result, null, 'channel token for room-1 must NOT authorize room-2');
69+
});
70+
71+
it('namespace-level token still works for sub-channels', () => {
72+
// A token scoped to a namespace (not the instance root) should still
73+
// authorize sub-channels within that namespace
74+
const nsToken = mintChannelToken(`${INSTANCE_PREFIX}/chat`, SECRET);
75+
const result = validateChannelToken(nsToken, SECRET, `${INSTANCE_PREFIX}/chat/room-1`);
76+
assert.ok(result, 'namespace token should authorize sub-channels');
77+
});
78+
79+
it('instance name containing $connect does not create ambiguity', () => {
80+
// If an instance is literally named "foo$connect", its connect token
81+
// channel field is "foo$connect$connect". This must NOT authorize
82+
// channels under the instance like "foo$connect/cursors/room-1".
83+
const weirdPrefix = 'foo$connect';
84+
const connectToken = mintConnectToken(weirdPrefix, SECRET);
85+
const channel = `${weirdPrefix}/cursors/room-1`;
86+
const result = validateChannelToken(connectToken, SECRET, channel);
87+
assert.strictEqual(result, null, 'connect token for $connect-named instance must NOT authorize its channels');
88+
89+
// But it still validates for connection establishment
90+
const connResult = validateChannelToken(connectToken, SECRET);
91+
assert.ok(connResult, 'connect token should still validate for connection');
92+
});
93+
});

packages/bb-realtime/src/utils.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,17 +79,19 @@ export function mintChannelToken(channel: string, secret: string, ttlSeconds = 3
7979
}
8080

8181
/**
82-
* Mint a connect token. Scoped to a Realtime instance prefix (not a specific
83-
* channel). Used to gate WebSocket connection establishment. The connect token
84-
* validates for any channel that starts with the given scope prefix.
82+
* Mint a connect token. Scoped to a Realtime instance prefix with a `$connect`
83+
* suffix (e.g., `myapp-rt$connect`). Connect tokens authorize WebSocket
84+
* connection establishment but not channel subscriptions — the `$connect`
85+
* suffix ensures the token's channel field does not prefix-match real channel
86+
* paths (which always contain a `/` separator after the instance prefix).
8587
*
8688
* Default TTL is 2 hours (matching API Gateway max connection duration).
8789
*/
8890
export function mintConnectToken(scopePrefix: string, secret: string, ttlSeconds = 7200): string {
8991
if (!secret) {
9092
throw blocksError(RealtimeErrors.ConnectionFailed, 'Refusing to mint token: signing secret is empty or missing');
9193
}
92-
return mintChannelToken(scopePrefix, secret, ttlSeconds);
94+
return mintChannelToken(scopePrefix + '$connect', secret, ttlSeconds);
9395
}
9496

9597
/**
@@ -109,7 +111,10 @@ export function validateChannelToken(
109111
const expectedSig = createHmac('sha256', secret).update(JSON.stringify(payload)).digest('base64url');
110112
if (!constantTimeEquals(sig, expectedSig)) return null;
111113
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null;
112-
if (requestedChannel && payload.channel && requestedChannel !== payload.channel && !requestedChannel.startsWith(payload.channel + '/')) return null;
114+
if (requestedChannel) {
115+
if (!payload.channel) return null;
116+
if (requestedChannel !== payload.channel && !requestedChannel.startsWith(payload.channel + '/')) return null;
117+
}
113118
return payload;
114119
} catch {
115120
return null;

test-apps/comprehensive/aws-blocks/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1222,9 +1222,12 @@ export const api = new ApiNamespace(scope, 'api', (context) => ({
12221222
},
12231223

12241224
async realtimeGetRawDescriptor(subChannel: string) {
1225-
// Return the raw toJSON() descriptor (not hydrated) so tests can inspect/tamper with tokens
1225+
// Return the raw toJSON() descriptor with __blocks removed so client middleware
1226+
// does NOT hydrate it into a channel client. Tests need the raw token fields.
12261227
const ch = await realtime.getChannel('cursors', subChannel);
1227-
return ch.toJSON();
1228+
const raw = ch.toJSON() as Record<string, unknown>;
1229+
const { __blocks, ...descriptor } = raw;
1230+
return descriptor;
12281231
},
12291232

12301233
async realtimeGetPoisonedChannel(subChannel: string) {

test-apps/comprehensive/test/realtime.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,65 @@ export function realtimeTests(getApi: () => typeof apiType) {
9898
});
9999
});
100100

101+
describe('Token Scope Isolation', () => {
102+
test('connect token cannot be reused as a channel subscription token', async () => {
103+
const api = getApi();
104+
105+
// Get raw descriptors to extract tokens and channel paths
106+
// realtimeGetRawDescriptor strips __blocks so middleware won't hydrate them
107+
const publicDescriptor = await api.realtimeGetRawDescriptor('public-room') as unknown as { connectToken: string; wsUrl: string; token: string; channel: string };
108+
const privateDescriptor = await api.realtimeGetRawDescriptor('private-room') as unknown as { channel: string; token: string };
109+
110+
const { connectToken, wsUrl } = publicDescriptor;
111+
const { channel: privateChannel } = privateDescriptor;
112+
113+
assert.ok(connectToken, 'Descriptor should include a connectToken');
114+
assert.ok(wsUrl, 'Descriptor should include a wsUrl');
115+
assert.ok(privateChannel, 'Private descriptor should include a channel path');
116+
117+
// Open a raw WebSocket using the connect token for connection auth
118+
const ws = new WebSocket(`${wsUrl}?token=${encodeURIComponent(connectToken)}`);
119+
120+
const reply = await new Promise<{ type: string; channel?: string; message?: string }>((resolve, reject) => {
121+
const timer = globalThis.setTimeout(() => {
122+
ws.close();
123+
reject(new Error('No reply from server within 5s'));
124+
}, 5000);
125+
126+
ws.onopen = () => {
127+
// Attempt to subscribe using the connect token AS the channel token.
128+
// This should be rejected — connect tokens are scoped to the instance
129+
// prefix ($connect) and must not authorize channel subscriptions.
130+
ws.send(JSON.stringify({
131+
action: 'subscribe',
132+
channel: privateChannel,
133+
token: connectToken,
134+
}));
135+
};
136+
137+
ws.onmessage = (event) => {
138+
clearTimeout(timer);
139+
resolve(JSON.parse(event.data as string));
140+
};
141+
142+
ws.onerror = () => {
143+
clearTimeout(timer);
144+
reject(new Error('WebSocket error'));
145+
};
146+
});
147+
148+
ws.close();
149+
150+
// The server must reject the subscription — a connect token is NOT a valid channel token
151+
assert.strictEqual(reply.type, 'error', 'Server should reject subscribe with a connect token used as channel token');
152+
assert.strictEqual(reply.channel, privateChannel, 'Error should reference the requested channel');
153+
assert.ok(
154+
reply.message && reply.message.includes('Unauthorized'),
155+
`Expected an Unauthorized error message, got: ${reply.message}`,
156+
);
157+
});
158+
});
159+
101160
describe('Schema Validation', () => {
102161
test('publish with wrong shape is rejected', async () => {
103162
const api = getApi();

0 commit comments

Comments
 (0)