Skip to content

Commit 6dace54

Browse files
committed
fix(box_sign): refuse a padded signRequestId instead of silently resolving it
Sweeping the six services for the same class found in BigQuery turned up a second instance nobody had flagged. signRequestId was interpolated raw before this branch — not even a .trim() — so a padded id was percent-encoded to %20%20<uuid>%20%20, matched no sign request, and the call failed: before: /2.0/sign_requests/%20%20<uuid>%20%20/cancel -> 404, no-op after: /2.0/sign_requests/<uuid>/cancel -> cancels it box_sign_cancel_request is irreversible, so trimming would have converted a request that did nothing into one that cancels a real signature request. Extracts the rule shared with BigQuery into strictUrlPathSegment, now that two services need it, and applies it to all three box_sign tools. Box Sign ids are UUIDs, so no legitimate value carries whitespace. The rest of the sweep is clean and deliberately unchanged: google_drive and box already trimmed every path id before this branch, so nothing there is newly resolved; google_contacts and the Supabase storage key move the other way, since safeUrlPath no longer trims at all, which can only turn a previously working value into a clean failure. Pinned the same way as BigQuery and verified non-vacuous: reverting the guard fails both the generic per-pair assertion and the explicit cancel test.
1 parent c61a662 commit 6dace54

6 files changed

Lines changed: 125 additions & 65 deletions

File tree

apps/sim/tools/box_sign/cancel_request.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import { strictUrlPathSegment } from '@/tools/strict-url-path'
12
import type { ToolConfig } from '@/tools/types'
2-
import { safeUrlPathSegment } from '@/tools/url-path'
33
import type { BoxSignCancelRequestParams, BoxSignResponse } from './types'
44
import { SIGN_REQUEST_OUTPUT_PROPERTIES } from './types'
55

@@ -31,7 +31,7 @@ export const boxSignCancelRequestTool: ToolConfig<BoxSignCancelRequestParams, Bo
3131

3232
request: {
3333
url: (params) =>
34-
`https://api.box.com/2.0/sign_requests/${safeUrlPathSegment(params.signRequestId, 'signRequestId')}/cancel`,
34+
`https://api.box.com/2.0/sign_requests/${strictUrlPathSegment(params.signRequestId, 'signRequestId')}/cancel`,
3535
method: 'POST',
3636
headers: (params) => ({
3737
Authorization: `Bearer ${params.accessToken}`,

apps/sim/tools/box_sign/get_request.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import { strictUrlPathSegment } from '@/tools/strict-url-path'
12
import type { ToolConfig } from '@/tools/types'
2-
import { safeUrlPathSegment } from '@/tools/url-path'
33
import type { BoxSignGetRequestParams, BoxSignResponse } from './types'
44
import { SIGN_REQUEST_OUTPUT_PROPERTIES } from './types'
55

@@ -31,7 +31,7 @@ export const boxSignGetRequestTool: ToolConfig<BoxSignGetRequestParams, BoxSignR
3131

3232
request: {
3333
url: (params) =>
34-
`https://api.box.com/2.0/sign_requests/${safeUrlPathSegment(params.signRequestId, 'signRequestId')}`,
34+
`https://api.box.com/2.0/sign_requests/${strictUrlPathSegment(params.signRequestId, 'signRequestId')}`,
3535
method: 'GET',
3636
headers: (params) => ({
3737
Authorization: `Bearer ${params.accessToken}`,

apps/sim/tools/box_sign/path_safety.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,58 @@ describe('box sign path-id traversal safety', () => {
5959
})
6060

6161
describe.each(PATH_PARAMS)('$label', (param) => {
62-
itResistsTraversal(param, { origin: ORIGIN, basePath: BASE_PATH })
62+
itResistsTraversal(param, {
63+
origin: ORIGIN,
64+
basePath: BASE_PATH,
65+
rejectsSurroundingWhitespace: ['signRequestId'],
66+
})
6367
itPassesLegitimateValues(param, { values: LEGITIMATE_IDS })
6468
})
6569
})
70+
71+
/**
72+
* A padded `signRequestId` must not become a successful cancellation.
73+
*
74+
* `signRequestId` was interpolated raw before this branch — not even a
75+
* `.trim()` — so a padded id was percent-encoded to
76+
* `%20%20<uuid>%20%20`, matched no sign request, and the call failed:
77+
*
78+
* ```
79+
* before: /2.0/sign_requests/%20%2012345678-…-123456789012%20%20/cancel
80+
* after: /2.0/sign_requests/12345678-…-123456789012/cancel
81+
* ```
82+
*
83+
* Had the guard simply trimmed, that POST would have stopped failing and
84+
* started **cancelling a real signature request** — irreversible, from a value
85+
* the caller never wrote. Box Sign ids are UUIDs, so no legitimate value
86+
* carries whitespace and refusing costs nothing.
87+
*/
88+
describe('a padded signRequestId cannot become a successful cancellation', () => {
89+
const PADDED = ' 12345678-1234-1234-1234-123456789012 '
90+
const CLEAN = '12345678-1234-1234-1234-123456789012'
91+
92+
const STATE_CHANGING = [
93+
{ name: 'box_sign_cancel_request', tool: boxSignTools.boxSignCancelRequestTool },
94+
{ name: 'box_sign_resend_request', tool: boxSignTools.boxSignResendRequestTool },
95+
]
96+
97+
it.each(STATE_CHANGING)('$name refuses a padded signRequestId', ({ tool }) => {
98+
expect(() =>
99+
(tool.request?.url as (p: Record<string, unknown>) => string)({
100+
accessToken: 't',
101+
signRequestId: PADDED,
102+
})
103+
).toThrow(/signRequestId cannot have leading or trailing whitespace/)
104+
})
105+
106+
it.each(STATE_CHANGING)('$name still accepts the unpadded id', ({ tool }) => {
107+
const url = new URL(
108+
(tool.request?.url as (p: Record<string, unknown>) => string)({
109+
accessToken: 't',
110+
signRequestId: CLEAN,
111+
})
112+
)
113+
114+
expect(url.pathname).toContain(`/2.0/sign_requests/${CLEAN}`)
115+
})
116+
})

apps/sim/tools/box_sign/resend_request.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import { strictUrlPathSegment } from '@/tools/strict-url-path'
12
import type { ToolConfig, ToolResponse } from '@/tools/types'
2-
import { safeUrlPathSegment } from '@/tools/url-path'
33
import type { BoxSignResendRequestParams } from './types'
44

55
export const boxSignResendRequestTool: ToolConfig<BoxSignResendRequestParams, ToolResponse> = {
@@ -30,7 +30,7 @@ export const boxSignResendRequestTool: ToolConfig<BoxSignResendRequestParams, To
3030

3131
request: {
3232
url: (params) =>
33-
`https://api.box.com/2.0/sign_requests/${safeUrlPathSegment(params.signRequestId, 'signRequestId')}/resend`,
33+
`https://api.box.com/2.0/sign_requests/${strictUrlPathSegment(params.signRequestId, 'signRequestId')}/resend`,
3434
method: 'POST',
3535
headers: (params) => ({
3636
Authorization: `Bearer ${params.accessToken}`,
Lines changed: 7 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,6 @@
1+
import { assertNoSurroundingWhitespace, strictUrlPathSegment } from '@/tools/strict-url-path'
12
import { safeUrlPathSegment } from '@/tools/url-path'
23

3-
/**
4-
* Refuses an identifier carrying leading or trailing whitespace.
5-
*
6-
* This exists because trimming is not a neutral convenience on an identifier
7-
* that was **not** trimmed before. Every BigQuery path identifier here was
8-
* previously interpolated as `encodeURIComponent(params.projectId)`, so a padded
9-
* value became `%20%20my-project%20%20`, which names no project — GCP project
10-
* ids match `[a-z][a-z0-9-]{5,29}` and cannot contain whitespace — and the
11-
* request failed cleanly. Guarding the path with `safeUrlPathSegment` trims,
12-
* which silently resolves that same value to the **real** `my-project`:
13-
*
14-
* ```
15-
* before: /bigquery/v2/projects/%20%20my-project%20%20/datasets/prod_dataset -> 404
16-
* after: /bigquery/v2/projects/my-project/datasets/prod_dataset -> deletes it
17-
* ```
18-
*
19-
* On `google_bigquery_delete_dataset` and `google_bigquery_delete_table` that
20-
* converts a request that did nothing into one that destroys a real dataset or
21-
* table, irreversibly. The rule this encodes is therefore narrow and testable:
22-
* **this change must not turn a failing request into a succeeding one.**
23-
*
24-
* Rejection rather than trimming is not a consistency argument — that reasoning
25-
* averages over sites with very different blast radii and is exactly what would
26-
* excuse the deletion above. It stands on two facts specific to these values:
27-
* no legitimate BigQuery identifier contains surrounding whitespace, so nothing
28-
* real is refused; and the pre-existing behaviour for these particular
29-
* parameters was already a clean failure, so refusing preserves it while adding
30-
* an error that names the offending parameter instead of an opaque 404.
31-
*
32-
* Identifiers that this PR did **not** newly trim keep `safeUrlPathSegment`.
33-
* `datasetId` on the two delete tools, for instance, was already
34-
* `.trim()`-ed before this branch, so trimming it is not a change made here and
35-
* refusing it would break callers whose stored value works today. That is a
36-
* real pre-existing hazard, but it is not this change's to introduce or to
37-
* silently alter.
38-
*/
39-
function assertNoSurroundingWhitespace(value: string | number | bigint, paramName: string): void {
40-
if (typeof value === 'string' && value !== value.trim()) {
41-
throw new Error(
42-
`${paramName} cannot have leading or trailing whitespace (received ${JSON.stringify(value)})`
43-
)
44-
}
45-
}
46-
47-
/**
48-
* Path-segment guard for an identifier this change newly began trimming.
49-
*
50-
* See {@link assertNoSurroundingWhitespace} for why padding is refused here
51-
* rather than trimmed away.
52-
*/
53-
export function strictBigQueryPathSegment(
54-
value: string | number | bigint,
55-
paramName: string
56-
): string {
57-
assertNoSurroundingWhitespace(value, paramName)
58-
return safeUrlPathSegment(value, paramName)
59-
}
60-
614
/**
625
* Returns the canonical, unencoded form of an identifier that appears in both
636
* the request path and the request body.
@@ -97,3 +40,9 @@ export function strictCanonicalBigQueryId(
9740
assertNoSurroundingWhitespace(value, paramName)
9841
return canonicalBigQueryId(value, paramName)
9942
}
43+
44+
/**
45+
* Path-segment guard for a BigQuery identifier this change newly began
46+
* trimming. See `strictUrlPathSegment` for why padding is refused.
47+
*/
48+
export const strictBigQueryPathSegment = strictUrlPathSegment

apps/sim/tools/strict-url-path.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { safeUrlPathSegment } from '@/tools/url-path'
2+
3+
/**
4+
* Guards a path identifier that this change **newly began trimming**, refusing
5+
* surrounding whitespace instead of silently removing it.
6+
*
7+
* Trimming is not a neutral convenience when it is new. These identifiers were
8+
* previously interpolated raw or through a bare `encodeURIComponent`, so a
9+
* padded value was percent-encoded and named nothing:
10+
*
11+
* ```
12+
* before: /2.0/sign_requests/%20%20<uuid>%20%20/cancel -> 404, no-op
13+
* after: /2.0/sign_requests/<uuid>/cancel -> cancels it
14+
*
15+
* before: /bigquery/v2/projects/%20%20my-project%20%20/datasets/prod_dataset -> 404, no-op
16+
* after: /bigquery/v2/projects/my-project/datasets/prod_dataset -> deletes it
17+
* ```
18+
*
19+
* On `box_sign_cancel_request` and `google_bigquery_delete_*` that converts a
20+
* request which did nothing into one with an **irreversible** effect, driven by
21+
* a value the caller never wrote. The rule this encodes is therefore narrow and
22+
* testable: *guarding a path must not turn a failing request into a succeeding
23+
* one.*
24+
*
25+
* Rejection is deliberately **not** argued from consistency with the other
26+
* guarded sites. That reasoning averages over parameters with very different
27+
* blast radii and would excuse the deletion above. It rests on two facts
28+
* specific to these values:
29+
*
30+
* 1. None of them can legitimately carry surrounding whitespace — a Box Sign id
31+
* is a UUID, a GCP project id matches `[a-z][a-z0-9-]{5,29}` — so refusing
32+
* excludes nothing a caller could really mean.
33+
* 2. Their previous behaviour was already a clean failure, so refusing
34+
* preserves it, and improves on it by replacing an opaque provider 404 with
35+
* an error naming the parameter.
36+
*
37+
* Identifiers that were **already** trimmed before this change keep plain
38+
* {@link safeUrlPathSegment}: trimming those is not a change made here, and
39+
* refusing them would break callers whose stored value works today.
40+
*/
41+
export function strictUrlPathSegment(value: string | number | bigint, paramName: string): string {
42+
assertNoSurroundingWhitespace(value, paramName)
43+
return safeUrlPathSegment(value, paramName)
44+
}
45+
46+
/**
47+
* Shared precondition behind {@link strictUrlPathSegment} and its body-value
48+
* counterparts, so a padded value is refused identically wherever the same
49+
* identifier is rendered.
50+
*/
51+
export function assertNoSurroundingWhitespace(
52+
value: string | number | bigint,
53+
paramName: string
54+
): void {
55+
if (typeof value === 'string' && value !== value.trim()) {
56+
throw new Error(
57+
`${paramName} cannot have leading or trailing whitespace (received ${JSON.stringify(value)})`
58+
)
59+
}
60+
}

0 commit comments

Comments
 (0)