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:
authored and GitHub committed 2026-06-11 16:26:47 +02:00
1 parent 715f003cdd
commit aae3adab23
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 {