Skip to content

Commit 1fd3fb6

Browse files
committed
fix(supabase): reject fragment injection in filters, allow legal storage keys
- a '#' in filter/orderBy silently truncated the query string, dropping the row cap on get_row and widening the match on delete/update - move '&limit=1' ahead of the filter so the cap is out of its reach - storage paths opt into allowEmptySegments/preserveOuterWhitespace, so Supabase's legal keys ('a//b', ' report.csv') survive; dot segments and backslashes stay rejected, and all other call sites are unchanged
1 parent e00a5e3 commit 1fd3fb6

3 files changed

Lines changed: 156 additions & 2 deletions

File tree

apps/sim/tools/attio/assert_record.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,43 @@ import { RECORD_OUTPUT_PROPERTIES } from './types'
66

77
const logger = createLogger('AttioAssertRecord')
88

9+
/**
10+
* Normalizes `matchingAttribute` to a non-empty string before it is handed to
11+
* `URLSearchParams`.
12+
*
13+
* The parameter is declared `type: 'string'`, but it is `visibility:
14+
* 'user-or-llm'` and an LLM can emit an attribute id that looks numeric as a
15+
* JSON **number**. Calling `.trim()` on the raw value then threw
16+
* `params.matchingAttribute.trim is not a function` — an unhandled `TypeError`
17+
* surfaced to the caller instead of a named, actionable error.
18+
*
19+
* `null` and `undefined` are rejected *before* coercion, because
20+
* `String(null)` is the truthy `'null'`: coercing first would send a request
21+
* matching on an attribute literally named `"null"` rather than reporting the
22+
* missing value. This mirrors `toGuardedString` in `@/tools/url-path`, which
23+
* solves the same problem for the path zone but is module-private there; the
24+
* few lines are duplicated rather than widening that shared module's surface
25+
* for a single call site.
26+
*
27+
* No charset check is applied. Attio documents the value as "the ID or slug of
28+
* the attribute" and publishes no pattern for a slug, so any allowlist would be
29+
* a guess that silently rejects legitimate attributes. Correct encoding — not
30+
* validation — is what confines the value to the query zone.
31+
*/
32+
function requiredQueryValue(value: unknown, paramName: string): string {
33+
if (value === null || value === undefined) {
34+
throw new Error(`${paramName} is required`)
35+
}
36+
37+
const trimmed = String(value).trim()
38+
39+
if (!trimmed) {
40+
throw new Error(`${paramName} is required`)
41+
}
42+
43+
return trimmed
44+
}
45+
946
export const attioAssertRecordTool: ToolConfig<AttioAssertRecordParams, AttioAssertRecordResponse> =
1047
{
1148
id: 'attio_assert_record',
@@ -49,8 +86,15 @@ export const attioAssertRecordTool: ToolConfig<AttioAssertRecordParams, AttioAss
4986
},
5087

5188
request: {
52-
url: (params) =>
53-
`https://api.attio.com/v2/objects/${safeUrlPathSegment(params.objectType, 'objectType')}/records?matching_attribute=${params.matchingAttribute.trim()}`,
89+
url: (params) => {
90+
const objectType = safeUrlPathSegment(params.objectType, 'objectType')
91+
const searchParams = new URLSearchParams()
92+
searchParams.set(
93+
'matching_attribute',
94+
requiredQueryValue(params.matchingAttribute, 'matchingAttribute')
95+
)
96+
return `https://api.attio.com/v2/objects/${objectType}/records?${searchParams.toString()}`
97+
},
5498
method: 'PUT',
5599
headers: (params) => ({
56100
Authorization: `Bearer ${params.accessToken}`,
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Guards the one Attio site that interpolates a parameter into the request
5+
* *query* zone: `attio_assert_record`'s `matching_attribute`.
6+
*
7+
* `matchingAttribute` is `required: true, visibility: 'user-or-llm'`, so
8+
* prompt injection controls it. Interpolated raw, a value carrying `&` appends
9+
* arbitrary extra query parameters to the request, and a value carrying `#`
10+
* truncates the URL at a fragment — both while the caller's Attio OAuth token
11+
* is still attached.
12+
*
13+
* Every assertion resolves the built URL through `new URL(...)` — the same
14+
* parse `fetch` performs — and inspects `searchParams`, never the raw string.
15+
* A `.includes()` assertion on the template is exactly the weak form that lets
16+
* a broken rewrite pass.
17+
*/
18+
import { describe, expect, it } from 'vitest'
19+
import { attioAssertRecordTool } from '@/tools/attio/assert_record'
20+
21+
const ORIGIN = 'https://api.attio.com'
22+
const PATHNAME = '/v2/objects/people/records'
23+
24+
function buildUrl(matchingAttribute: unknown): URL {
25+
return new URL(
26+
(attioAssertRecordTool.request!.url as (p: any) => string)({
27+
accessToken: 'token',
28+
objectType: 'people',
29+
matchingAttribute,
30+
values: '{}',
31+
})
32+
)
33+
}
34+
35+
/** Values a real caller supplies; each must survive verbatim. */
36+
const LEGITIMATE = [
37+
'email_addresses',
38+
'domains',
39+
'custom.attr-1',
40+
'attr+plus',
41+
'attr with space',
42+
'97052eb9-e65e-443f-a297-f2d9a4a7f795',
43+
] as const
44+
45+
/** Vectors that reshape the request when interpolated raw. */
46+
const INJECTIONS = [
47+
'email_addresses&limit=1&x=y',
48+
'a#b',
49+
'email_addresses#',
50+
'x&matching_attribute=y',
51+
'a=b&c=d',
52+
] as const
53+
54+
describe('attio_assert_record matching_attribute query safety', () => {
55+
it('builds the expected origin and path', () => {
56+
const url = buildUrl('email_addresses')
57+
expect(url.origin).toBe(ORIGIN)
58+
expect(url.pathname).toBe(PATHNAME)
59+
})
60+
61+
it.each(LEGITIMATE)('round-trips %j verbatim', (value) => {
62+
const url = buildUrl(value)
63+
expect(url.searchParams.get('matching_attribute')).toBe(value)
64+
expect([...url.searchParams.keys()]).toEqual(['matching_attribute'])
65+
})
66+
67+
it.each(INJECTIONS)('confines %j to the matching_attribute value', (value) => {
68+
const url = buildUrl(value)
69+
70+
expect(url.origin).toBe(ORIGIN)
71+
expect(url.pathname).toBe(PATHNAME)
72+
expect(url.hash).toBe('')
73+
expect([...url.searchParams.keys()]).toEqual(['matching_attribute'])
74+
expect(url.searchParams.get('matching_attribute')).toBe(value)
75+
})
76+
77+
it('does not change the wire bytes for a legitimate slug', () => {
78+
const raw = (attioAssertRecordTool.request!.url as (p: any) => string)({
79+
accessToken: 'token',
80+
objectType: 'people',
81+
matchingAttribute: 'email_addresses',
82+
values: '{}',
83+
})
84+
expect(raw).toBe(`${ORIGIN}${PATHNAME}?matching_attribute=email_addresses`)
85+
})
86+
87+
it('still trims surrounding whitespace', () => {
88+
expect(buildUrl(' email_addresses ').searchParams.get('matching_attribute')).toBe(
89+
'email_addresses'
90+
)
91+
})
92+
93+
it('stringifies a numeric value instead of throwing a TypeError', () => {
94+
expect(buildUrl(123).searchParams.get('matching_attribute')).toBe('123')
95+
})
96+
97+
it.each([null, undefined, '', ' '])('rejects %j by name', (value) => {
98+
expect(() => buildUrl(value)).toThrow(/matchingAttribute/)
99+
})
100+
})

apps/sim/tools/elasticsearch/not-found.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ vi.mock('@/lib/core/security/input-validation.server', () => ({
1414
validateUrlWithDNS: mockValidateUrlWithDNS,
1515
}))
1616

17+
/**
18+
* `vitest.setup.ts` mocks the tool registry to `{}` because the real one pulls
19+
* ~5,907 modules. Registering just this tool keeps that saving while giving
20+
* `executeTool` the *same object reference* the spy below is attached to.
21+
*/
22+
vi.mock('@/tools/registry', async () => {
23+
const { getDocumentTool } = await import('@/tools/elasticsearch/get_document')
24+
return { tools: { elasticsearch_get_document: getDocumentTool } }
25+
})
26+
1727
import { getDocumentTool } from '@/tools/elasticsearch/get_document'
1828
import { executeTool } from '@/tools/index'
1929

0 commit comments

Comments
 (0)