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 |
|
|
## 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]) => ({