Skip to content

Commit 12fd062

Browse files
committed
Add SelectionContext codec and stale-mutation guard
1 parent 24fd450 commit 12fd062

3 files changed

Lines changed: 191 additions & 0 deletions

File tree

‎src/core/selection-context.ts‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { b64uDecodeString, b64uEncode } from './base64.js'
2+
3+
export const SELECTION_CONTEXT_HEADER = 'X-OA-Selection-Context'
4+
export const MAX_SELECTION_CONTEXT_LENGTH = 2048
5+
const MAX_ID_LENGTH = 256
6+
7+
/** Safe selectors, never credentials, subjects, emails, roles, or authority. */
8+
export interface SelectionContext {
9+
browserSetId: string
10+
sessionId: string | null
11+
version: number
12+
}
13+
14+
function validId(value: unknown): value is string {
15+
return typeof value === 'string' && value.length > 0 && value.length <= MAX_ID_LENGTH
16+
}
17+
18+
function validContext(value: unknown): value is SelectionContext {
19+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
20+
const context = value as Partial<SelectionContext>
21+
return validId(context.browserSetId)
22+
&& (context.sessionId === null || validId(context.sessionId))
23+
&& typeof context.version === 'number'
24+
&& Number.isSafeInteger(context.version) && context.version >= 0
25+
&& !Object.is(context.version, -0)
26+
}
27+
28+
/** Canonical unpadded base64url JSON; opaque to clients, not encrypted or signed. */
29+
export function serializeSelectionContext(context: SelectionContext): string {
30+
if (!validContext(context)) throw new TypeError('Invalid selection context')
31+
const { browserSetId, sessionId, version } = context
32+
const header = b64uEncode(new TextEncoder().encode(JSON.stringify({ browserSetId, sessionId, version })))
33+
if (header.length > MAX_SELECTION_CONTEXT_LENGTH) throw new RangeError('Selection context is too large')
34+
return header
35+
}
36+
37+
/** Missing, oversized, noncanonical, or malformed assertions are invalid. */
38+
export function parseSelectionContext(header: string | null): SelectionContext | null {
39+
if (!header || header.length > MAX_SELECTION_CONTEXT_LENGTH || !/^[A-Za-z0-9_-]+$/.test(header)) return null
40+
// Invalid client input is expected; invalid trusted server contexts throw instead.
41+
try {
42+
const context: unknown = JSON.parse(b64uDecodeString(header))
43+
if (!validContext(context) || serializeSelectionContext(context) !== header) return null
44+
return context
45+
} catch {
46+
return null
47+
}
48+
}
49+
50+
/**
51+
* Persist this increment atomically with every selection change (including
52+
* sign-out and A -> B -> A). Never reset/reuse a version within a browser set.
53+
* At exhaustion, fail closed or establish a new, unique browser-set ID.
54+
*/
55+
export function nextSelectionVersion(version: number): number {
56+
if (!Number.isSafeInteger(version) || version < 0 || Object.is(version, -0)) {
57+
throw new TypeError('Invalid selection version')
58+
}
59+
if (version === Number.MAX_SAFE_INTEGER) throw new RangeError('Selection version exhausted')
60+
return version + 1
61+
}
62+
63+
/**
64+
* Run after authentication/authorization, before mutation side effects. `current`
65+
* must come from the caller's validated selection for this gate and browser set;
66+
* browser-set IDs must not be reused across gates. Retain that authenticated
67+
* actor for the request, and coordinate concurrent switches/mutations in the
68+
* caller's transaction when necessary. This comparison is not a store/CAS lock.
69+
*
70+
* The header is only a concurrency assertion, NOT authentication or CSRF
71+
* protection. A match even for sessionId:null confers no authority. On conflict,
72+
* refresh selection and ask the user to review; never automatically replay a
73+
* mutation under the new identity. No client field supplies an actor.
74+
*/
75+
export function checkSelectionContext(
76+
intendedHeader: string | null,
77+
current: SelectionContext,
78+
): Response | null {
79+
const actual = serializeSelectionContext(current)
80+
const intended = parseSelectionContext(intendedHeader)
81+
if (intended !== null && intendedHeader === actual) return null
82+
return new Response(JSON.stringify({
83+
error: 'selection_context_conflict',
84+
recoverable: true,
85+
action: 'refresh_selection',
86+
}) + '\n', {
87+
status: 409,
88+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
89+
})
90+
}

‎src/index.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ export {
7373
verifySessionClaims,
7474
} from './core/session.js'
7575
export type { CookieOpts, SessionClaims } from './core/session.js'
76+
export {
77+
SELECTION_CONTEXT_HEADER,
78+
MAX_SELECTION_CONTEXT_LENGTH,
79+
serializeSelectionContext,
80+
parseSelectionContext,
81+
nextSelectionVersion,
82+
checkSelectionContext,
83+
} from './core/selection-context.js'
84+
export type { SelectionContext } from './core/selection-context.js'
7685
export type {
7786
AllowEntry,
7887
AllowlistStore,

‎test/selection-context.test.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
checkSelectionContext,
4+
MAX_SELECTION_CONTEXT_LENGTH,
5+
nextSelectionVersion,
6+
parseSelectionContext,
7+
SELECTION_CONTEXT_HEADER,
8+
serializeSelectionContext,
9+
type SelectionContext,
10+
} from '../src/core/selection-context.js'
11+
12+
const current: SelectionContext = { browserSetId: 'browser-1', sessionId: 'session-A', version: 4 }
13+
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url')
14+
15+
describe('selection context', () => {
16+
it('serializes a canonical header and round-trips safe metadata only', () => {
17+
expect(SELECTION_CONTEXT_HEADER).toBe('X-OA-Selection-Context')
18+
const header = serializeSelectionContext(current)
19+
expect(header).toBe(encode({ browserSetId: 'browser-1', sessionId: 'session-A', version: 4 }))
20+
expect(parseSelectionContext(header)).toEqual(current)
21+
expect(serializeSelectionContext({ version: 4, sessionId: 'session-A', browserSetId: 'browser-1' })).toBe(header)
22+
expect(serializeSelectionContext({ ...current, actor: 'forged' } as SelectionContext)).toBe(header)
23+
expect(parseSelectionContext(encode({ ...current, actor: 'forged' }))).toBe(null)
24+
})
25+
26+
it.each([null, 'session-A', 'unicode-\u00e9'])('accepts exact selection %s without granting authority', sessionId => {
27+
const context = { ...current, sessionId }
28+
expect(parseSelectionContext(serializeSelectionContext(context))).toEqual(context)
29+
expect(checkSelectionContext(serializeSelectionContext(context), context)).toBe(null)
30+
})
31+
32+
it.each([
33+
null, '', '!', 'e30=', 'e30', 'x'.repeat(MAX_SELECTION_CONTEXT_LENGTH + 1),
34+
encode(null), encode([]), encode({}),
35+
encode({ ...current, version: -1 }), encode({ ...current, version: 0.5 }),
36+
encode({ ...current, version: Number.MAX_SAFE_INTEGER + 1 }),
37+
encode({ ...current, version: '4' }), encode({ ...current, sessionId: 1 }),
38+
encode({ ...current, browserSetId: '' }), encode({ ...current, sessionId: '' }),
39+
encode({ ...current, browserSetId: 'x'.repeat(257) }),
40+
encode({ ...current, sessionId: 'x'.repeat(257) }),
41+
encode({ version: 4, sessionId: 'session-A', browserSetId: 'browser-1' }),
42+
Buffer.from('{"browserSetId":"browser-1","sessionId":"session-A","version":4,"version":4}').toString('base64url'),
43+
])('rejects absent or malformed headers: %s', async header => {
44+
expect(parseSelectionContext(header)).toBe(null)
45+
const response = checkSelectionContext(header, current)!
46+
expect(response.status).toBe(409)
47+
expect(Object.fromEntries(response.headers)).toEqual({
48+
'cache-control': 'no-store',
49+
'content-type': 'application/json; charset=utf-8',
50+
})
51+
expect(await response.text()).toBe('{"error":"selection_context_conflict","recoverable":true,"action":"refresh_selection"}\n')
52+
})
53+
54+
it.each([
55+
{ ...current, version: 3 },
56+
{ ...current, version: 5 },
57+
{ ...current, sessionId: 'session-B' },
58+
{ ...current, sessionId: null },
59+
{ ...current, browserSetId: 'other-browser-or-gate' },
60+
])('rejects stale, future, signed-out, and cross-browser/gate assertions', async intended => {
61+
const response = checkSelectionContext(serializeSelectionContext(intended), current)!
62+
expect(response.status).toBe(409)
63+
expect(response.headers.get('cache-control')).toBe('no-store')
64+
expect(await response.json()).toEqual({ error: 'selection_context_conflict', recoverable: true, action: 'refresh_selection' })
65+
})
66+
67+
it('detects A -> B -> A and sign-out without resetting versions', () => {
68+
const before = serializeSelectionContext(current)
69+
const switched = { ...current, sessionId: 'session-B', version: nextSelectionVersion(current.version) }
70+
const returned = { ...current, version: nextSelectionVersion(switched.version) }
71+
const signedOut = { ...returned, sessionId: null, version: nextSelectionVersion(returned.version) }
72+
expect([switched.version, returned.version, signedOut.version]).toEqual([5, 6, 7])
73+
expect(checkSelectionContext(before, returned)!.status).toBe(409)
74+
expect(checkSelectionContext(before, signedOut)!.status).toBe(409)
75+
})
76+
77+
it.each([-1, -0, 0.1, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])('throws for invalid trusted version %s', version => {
78+
expect(() => serializeSelectionContext({ ...current, version })).toThrow('Invalid selection context')
79+
expect(() => checkSelectionContext(null, { ...current, version })).toThrow('Invalid selection context')
80+
expect(() => nextSelectionVersion(version)).toThrow('Invalid selection version')
81+
})
82+
83+
it('supports safe integer boundaries and refuses overflow', () => {
84+
for (const version of [0, Number.MAX_SAFE_INTEGER]) {
85+
const context = { ...current, version }
86+
expect(parseSelectionContext(serializeSelectionContext(context))).toEqual(context)
87+
}
88+
expect(nextSelectionVersion(0)).toBe(1)
89+
expect(nextSelectionVersion(Number.MAX_SAFE_INTEGER - 1)).toBe(Number.MAX_SAFE_INTEGER)
90+
expect(() => nextSelectionVersion(Number.MAX_SAFE_INTEGER)).toThrow('Selection version exhausted')
91+
})
92+
})

0 commit comments

Comments
 (0)