Skip to content

Commit fb0da46

Browse files
authored
Harden WAB transport request provenance (#366)
* Harden WAB transport observability * Address WAB transport review findings
1 parent ac8128d commit fb0da46

2 files changed

Lines changed: 232 additions & 56 deletions

File tree

‎packages/wallet/wallet-toolbox/src/wab-client/WABTransport.ts‎

Lines changed: 163 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,20 @@ export type WABClientErrorCode =
1818
| 'WAB_NETWORK_ERROR'
1919
| 'WAB_TIMEOUT'
2020
| 'WAB_HTTP_ERROR'
21+
| 'WAB_ENDPOINT_MISMATCH'
2122
| 'WAB_REQUEST_TOO_LARGE'
2223
| 'WAB_RESPONSE_TOO_LARGE'
2324
| 'WAB_INVALID_RESPONSE'
2425

26+
export interface WABClientErrorOptions {
27+
cause?: unknown
28+
correlationId?: string
29+
operation?: string
30+
route?: string
31+
endpointMarkerPresent?: boolean
32+
responseCorrelationMatched?: boolean
33+
}
34+
2535
/**
2636
* A privacy-safe WAB transport failure. Response bodies and request payloads
2737
* are deliberately excluded from the error.
@@ -32,11 +42,22 @@ export class WABClientError extends Error {
3242
message: string,
3343
public readonly retryable: boolean,
3444
public readonly status?: number,
35-
options?: { cause?: unknown }
45+
options: WABClientErrorOptions = {}
3646
) {
3747
super(message, options)
3848
this.name = 'WABClientError'
49+
this.correlationId = options.correlationId
50+
this.operation = options.operation
51+
this.route = options.route
52+
this.endpointMarkerPresent = options.endpointMarkerPresent
53+
this.responseCorrelationMatched = options.responseCorrelationMatched
3954
}
55+
56+
public readonly correlationId?: string
57+
public readonly operation?: string
58+
public readonly route?: string
59+
public readonly endpointMarkerPresent?: boolean
60+
public readonly responseCorrelationMatched?: boolean
4061
}
4162

4263
export interface WABTransportOptions {
@@ -125,6 +146,14 @@ function isRetryableStatus (status: number): boolean {
125146
return status === 408 || status === 425 || status === 429 || status >= 500
126147
}
127148

149+
function isSafeCorrelationId (value: string): boolean {
150+
return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)
151+
}
152+
153+
function isWabResponse (response: Response): boolean {
154+
return response.headers.get('X-WAB-Service')?.toLowerCase() === 'wab-server'
155+
}
156+
128157
/**
129158
* Centralized, bounded transport used by every WAB client operation.
130159
*
@@ -175,14 +204,25 @@ export class WABTransport {
175204
this.telemetry = new Telemetry(options.telemetry)
176205
}
177206

178-
createCorrelationId (): string | undefined {
179-
return this.telemetry.enabled ? this.telemetry.createCorrelationId() : undefined
207+
createCorrelationId (): string {
208+
const correlationId = this.telemetry.createCorrelationId()
209+
return isSafeCorrelationId(correlationId)
210+
? correlationId
211+
: new Telemetry().createCorrelationId()
180212
}
181213

182214
async request<T>(path: string, options: WABRequestOptions): Promise<T> {
183215
assertSafePath(path)
184216
const method = options.method ?? 'POST'
185-
const correlationId = options.correlationId ?? this.createCorrelationId()
217+
const correlationId = options.correlationId != null &&
218+
isSafeCorrelationId(options.correlationId)
219+
? options.correlationId
220+
: this.createCorrelationId()
221+
const requestContext: WABClientErrorOptions = {
222+
correlationId,
223+
operation: options.operation,
224+
route: path
225+
}
186226
const startedAt = Date.now()
187227
const controller = new AbortController()
188228
let timer: ReturnType<typeof setTimeout> | undefined
@@ -210,7 +250,7 @@ export class WABTransport {
210250
'WAB request payload could not be encoded.',
211251
false,
212252
undefined,
213-
{ cause: error }
253+
{ ...requestContext, cause: error }
214254
)
215255
this.captureFailure(options.operation, method, path, correlationId, startedAt, normalized)
216256
throw normalized
@@ -219,7 +259,9 @@ export class WABTransport {
219259
const error = new WABClientError(
220260
'WAB_INVALID_REQUEST',
221261
'WAB request payload must be JSON-serializable.',
222-
false
262+
false,
263+
undefined,
264+
requestContext
223265
)
224266
this.captureFailure(options.operation, method, path, correlationId, startedAt, error)
225267
throw error
@@ -228,7 +270,9 @@ export class WABTransport {
228270
const error = new WABClientError(
229271
'WAB_REQUEST_TOO_LARGE',
230272
'WAB request exceeded the configured size limit.',
231-
false
273+
false,
274+
undefined,
275+
requestContext
232276
)
233277
this.captureFailure(options.operation, method, path, correlationId, startedAt, error)
234278
throw error
@@ -240,7 +284,7 @@ export class WABTransport {
240284
headers: {
241285
Accept: 'application/json',
242286
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
243-
...(correlationId !== undefined ? { 'X-Correlation-ID': correlationId } : {})
287+
'X-Correlation-ID': correlationId
244288
},
245289
...(body !== undefined ? { body } : {}),
246290
signal: controller.signal,
@@ -255,7 +299,13 @@ export class WABTransport {
255299
timer = setTimeout(() => {
256300
timedOut = true
257301
controller.abort()
258-
reject(new WABClientError('WAB_TIMEOUT', 'WAB request timed out.', true))
302+
reject(new WABClientError(
303+
'WAB_TIMEOUT',
304+
'WAB request timed out.',
305+
true,
306+
undefined,
307+
requestContext
308+
))
259309
}, this.timeoutMs)
260310
})
261311

@@ -271,19 +321,32 @@ export class WABTransport {
271321
timedOut ? 'WAB request timed out.' : 'WAB request failed before receiving a response.',
272322
true,
273323
undefined,
274-
{ cause: error }
324+
{ ...requestContext, cause: error }
275325
)
276326
this.captureFailure(options.operation, method, path, correlationId, startedAt, normalized)
277327
throw normalized
278328
}
279329

330+
const endpointMarkerPresent = isWabResponse(response)
331+
const responseCorrelationMatched =
332+
response.headers.get('X-Correlation-ID') === correlationId
333+
const responseContext: WABClientErrorOptions = {
334+
...requestContext,
335+
endpointMarkerPresent,
336+
responseCorrelationMatched
337+
}
338+
280339
if (!response.ok) {
281340
if (timer !== undefined) clearTimeout(timer)
341+
const endpointMismatch = response.status === 404 && !endpointMarkerPresent
282342
const error = new WABClientError(
283-
'WAB_HTTP_ERROR',
284-
`WAB request failed with HTTP status ${response.status}.`,
343+
endpointMismatch ? 'WAB_ENDPOINT_MISMATCH' : 'WAB_HTTP_ERROR',
344+
endpointMismatch
345+
? 'Configured WAB endpoint did not return a compatible WAB response.'
346+
: `WAB request failed with HTTP status ${response.status}.`,
285347
isRetryableStatus(response.status),
286-
response.status
348+
response.status,
349+
responseContext
287350
)
288351
this.captureFailure(options.operation, method, path, correlationId, startedAt, error)
289352
void response.body?.cancel().catch(() => { /* best effort only */ })
@@ -293,7 +356,7 @@ export class WABTransport {
293356
let responseText: string
294357
try {
295358
responseText = await Promise.race([
296-
this.readBoundedResponse(response),
359+
this.readBoundedResponse(response, responseContext),
297360
timeoutPromise
298361
])
299362
} catch (error) {
@@ -304,7 +367,7 @@ export class WABTransport {
304367
timedOut ? 'WAB request timed out.' : 'WAB response could not be read.',
305368
true,
306369
response.status,
307-
{ cause: error }
370+
{ ...responseContext, cause: error }
308371
)
309372
this.captureFailure(options.operation, method, path, correlationId, startedAt, normalized)
310373
throw normalized
@@ -321,7 +384,7 @@ export class WABTransport {
321384
'WAB response was not valid JSON.',
322385
true,
323386
response.status,
324-
{ cause: error }
387+
{ ...responseContext, cause: error }
325388
)
326389
this.captureFailure(options.operation, method, path, correlationId, startedAt, normalized)
327390
throw normalized
@@ -331,7 +394,8 @@ export class WABTransport {
331394
'WAB_INVALID_RESPONSE',
332395
'WAB response must be a JSON object.',
333396
true,
334-
response.status
397+
response.status,
398+
responseContext
335399
)
336400
this.captureFailure(options.operation, method, path, correlationId, startedAt, error)
337401
throw error
@@ -348,43 +412,56 @@ export class WABTransport {
348412
route: path,
349413
serverOrigin: this.serverOrigin,
350414
status: response.status,
415+
endpointMarkerPresent,
416+
responseCorrelationMatched,
351417
responseBytes: new TextEncoder().encode(responseText).byteLength,
352418
durationMs: Date.now() - startedAt
353419
}
354420
})
355421
return parsed as T
356422
}
357423

358-
private async readBoundedResponse (response: Response): Promise<string> {
359-
const contentLength = Number(response.headers.get('content-length'))
360-
if (Number.isFinite(contentLength) && contentLength > this.maxResponseBytes) {
361-
try {
362-
await response.body?.cancel()
363-
} catch {
364-
// Best effort only.
365-
}
366-
throw new WABClientError(
367-
'WAB_RESPONSE_TOO_LARGE',
368-
'WAB response exceeded the configured size limit.',
369-
false,
370-
response.status
371-
)
372-
}
424+
private async readBoundedResponse (
425+
response: Response,
426+
responseContext: WABClientErrorOptions
427+
): Promise<string> {
428+
await this.rejectOversizedDeclaredResponse(response, responseContext)
373429

374430
const reader = response.body?.getReader()
375431
if (reader == null) {
376-
const bytes = new Uint8Array(await response.arrayBuffer())
377-
if (bytes.byteLength > this.maxResponseBytes) {
378-
throw new WABClientError(
379-
'WAB_RESPONSE_TOO_LARGE',
380-
'WAB response exceeded the configured size limit.',
381-
false,
382-
response.status
383-
)
384-
}
385-
return new TextDecoder().decode(bytes)
432+
return await this.readBoundedArrayBuffer(response, responseContext)
433+
}
434+
435+
return await this.readBoundedStream(reader, response, responseContext)
436+
}
437+
438+
private async rejectOversizedDeclaredResponse (
439+
response: Response,
440+
responseContext: WABClientErrorOptions
441+
): Promise<void> {
442+
const contentLength = Number(response.headers.get('content-length'))
443+
if (!Number.isFinite(contentLength) || contentLength <= this.maxResponseBytes) return
444+
445+
await this.cancelResponseBody(response)
446+
throw this.responseTooLargeError(response, responseContext)
447+
}
448+
449+
private async readBoundedArrayBuffer (
450+
response: Response,
451+
responseContext: WABClientErrorOptions
452+
): Promise<string> {
453+
const bytes = new Uint8Array(await response.arrayBuffer())
454+
if (bytes.byteLength > this.maxResponseBytes) {
455+
throw this.responseTooLargeError(response, responseContext)
386456
}
457+
return new TextDecoder().decode(bytes)
458+
}
387459

460+
private async readBoundedStream (
461+
reader: ReadableStreamDefaultReader<Uint8Array>,
462+
response: Response,
463+
responseContext: WABClientErrorOptions
464+
): Promise<string> {
388465
const chunks: Uint8Array[] = []
389466
let total = 0
390467
while (true) {
@@ -393,21 +470,16 @@ export class WABTransport {
393470
if (value == null) continue
394471
total += value.byteLength
395472
if (total > this.maxResponseBytes) {
396-
try {
397-
await reader.cancel()
398-
} catch {
399-
// Best effort only.
400-
}
401-
throw new WABClientError(
402-
'WAB_RESPONSE_TOO_LARGE',
403-
'WAB response exceeded the configured size limit.',
404-
false,
405-
response.status
406-
)
473+
await this.cancelResponseReader(reader)
474+
throw this.responseTooLargeError(response, responseContext)
407475
}
408476
chunks.push(value)
409477
}
410478

479+
return this.decodeChunks(chunks, total)
480+
}
481+
482+
private decodeChunks (chunks: Uint8Array[], total: number): string {
411483
const bytes = new Uint8Array(total)
412484
let offset = 0
413485
for (const chunk of chunks) {
@@ -417,11 +489,42 @@ export class WABTransport {
417489
return new TextDecoder().decode(bytes)
418490
}
419491

492+
private responseTooLargeError (
493+
response: Response,
494+
responseContext: WABClientErrorOptions
495+
): WABClientError {
496+
return new WABClientError(
497+
'WAB_RESPONSE_TOO_LARGE',
498+
'WAB response exceeded the configured size limit.',
499+
false,
500+
response.status,
501+
responseContext
502+
)
503+
}
504+
505+
private async cancelResponseBody (response: Response): Promise<void> {
506+
try {
507+
await response.body?.cancel()
508+
} catch {
509+
// Best effort only.
510+
}
511+
}
512+
513+
private async cancelResponseReader (
514+
reader: ReadableStreamDefaultReader<Uint8Array>
515+
): Promise<void> {
516+
try {
517+
await reader.cancel()
518+
} catch {
519+
// Best effort only.
520+
}
521+
}
522+
420523
private captureFailure (
421524
operation: string,
422525
method: 'GET' | 'POST',
423526
path: string,
424-
correlationId: string | undefined,
527+
correlationId: string,
425528
startedAt: number,
426529
error: WABClientError
427530
): void {
@@ -437,6 +540,12 @@ export class WABTransport {
437540
serverOrigin: this.serverOrigin,
438541
retryable: error.retryable,
439542
...(error.status !== undefined ? { status: error.status } : {}),
543+
...(error.endpointMarkerPresent !== undefined
544+
? { endpointMarkerPresent: error.endpointMarkerPresent }
545+
: {}),
546+
...(error.responseCorrelationMatched !== undefined
547+
? { responseCorrelationMatched: error.responseCorrelationMatched }
548+
: {}),
440549
durationMs: Date.now() - startedAt
441550
},
442551
error

0 commit comments

Comments
 (0)