From 344480ef83cbef4ab42e132e2d552b90dad1531a Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:25:44 +0530 Subject: [PATCH] fix: cron sql literal (#45189) ## TL;DR another parsing issue & updated prev tests to validate this aswell :P ## ex
Before After
Before After
## ref: - closes https://github.com/supabase/supabase/issues/45186 ## Summary by CodeRabbit * **Tests** * Expanded cron job parsing tests to verify correct handling of SQL-escaped single quotes in HTTP headers and request bodies. * **Bug Fixes** * Fixed cron HTTP request generation and parsing so URLs, headers, and POST bodies with escaped quotes are preserved and unescaped correctly when building and reading cron jobs. --- .../CronJobs/CronJobs.utils.test.ts | 15 +++---- .../Integrations/CronJobs/CronJobs.utils.tsx | 40 ++++++++++--------- 2 files changed, 27 insertions(+), 28 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 be744e53280..616397b158c 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts @@ -165,13 +165,13 @@ describe('parseCronJobCommand', () => { }) it('should return an HTTP request config with POST method, some headers and empty body', () => { - const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:=jsonb_build_object('fst', '1', 'snd', '2'), body:='', timeout_milliseconds:=1000 );` + const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:=jsonb_build_object('fst', '1', 'snd', 'O''Reilly'), body:='', timeout_milliseconds:=1000 );` expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({ endpoint: 'https://example.com/api/endpoint', method: 'POST', httpHeaders: [ { name: 'fst', value: '1' }, - { name: 'snd', value: '2' }, + { name: 'snd', value: "O'Reilly" }, ], httpBody: '', timeoutMs: 1000, @@ -222,16 +222,13 @@ describe('parseCronJobCommand', () => { }) }) - it('should return an HTTP request config with POST method, plain JSON headers and plain JSON body with ::jsonb typecasting', () => { - const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:='{"fst": "1", "snd": "2"}'::jsonb,body:='{"key": "value"}'::jsonb,timeout_milliseconds:=5000);` + it('should return an HTTP request config with POST method, plain JSON headers and plain JSON body with escaped SQL strings and ::jsonb typecasting', () => { + const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:='{"X-Name":"O''Reilly"}'::jsonb,body:='{"message":"hello there","name":"O''Reilly"}'::jsonb,timeout_milliseconds:=5000);` expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({ endpoint: 'https://example.com/api/endpoint', method: 'POST', - httpHeaders: [ - { name: 'fst', value: '1' }, - { name: 'snd', value: '2' }, - ], - httpBody: '{"key": "value"}', + httpHeaders: [{ name: 'X-Name', value: "O'Reilly" }], + httpBody: `{"message":"hello there","name":"O'Reilly"}`, timeoutMs: 5000, type: 'http_request', snippet: command, diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx index 08c43117555..ca2cfd35d7d 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx @@ -7,8 +7,11 @@ import { CRON_TABLE_COLUMNS, HTTPHeader, secondsPattern } from './CronJobs.const import { CronJobTableCell } from './CronJobTableCell' import { CronJob } from '@/data/database-cron-jobs/database-cron-jobs-infinite-query' +const escapeSqlLiteral = (value = '') => value.replaceAll("'", "''") +const unescapeSqlLiteral = (value = '') => value.replaceAll("''", "'") + export function buildCronQuery(name: string, schedule: string, command: string) { - const escapedName = name.replace(/'/g, "''") + const escapedName = escapeSqlLiteral(name) return `select cron.schedule('${escapedName}', '${schedule}', ${command});` } @@ -22,11 +25,13 @@ export const buildHttpRequestCommand = ( return ` select net.${method === 'GET' ? 'http_get' : 'http_post'}( - url:='${url}', + url:='${escapeSqlLiteral(url)}', headers:=jsonb_build_object(${headers .filter((v) => v.name && v.value) - .map((v) => `'${v.name}', '${v.value}'`) - .join(', ')}), ${method === 'POST' && body ? `\n body:='${body}',` : ''} + .map((v) => `'${escapeSqlLiteral(v.name)}', '${escapeSqlLiteral(v.value)}'`) + .join( + ', ' + )}), ${method === 'POST' && body ? `\n body:='${escapeSqlLiteral(body)}',` : ''} timeout_milliseconds:=${timeout} );` } @@ -41,21 +46,17 @@ const DEFAULT_CRONJOB_COMMAND = { } as const export const parseCronJobCommand = (originalCommand: string, projectRef: string): CronJobType => { - const command = originalCommand - .replaceAll('$$', ' ') - .replaceAll(/\n/g, ' ') - .replaceAll(/\s+/g, ' ') - .trim() + const command = originalCommand.replaceAll('$$', ' ').replaceAll(/\n/g, ' ').trim() - if (command.toLocaleLowerCase().startsWith('select net.')) { - const methodMatch = command.match(/select net\.([^']+)\(\s*url:=/i) + if (command.toLocaleLowerCase().match(/^select\s+net\./)) { + const methodMatch = command.match(/select\s+net\.([^']+)\(\s*url:=/i) const method = methodMatch?.[1] || '' - const urlMatch = command.match(/url:='([^']+)'/i) - const url = urlMatch?.[1] || '' + const urlMatch = command.match(/url:='((?:''|[^'])*)'/i) + const url = unescapeSqlLiteral(urlMatch?.[1]) - const bodyMatch = command.match(/body:='(.*?)'/i) - const body = bodyMatch?.[1] || '' + const bodyMatch = command.match(/body:='((?:''|[^'])*)'/i) + const body = unescapeSqlLiteral(bodyMatch?.[1]) const timeoutMatch = command.match(/timeout_milliseconds:=(\d+)/i) const timeout = timeoutMatch?.[1] || '' @@ -65,8 +66,9 @@ export const parseCronJobCommand = (originalCommand: string, projectRef: string) let headersObjs: { name: string; value: string }[] = [] if (headersJsonBuildObject) { - // convert the header string to array of objects, clean up the values, trim them of spaces and remove the quotation marks at start and end - const headers = headersJsonBuildObject.split(',').map((s) => s.trim().replace(/^'|'$/g, '')) + const headers = headersJsonBuildObject + .split(',') + .map((s) => unescapeSqlLiteral(s.trim().replace(/^'|'$/g, ''))) for (let i = 0; i < headers.length; i += 2) { if (headers[i] && headers[i].length > 0) { @@ -74,8 +76,8 @@ export const parseCronJobCommand = (originalCommand: string, projectRef: string) } } } else { - const headersStringMatch = command.match(/headers:='([^']*)'/i) - const headersString = headersStringMatch?.[1] || '{}' + const headersStringMatch = command.match(/headers:='((?:''|[^'])*)'/i) + const headersString = unescapeSqlLiteral(headersStringMatch?.[1]) || '{}' try { const parsedHeaders = JSON.parse(headersString) headersObjs = Object.entries(parsedHeaders).map(([name, value]) => ({