Skip to content

Commit 600a0ff

Browse files
authored
docs(claude): add safe-sql-execution skill (supabase#46171)
## 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? Docs (new Claude Code skill). ## What is the current behavior? There is no shared, written-down reference for the SQL safety model in Studio. The rules around `SafeSqlFragment`/`UntrustedSqlFragment`, sanitization utilities, and how to promote snippet content live only in code and contributor knowledge, which makes it easy for AI-assisted changes to bypass the type-based guarantees. ## What is the new behavior? Adds a `safe-sql-execution` skill under `.claude/skills/` that documents the proven-authorship security model: the three classes of SQL fragments, provenance tracking with branded types, sanitization utilities (`ident`/`literal`/`keyword`), the `acceptUntrustedSql` rule (event handlers only), and the special case that snippet content (`unchecked_sql`) must never be considered safe. Includes good/bad examples for the common patterns. ## Additional context <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Added a comprehensive guide on secure SQL execution in Supabase Studio: explains provenance-based SQL safety, distinct categories of SQL fragments, how unsafe snippets must be explicitly promoted before execution, available sanitization helpers for user input, strict execution constraints to prevent accidental runs, and numerous examples demonstrating safe vs. unsafe usage and safe preview/runner patterns. <!-- review_stack_entry_start --> [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46171?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 0283d92 commit 600a0ff

1 file changed

Lines changed: 378 additions & 0 deletions

File tree

  • .claude/skills/safe-sql-execution
Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
1+
---
2+
name: safe-sql-execution
3+
description: Safely execute SQL queries against a user database without risking SQL injection or other security vulnerabilities.
4+
---
5+
6+
# Safe SQL execution
7+
8+
Supabase Studio executes SQL statements directly against the user's database.
9+
Because this is the authenticated user's own database, our security model is
10+
different from most frontend applications: a user should be able to execute any
11+
SQL statement, as long as it is proven that they themselves authored it. What
12+
we SHOULD NOT ALLOW is execution of SQL statements that can be influenced by an
13+
attacker, such as through URL parameters.
14+
15+
## Security model
16+
17+
The security model for SQL execution in Supabase Studio is based on the
18+
principle of "proven authorship". This means that a user should only be able to
19+
execute SQL statements that they have explicitly authored, and not statements
20+
that can be influenced by external input.
21+
22+
There are three classes of SQL fragments:
23+
24+
1. Hardcoded within the application code. These are safe to execute because
25+
they cannot be influenced by an attacker. They can be marked with the
26+
`safeSql` utility with `pg-meta`:
27+
28+
```ts
29+
import { safeSql } from '@supabase/pg-meta'
30+
31+
const sql = safeSql`
32+
SELECT *
33+
FROM users
34+
WHERE id = 1
35+
`
36+
```
37+
38+
`safeSql` automatically creates a string of the branded type
39+
`SafeSqlFragment`. (See Provenance Tracking below.)
40+
41+
2. Third-party influenceable. These are SQL fragments that can be influenced
42+
by an attacker, such as through URL parameters or LLM output. These should
43+
be marked with the `untrustedSql` utility with `pg-meta`:
44+
45+
```ts
46+
import { untrustedSql } from '@supabase/pg-meta'
47+
48+
const unsafeQuery = searchParams.get('query')
49+
const querySql = untrustedSql(unsafeQuery)
50+
```
51+
52+
`untrustedSql` creates a string of the branded type `UntrustedSqlFragment`.
53+
(See Provenance Tracking below.)
54+
55+
3. User-authored. These are SQL fragments that are authored by the user
56+
themselves within the UI, for example in a text input field. Because the
57+
user is the author, these should be considered safe to execute.
58+
59+
However, there is a caveat, where third-party and user-authored code can
60+
mix, contaminating the user-authored code (for example, if an input is
61+
prefilled from an unsanitized URL parameter). Provenance tracking helps us
62+
track these cases.
63+
64+
For example, a safe input component could be implemented as follows by requiring that its placeholder and controlled value are of type `SafeSqlFragment`. In this case we can use its onChange to promote the user input to `SafeSqlFragment` type, because we know that the user is the author of the input. An implementation of this is in
65+
@apps/studio/components/ui/SafeSqlInput.tsx:
66+
67+
```ts
68+
import { rawSql, type SafeSqlFragment } from '@supabase/pg-meta'
69+
import type { ChangeEvent, ComponentProps } from 'react'
70+
import { Input } from 'ui-patterns/DataInputs/Input'
71+
72+
type InputProps = ComponentProps<typeof Input>
73+
74+
export type SafeSqlInputProps = Omit<
75+
InputProps, 'placeholder' | 'value' | 'onChange'
76+
> & {
77+
placeholder?: SafeSqlFragment
78+
value: SafeSqlFragment
79+
onChange?:
80+
(event: ChangeEvent<HTMLInputElement>, value: SafeSqlFragment) => void
81+
}
82+
83+
export const SafeSqlInput = ({ onChange, ...props }: SafeSqlInputProps) => (
84+
<Input
85+
{...props}
86+
onChange={(event) => onChange?.(event, rawSql(event.target.value))}
87+
/>
88+
)
89+
```
90+
91+
This is pretty much the ONLY VALID USE CASE of the rawSql export from
92+
pg-meta, and it should be used with caution.
93+
94+
## Provenance tracking
95+
96+
Branded types are used to track the provenance of SQL fragments. The types,
97+
exported from `pg-meta`, are:
98+
99+
- `SafeSqlFragment`: represents SQL fragments that are safe to execute, because
100+
they are either hardcoded in the application or authored by the user
101+
themselves.
102+
- `UntrustedSqlFragment`: represents SQL fragments that can be influenced by an
103+
attacker, such as through URL parameters or LLM output.
104+
105+
These are valid ways to generate a `SafeSqlFragment`:
106+
107+
- Using the `safeSql` utility from `pg-meta` to create hardcoded SQL fragments.
108+
- Using the sanitization utilities from `pg-meta` to sanitize untrusted input
109+
and promote it to a `SafeSqlFragment`:
110+
- `ident`
111+
- `literal`
112+
- `keyword`
113+
- Using the safe SQL manipulation utilities:
114+
- `joinSqlFragments`
115+
- `trimSafeSqlFragment`
116+
117+
`UntrustedSqlFragments` can be generated from raw strings using
118+
`untrustedSql()`.
119+
120+
There is also a union type, `DisplayableSqlFragment`, which represents SQL fragments that can be safely displayed in the UI, but not necessarily executed. This includes both `SafeSqlFragment` and `UntrustedSqlFragment`.
121+
122+
## Security of SQL round-tripped from the user's database
123+
124+
SQL derived directly from catalog tables (e.g., function definitions, RLS
125+
expressions, etc.) is considered safe, and it is promoted AT THE POINT OF
126+
BEING QUERIED from the database. In most cases, this is in an
127+
apps/studio/data/\*_/_.ts file, in the utility function that makes the API or
128+
database fetch.
129+
130+
A critical exception to the safety of SQL round-tripped from the database is
131+
user snippets. These must NEVER BE CONSIDERED SAFE because they are both (a)
132+
externally influenceable and (b) auto-saved. The snippet type uses the
133+
`unchecked_sql` property, which is an `UntrustedSqlFragment`, to enforce this.
134+
135+
## Promoting SQL fragments to `SafeSqlFragment` type
136+
137+
Given an insecure string or `UntrustedSqlFragment`, how do we promote it safely
138+
to a `SafeSqlFragment`?
139+
140+
### Sanitization utilities
141+
142+
This is the preferred method when the input is sanitizable, e.g., it is a
143+
relation name, a column name, will be compared as a literal, etc.
144+
145+
The pg-meta library provides the following sanitization utilities that can be
146+
used to safely promote untrusted input to `SafeSqlFragment`:
147+
148+
- `ident`: for sanitizing identifiers such as table names or column names.
149+
- `literal`: for sanitizing literal values that will be used in SQL statements.
150+
- `keyword`: for sanitizing SQL keywords.
151+
152+
### `acceptUntrustedSql`
153+
154+
Some untrusted SQL fragments cannot be sanitized with the above utilities. For
155+
example, the `USING` expression in the RLS policy editor is an arbitrary SQL
156+
expression.
157+
158+
In these cases, we can promote the SQL fragment _upon explicit user action_.
159+
User action indicates that the user has seen the SQL and is OK with running it.
160+
For example, an explicit user action could be clicking a "Run" button.
161+
162+
The promotion happens with the `acceptUntrustedSql` utility from `pg-meta`,
163+
which takes an `UntrustedSqlFragment` and returns a `SafeSqlFragment`.
164+
165+
This utility MUST ONLY BE USED IN event handlers. It should NEVER be used in
166+
a useQuery, direct in the render body of a component, in a useEffect, or
167+
anywhere it could auto-run without explicit user action.
168+
169+
This is safe:
170+
171+
```ts
172+
import { acceptUntrustedSql } from '@supabase/pg-meta'
173+
174+
function SafeComponent() {
175+
const { mutate: execute } = useExecuteSqlMutation()
176+
177+
const handleRun = () => {
178+
// ✅ GOOD: Safe because it is in an event handler which requires a user
179+
// click
180+
execute({ sql: acceptUntrustedSql(/* sql */) })
181+
}
182+
183+
return (
184+
<button onClick={handleRun}>Run</button>
185+
)
186+
}
187+
```
188+
189+
This is unsafe:
190+
191+
```ts
192+
import { acceptUntrustedSql } from '@supabase/pg-meta'
193+
194+
function UnsafeComponent() {
195+
const { data } = useQuery({
196+
queryKey: ['execute-sql', sql],
197+
queryFn: () => {
198+
// 🛑 BAD: Unsafe because it is in a query which could auto-run without
199+
// explicit user action
200+
return execute({ sql: acceptUntrustedSql(/* sql */) })
201+
},
202+
})
203+
}
204+
```
205+
206+
## Type guarantees
207+
208+
SQL run against the user's Postgres database runs through the `executeSql`
209+
function, which only takes arguments of type `SafeSqlFragment` for the SQL
210+
parameter. Raw strings or `UntrustedSqlFragment`s will error at compile time.
211+
212+
## Examples
213+
214+
### Hard-coded SQL
215+
216+
```ts
217+
// ✅ GOOD: Automatically safe with `safeSql` utility
218+
const selectStatement = safeSql`select 1`
219+
```
220+
221+
### SQL with sanitizable interpolations
222+
223+
```ts
224+
// ✅ GOOD: `pg-meta` utilities sanitize the input
225+
const tableName = ident(userInputTableName)
226+
const searchString = literal(userInputSearchString)
227+
const sqlStatement = safeSql`
228+
SELECT *
229+
FROM ${tableName}
230+
WHERE search_column = ${searchString}
231+
`
232+
```
233+
234+
```ts
235+
// 🛑 BAD: Passing raw strings will type error
236+
const tableName = 'my_table'
237+
const sqlStatement = safeSql`
238+
SELECT *
239+
FROM ${tableName}
240+
`
241+
```
242+
243+
### Non-sanitizable SQL from a user input
244+
245+
```ts
246+
// ✅ GOOD: SafeSqlInput only allows a value that is a SafeSqlFragment
247+
import { SafeSqlInput } from '@apps/studio/components/ui/SafeSqlInput'
248+
249+
function MyComponent() {
250+
const [sql, setSql] = useState<SafeSqlFragment>(safeSql``)
251+
252+
return (
253+
<SafeSqlInput
254+
placeholder={safeSql`Enter your SQL query here...`}
255+
value={sql}
256+
onChange={(event, value) => setSql(value)}
257+
/>
258+
)
259+
}
260+
```
261+
262+
```ts
263+
// 🛑 BAD: This input mixes SafeSqlFragments and unsafe strings
264+
265+
function MyBadComponent() {
266+
const [sql, setSql] = useState<SafeSqlFragment>(safeSql``)
267+
268+
return (
269+
<Input
270+
// 🛑 BAD: This is unsafe because the placeholder is a raw string
271+
placeholder="Enter your SQL query here..."
272+
value={sql}
273+
onChange={(event) => setSql(event.target.value)}
274+
/>
275+
)
276+
}
277+
```
278+
279+
### Round-tripping SQL from the database (NOT snippet content)
280+
281+
```ts
282+
// ✅ GOOD: SQL from the database is promoted to SafeSqlFragment at the point
283+
// of fetching
284+
285+
// data/function-definitions.ts
286+
function markFunctionDefinitionSafe(
287+
functionDefinition: FunctionDefinition
288+
): SafeFunctionDefinition {
289+
return {
290+
...functionDefinition,
291+
definition: functionDefinition.definition as SafeSqlFragment,
292+
}
293+
}
294+
295+
// data/function-definitions.ts
296+
function getFunctionDefinitions() {
297+
return GET(`/function-definitions`).then((functionDefinitions) =>
298+
functionDefinitions.map(markFunctionDefinitionSafe)
299+
)
300+
}
301+
```
302+
303+
```ts
304+
// 🛑 BAD: Strings are promoted to SafeSqlFragment in a utility function, where
305+
// it is impossible to easily determine the safety of the input
306+
307+
// utils.ts
308+
function markFunctionDefinitionSafe(
309+
functionDefinition: FunctionDefinition
310+
): SafeFunctionDefinition {
311+
return {
312+
...functionDefinition,
313+
definition: functionDefinition.definition as SafeSqlFragment,
314+
}
315+
}
316+
317+
// Component.ts
318+
function MyComponent() {
319+
const { data: functionDefinitions } = useFunctionDefinitions()
320+
const safeFunctionDefinitions = functionDefinitions.map(markFunctionDefinitionSafe)
321+
}
322+
```
323+
324+
### Snippet content is ALWAYS UNSAFE
325+
326+
Snippets are auto-persisted to the database and can be created or modified
327+
through externally influenceable channels (e.g., prefilled from URL params).
328+
The `unchecked_sql` property is typed as `UntrustedSqlFragment` to enforce this
329+
— it must only be promoted to `SafeSqlFragment` via `acceptUntrustedSql` in an
330+
event handler that requires explicit user action.
331+
332+
```ts
333+
// 🛑 BAD: Snippet content is executed automatically via useQuery, with no
334+
// explicit user action confirming that the user has reviewed the SQL.
335+
import { acceptUntrustedSql } from '@supabase/pg-meta'
336+
337+
function UnsafeSnippetPreview({ snippet }: { snippet: Snippet }) {
338+
const { data } = useExecuteSqlQuery({
339+
sql: acceptUntrustedSql(snippet.content.unchecked_sql),
340+
})
341+
342+
return <Results data={data} />
343+
}
344+
```
345+
346+
```ts
347+
// 🛑 BAD: Casting bypasses the type system entirely. The snippet's
348+
// `unchecked_sql` is `UntrustedSqlFragment` for a reason — never cast it.
349+
function UnsafeSnippetRunner({ snippet }: { snippet: Snippet }) {
350+
const { mutate: execute } = useExecuteSqlMutation()
351+
352+
useEffect(() => {
353+
execute({ sql: snippet.content.unchecked_sql as SafeSqlFragment })
354+
}, [snippet])
355+
}
356+
```
357+
358+
```ts
359+
// ✅ GOOD: Snippet content is only promoted to SafeSqlFragment inside an event
360+
// handler, after the user clicks Run. The user has seen the SQL in the editor
361+
// and explicitly chosen to execute it.
362+
import { acceptUntrustedSql } from '@supabase/pg-meta'
363+
364+
function SnippetRunner({ snippet }: { snippet: Snippet }) {
365+
const { mutate: execute } = useExecuteSqlMutation()
366+
367+
const handleRun = () => {
368+
execute({ sql: acceptUntrustedSql(snippet.content.unchecked_sql) })
369+
}
370+
371+
return (
372+
<>
373+
<SnippetEditor snippet={snippet} />
374+
<button onClick={handleRun}>Run</button>
375+
</>
376+
)
377+
}
378+
```

0 commit comments

Comments
 (0)