|
| 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