diff --git a/packages/core/postgrest-js/src/PostgrestFilterBuilder.ts b/packages/core/postgrest-js/src/PostgrestFilterBuilder.ts index cb53017f4c..c5d099a262 100644 --- a/packages/core/postgrest-js/src/PostgrestFilterBuilder.ts +++ b/packages/core/postgrest-js/src/PostgrestFilterBuilder.ts @@ -35,6 +35,24 @@ export type IsStringOperator = Path extends `${string}->>${ const PostgrestReservedCharsRegexp = new RegExp('[,()]') +/** + * Serialize a single value for an `in.(...)` / `not.in.(...)` filter list. + * + * PostgREST treats `,`, `(` and `)` as reserved characters inside the list, so + * any value containing one must be wrapped in double quotes. A quoted value may + * itself contain `"` or `\`, which PostgREST expects to be backslash-escaped + * (`"` -> `\"`, `\` -> `\\`). Without escaping, a value such as `a"b,c` + * serializes to `"a"b,c"`, whose stray quote corrupts the parsed list. + */ +function formatInFilterValue(value: unknown): string { + const str = `${value}` + if (typeof value === 'string' && (PostgrestReservedCharsRegexp.test(str) || /["\\]/.test(str))) { + // Escape backslashes first, then double quotes, then wrap in quotes. + return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` + } + return str +} + // Match relationship filters with `table.column` syntax and resolve underlying // column value. If not matched, fallback to generic type. // TODO: Validate the relationship itself ala select-query-parser. Currently we @@ -835,14 +853,7 @@ export default class PostgrestFilterBuilder< never > ): this { - const cleanedValues = Array.from(new Set(values)) - .map((s) => { - // handle postgrest reserved characters - // https://postgrest.org/en/v7.0.0/api.html#reserved-characters - if (typeof s === 'string' && PostgrestReservedCharsRegexp.test(s)) return `"${s}"` - else return `${s}` - }) - .join(',') + const cleanedValues = Array.from(new Set(values)).map(formatInFilterValue).join(',') this.url.searchParams.append(column, `in.(${cleanedValues})`) return this } @@ -863,14 +874,7 @@ export default class PostgrestFilterBuilder< : never > ): this { - const cleanedValues = Array.from(new Set(values)) - .map((s) => { - // handle postgrest reserved characters - // https://postgrest.org/en/v7.0.0/api.html#reserved-characters - if (typeof s === 'string' && PostgrestReservedCharsRegexp.test(s)) return `"${s}"` - else return `${s}` - }) - .join(',') + const cleanedValues = Array.from(new Set(values)).map(formatInFilterValue).join(',') this.url.searchParams.append(column, `not.in.(${cleanedValues})`) return this } diff --git a/packages/core/postgrest-js/test/in-filter-escaping.test.ts b/packages/core/postgrest-js/test/in-filter-escaping.test.ts new file mode 100644 index 0000000000..e73caca8b5 --- /dev/null +++ b/packages/core/postgrest-js/test/in-filter-escaping.test.ts @@ -0,0 +1,71 @@ +import { PostgrestClient } from '../src/index' + +const REST_URL = 'http://localhost:3000' + +/** + * Build a client whose fetch records the request URL instead of hitting a + * server, so we can assert exactly how `.in()` / `.notIn()` serialize values. + */ +function clientCapturingUrl(): { postgrest: PostgrestClient; getUrl: () => string } { + let captured = '' + const fetchMock: typeof fetch = (input: RequestInfo | URL) => { + captured = typeof input === 'string' ? input : input.toString() + return Promise.resolve(new Response('[]', { status: 200 })) + } + const postgrest = new PostgrestClient(REST_URL, { fetch: fetchMock }) + return { postgrest, getUrl: () => captured } +} + +/** Decode the value of a query parameter from a raw (percent-encoded) URL. */ +function paramValue(rawUrl: string, key: string): string { + const query = rawUrl.split('?')[1] ?? '' + for (const pair of query.split('&')) { + const eq = pair.indexOf('=') + // URLSearchParams encodes spaces as '+', which decodeURIComponent leaves as-is. + const decode = (s: string) => decodeURIComponent(s.replace(/\+/g, ' ')) + const k = decode(pair.slice(0, eq)) + if (k === key) return decode(pair.slice(eq + 1)) + } + throw new Error(`param ${key} not found in ${rawUrl}`) +} + +describe('in() / notIn() reserved-character escaping', () => { + test('quotes values containing reserved characters', async () => { + const { postgrest, getUrl } = clientCapturingUrl() + await postgrest.from('t').select().in('name', ['a,b', 'plain']) + expect(paramValue(getUrl(), 'name')).toBe('in.("a,b",plain)') + }) + + test('escapes an embedded double quote inside a quoted value', async () => { + const { postgrest, getUrl } = clientCapturingUrl() + // Value has both a reserved char (comma) and a double quote. + await postgrest.from('t').select().in('name', ['a"b,c']) + // The inner quote must be backslash-escaped so the quoted value stays intact. + expect(paramValue(getUrl(), 'name')).toBe('in.("a\\"b,c")') + }) + + test('escapes a value that contains only a double quote', async () => { + const { postgrest, getUrl } = clientCapturingUrl() + await postgrest.from('t').select().in('name', ['say "hi"']) + expect(paramValue(getUrl(), 'name')).toBe('in.("say \\"hi\\"")') + }) + + test('escapes backslashes inside a quoted value', async () => { + const { postgrest, getUrl } = clientCapturingUrl() + await postgrest.from('t').select().in('path', ['a\\b,c']) + // Backslash doubled, then wrapped in quotes. + expect(paramValue(getUrl(), 'path')).toBe('in.("a\\\\b,c")') + }) + + test('leaves simple values unquoted', async () => { + const { postgrest, getUrl } = clientCapturingUrl() + await postgrest.from('t').select().in('status', ['ONLINE', 'OFFLINE']) + expect(paramValue(getUrl(), 'status')).toBe('in.(ONLINE,OFFLINE)') + }) + + test('notIn() escapes the same way as in()', async () => { + const { postgrest, getUrl } = clientCapturingUrl() + await postgrest.from('t').select().notIn('name', ['a"b,c']) + expect(paramValue(getUrl(), 'name')).toBe('not.in.("a\\"b,c")') + }) +})