mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
fix(studio): preserve cron HTTP headers containing commas or parentheses (#46830)
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Bug fix. ## What is the current behavior? Closes #46829. When a cron job's command uses `jsonb_build_object(...)` header syntax, `parseCronJobCommand` captures the argument list with `([^)]*)` (stopping at the first `)`) and splits it on every `,`. A header name or value that legitimately contains a comma or parenthesis is split into the wrong pairs, shifting every following header and leaving a trailing header with an undefined value. Because the edit sheet rebuilds the command from these parsed fields, saving a job (even just changing its schedule) silently rewrites its stored headers. ## What is the new behavior? The `jsonb_build_object` argument list is parsed with a scanner that respects single-quoted SQL literals (`''` escapes) and nested parentheses, splitting only on top-level commas. Header names and values containing commas or parentheses now round-trip unchanged. Added four regression tests in `CronJobs.utils.test.ts`. ## Additional context Verified locally: `vitest` cron suite 48/48 pass (the 4 new tests fail without the fix), `tsc --noEmit` clean, ESLint clean, Prettier clean, and `next build` succeeds. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Corrected HTTP header parsing in cron jobs so header values with commas, parentheses, escaped quotes, or escape-string prefixes are preserved and don't corrupt adjacent arguments. * Ensured commas inside header values no longer swallow following body arguments. * **New Features** * Added robust SQL-literal and JSONB-argument parsing to reliably extract name/value pairs from JSONB-style headers. * **Tests** * Added tests covering complex header value cases and whitespace/escaping edge cases. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
This commit is contained in:
2 files changed
+186
-10
No files matched your search
@@ -211,6 +211,116 @@ describe('parseCronJobCommand', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep a jsonb_build_object header value that contains a comma intact', () => {
|
||||
const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:=jsonb_build_object('Accept', 'application/json, text/plain'), timeout_milliseconds:=5000 );`
|
||||
expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
|
||||
endpoint: 'https://example.com/api/endpoint',
|
||||
method: 'POST',
|
||||
httpHeaders: [{ name: 'Accept', value: 'application/json, text/plain' }],
|
||||
httpBody: '',
|
||||
timeoutMs: 5000,
|
||||
type: 'http_request',
|
||||
snippet: command,
|
||||
})
|
||||
})
|
||||
|
||||
it('should not shift later jsonb_build_object headers when an earlier value contains a comma', () => {
|
||||
const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:=jsonb_build_object('Accept', 'application/json, text/plain', 'Authorization', 'Bearer abc'), timeout_milliseconds:=5000 );`
|
||||
expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
|
||||
endpoint: 'https://example.com/api/endpoint',
|
||||
method: 'POST',
|
||||
httpHeaders: [
|
||||
{ name: 'Accept', value: 'application/json, text/plain' },
|
||||
{ name: 'Authorization', value: 'Bearer abc' },
|
||||
],
|
||||
httpBody: '',
|
||||
timeoutMs: 5000,
|
||||
type: 'http_request',
|
||||
snippet: command,
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep a jsonb_build_object header value that contains parentheses intact', () => {
|
||||
const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:=jsonb_build_object('User-Agent', 'Mozilla/5.0 (compatible)'), timeout_milliseconds:=5000 );`
|
||||
expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
|
||||
endpoint: 'https://example.com/api/endpoint',
|
||||
method: 'POST',
|
||||
httpHeaders: [{ name: 'User-Agent', value: 'Mozilla/5.0 (compatible)' }],
|
||||
httpBody: '',
|
||||
timeoutMs: 5000,
|
||||
type: 'http_request',
|
||||
snippet: command,
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep an escaped quote inside a comma-containing jsonb_build_object value', () => {
|
||||
const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:=jsonb_build_object('X-Company', 'O''Reilly, Inc'), timeout_milliseconds:=5000 );`
|
||||
expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
|
||||
endpoint: 'https://example.com/api/endpoint',
|
||||
method: 'POST',
|
||||
httpHeaders: [{ name: 'X-Company', value: "O'Reilly, Inc" }],
|
||||
httpBody: '',
|
||||
timeoutMs: 5000,
|
||||
type: 'http_request',
|
||||
snippet: command,
|
||||
})
|
||||
})
|
||||
|
||||
it('should unescape backslashes in an E-prefixed jsonb_build_object header value', () => {
|
||||
const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:=jsonb_build_object('X-Custom', E'value\\\\here'), timeout_milliseconds:=5000 );`
|
||||
expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
|
||||
endpoint: 'https://example.com/api/endpoint',
|
||||
method: 'POST',
|
||||
httpHeaders: [{ name: 'X-Custom', value: 'value\\here' }],
|
||||
httpBody: '',
|
||||
timeoutMs: 5000,
|
||||
type: 'http_request',
|
||||
snippet: command,
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep later jsonb_build_object headers when an earlier value contains parentheses', () => {
|
||||
const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:=jsonb_build_object('User-Agent', 'Mozilla/5.0 (compatible)', 'Accept', 'application/json'), timeout_milliseconds:=5000 );`
|
||||
expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
|
||||
endpoint: 'https://example.com/api/endpoint',
|
||||
method: 'POST',
|
||||
httpHeaders: [
|
||||
{ name: 'User-Agent', value: 'Mozilla/5.0 (compatible)' },
|
||||
{ name: 'Accept', value: 'application/json' },
|
||||
],
|
||||
httpBody: '',
|
||||
timeoutMs: 5000,
|
||||
type: 'http_request',
|
||||
snippet: command,
|
||||
})
|
||||
})
|
||||
|
||||
it('should parse jsonb_build_object headers when there is whitespace before the opening parenthesis', () => {
|
||||
const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:=jsonb_build_object ('Accept', 'application/json'), timeout_milliseconds:=5000 );`
|
||||
expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
|
||||
endpoint: 'https://example.com/api/endpoint',
|
||||
method: 'POST',
|
||||
httpHeaders: [{ name: 'Accept', value: 'application/json' }],
|
||||
httpBody: '',
|
||||
timeoutMs: 5000,
|
||||
type: 'http_request',
|
||||
snippet: command,
|
||||
})
|
||||
})
|
||||
|
||||
it('should not let a comma inside a jsonb_build_object value swallow the following body argument', () => {
|
||||
const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:=jsonb_build_object('Accept', 'application/json, text/plain'), body:='{"key": "value"}', timeout_milliseconds:=5000 );`
|
||||
expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
|
||||
endpoint: 'https://example.com/api/endpoint',
|
||||
method: 'POST',
|
||||
httpHeaders: [{ name: 'Accept', value: 'application/json, text/plain' }],
|
||||
httpBody: '{"key": "value"}',
|
||||
timeoutMs: 5000,
|
||||
type: 'http_request',
|
||||
snippet: command,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return an HTTP request config with GET method and empty body', () => {
|
||||
const command = `select net.http_get( url:='https://example.com/api/endpoint', headers:=jsonb_build_object(), timeout_milliseconds:=5000 );`
|
||||
expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
|
||||
|
||||
@@ -13,6 +13,76 @@ const unescapeSqlLiteral = (value = '', isEscapeString = false) => {
|
||||
return isEscapeString ? unescaped.replaceAll('\\\\', '\\') : unescaped
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the surrounding quotes from a single SQL string literal and unescapes its
|
||||
* contents, handling the optional `E''` escape-string prefix.
|
||||
*/
|
||||
const unwrapSqlLiteral = (token: string) => {
|
||||
const trimmed = token.trim()
|
||||
const isEscapeString = /^e'/i.test(trimmed)
|
||||
const withoutPrefix = isEscapeString ? trimmed.slice(1) : trimmed
|
||||
const withoutQuotes = withoutPrefix.replace(/^'|'$/g, '')
|
||||
return unescapeSqlLiteral(withoutQuotes, isEscapeString)
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the argument list of a `jsonb_build_object(...)` call into its individual
|
||||
* values, honoring single-quoted SQL string literals (with '' escapes) and nested
|
||||
* parentheses. A naive split on ',' corrupts a header name or value that legitimately
|
||||
* contains a comma or parenthesis, which then gets persisted on save and no longer
|
||||
* matches what the user entered.
|
||||
*/
|
||||
const parseJsonBuildObjectArgs = (command: string) => {
|
||||
const match = command.match(/headers:=jsonb_build_object\s*\(/i)
|
||||
if (!match || match.index === undefined) return []
|
||||
|
||||
const args: string[] = []
|
||||
let current = ''
|
||||
let depth = 1
|
||||
let inQuote = false
|
||||
let hasContent = false
|
||||
|
||||
for (let i = match.index + match[0].length; i < command.length && depth > 0; i++) {
|
||||
const char = command[i]
|
||||
|
||||
if (inQuote) {
|
||||
if (char === "'" && command[i + 1] === "'") {
|
||||
current += "''"
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (char === "'") inQuote = false
|
||||
current += char
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "'") {
|
||||
inQuote = true
|
||||
current += char
|
||||
hasContent = true
|
||||
} else if (char === '(') {
|
||||
depth++
|
||||
current += char
|
||||
} else if (char === ')') {
|
||||
depth--
|
||||
if (depth > 0) current += char
|
||||
} else if (char === ',' && depth === 1) {
|
||||
args.push(current)
|
||||
current = ''
|
||||
} else {
|
||||
current += char
|
||||
if (char.trim().length > 0) hasContent = true
|
||||
}
|
||||
}
|
||||
|
||||
// Unbalanced parentheses: bail rather than emit mangled fragments.
|
||||
if (depth !== 0) return []
|
||||
|
||||
if (hasContent || args.length > 0) args.push(current)
|
||||
|
||||
return args.map(unwrapSqlLiteral)
|
||||
}
|
||||
|
||||
export function buildCronCreateQuery(
|
||||
name: string,
|
||||
schedule: string,
|
||||
@@ -75,18 +145,14 @@ export const parseCronJobCommand = (originalCommand: string, projectRef: string)
|
||||
const timeoutMatch = command.match(/timeout_milliseconds:=(\d+)/i)
|
||||
const timeout = timeoutMatch?.[1] || ''
|
||||
|
||||
const headersJsonBuildObjectMatch = command.match(/headers:=jsonb_build_object\(([^)]*)/i)
|
||||
const headersJsonBuildObject = headersJsonBuildObjectMatch?.[1] || ''
|
||||
|
||||
let headersObjs: { name: string; value: string }[] = []
|
||||
if (headersJsonBuildObject) {
|
||||
const headers = headersJsonBuildObject
|
||||
.split(',')
|
||||
.map((s) => unescapeSqlLiteral(s.trim().replace(/^'|'$/g, '')))
|
||||
if (/headers:=jsonb_build_object\s*\(/i.test(command)) {
|
||||
const args = parseJsonBuildObjectArgs(command)
|
||||
|
||||
for (let i = 0; i < headers.length; i += 2) {
|
||||
if (headers[i] && headers[i].length > 0) {
|
||||
headersObjs.push({ name: headers[i], value: headers[i + 1] })
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
const name = args[i]
|
||||
if (name && name.length > 0) {
|
||||
headersObjs.push({ name, value: args[i + 1] ?? '' })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user