From aae3adab23e91da0c590dd4e1dee08afd23fa95e Mon Sep 17 00:00:00 2001 From: oniani1 Date: Thu, 11 Jun 2026 18:26:47 +0400 Subject: [PATCH] fix(studio): preserve cron HTTP headers containing commas or parentheses (#46830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) ## 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. --------- Co-authored-by: Claude Fable 5 Co-authored-by: Ivan Vasilov --- .../CronJobs/CronJobs.utils.test.ts | 110 ++++++++++++++++++ .../Integrations/CronJobs/CronJobs.utils.tsx | 86 ++++++++++++-- 2 files changed, 186 insertions(+), 10 deletions(-) diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts index f79435a6455..93c3b1c7390 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts @@ -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({ diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx index ac68d4948f9..22884d07b76 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx @@ -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 {