Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/installcheck
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ else
fi

# Execute the test fixtures
psql -v ON_ERROR_STOP= -f test/fixtures.sql -f lints/0001*.sql -f lints/0002*.sql -f lints/0003*.sql -f lints/0004*.sql -f lints/0005*.sql -f lints/0006*.sql -f lints/0007*.sql -f lints/0008*.sql -f lints/0009*.sql -f lints/0010*.sql -f lints/0011*.sql -f lints/0013*.sql -f lints/0014*.sql -f lints/0015*.sql -f lints/0016*.sql -f lints/0017*.sql -f lints/0018*.sql -f lints/0019*.sql -f lints/0020*.sql -f lints/0021*.sql -f lints/0022*.sql -f lints/0023*.sql -f lints/0024*.sql -f lints/0025*.sql -f lints/0026*.sql -f lints/0027*.sql -f lints/0028*.sql -f lints/0029*.sql -f lints/0030*.sql -d contrib_regression
psql -v ON_ERROR_STOP= -f test/fixtures.sql -f lints/0001*.sql -f lints/0002*.sql -f lints/0003*.sql -f lints/0004*.sql -f lints/0005*.sql -f lints/0006*.sql -f lints/0007*.sql -f lints/0008*.sql -f lints/0009*.sql -f lints/0010*.sql -f lints/0011*.sql -f lints/0013*.sql -f lints/0014*.sql -f lints/0015*.sql -f lints/0016*.sql -f lints/0017*.sql -f lints/0018*.sql -f lints/0019*.sql -f lints/0020*.sql -f lints/0021*.sql -f lints/0022*.sql -f lints/0023*.sql -f lints/0024*.sql -f lints/0025*.sql -f lints/0026*.sql -f lints/0027*.sql -f lints/0028*.sql -f lints/0029*.sql -f lints/0030*.sql -f lints/0031*.sql -f lints/0032*.sql -d contrib_regression

# Run tests
${REGRESS} --use-existing --dbname=contrib_regression --inputdir=${TESTDIR} ${TESTS}
Expand Down
64 changes: 64 additions & 0 deletions docs/0031_anon_dynamic_sql_function_executable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
**Level:** ERROR / WARN

**Summary:** This function is callable without signing in and appears to `EXECUTE` a SQL string passed in by the caller.

**Ramification:** The function is reachable through `POST /rest/v1/rpc/<name>`, so anyone with the public anon key can run arbitrary SQL through it. If the function is `SECURITY DEFINER` the SQL runs as the function owner and bypasses RLS. If it is `SECURITY INVOKER` the SQL runs as `anon` and is limited by that role's grants and RLS. The level is `ERROR` for a `SECURITY DEFINER` function whose parameter appears directly in an `EXECUTE`, and `WARN` for everything else, including `SECURITY INVOKER` functions and functions matched only because a parameter has a SQL-like name. Either way the caller can read your schema and any data `anon` can reach. Revoke `EXECUTE`, or replace the function with fixed operations.

> **See also: lint [0032_authenticated_dynamic_sql_function_executable](0032_authenticated_dynamic_sql_function_executable.md).** It is the same check for signed-in users. Revoking from `anon` alone usually leaves the function callable by every signed-up user, so address both together. Lint [0028_anon_security_definer_function_executable](0028_anon_security_definer_function_executable.md) covers `SECURITY DEFINER` functions exposed to `anon` in general.

---

### Rationale

Helpers like `exec_sql(query text)` or `run_query(sql text)` let a client send SQL to the database and get the result back. Postgres grants `EXECUTE` on new functions to `PUBLIC`, and Supabase also grants it to `anon` and `authenticated`, so a function like this is callable by clients unless `EXECUTE` is revoked. Checking that the string starts with `select` does not make it safe.

This lint reads the function body, and only for PL/pgSQL functions in exposed schemas. A function is flagged when:

1. a `text` or `varchar` parameter (or an array of either, including `VARIADIC`) is used inside an `EXECUTE` statement, or
2. a `text` or `varchar` parameter (or an array of either, including `VARIADIC`) is named like SQL (`sql`, `query`, `stmt`, `statement`, `command`, `cmd`, ...) and the body contains an `EXECUTE` anywhere.

Parameters bound with `EXECUTE ... USING` and parameters wrapped in `quote_ident()`, `quote_literal()` or `quote_nullable()` are not counted.

### How to Resolve

**Option 1: Revoke `EXECUTE`**

```sql
revoke execute on function public.exec_sql(text) from anon, public;
```

**Option 2: Drop the function**

```sql
drop function public.exec_sql(text);
```

**Option 3: Replace it with fixed operations**

Expose one function per operation with typed arguments and no string-built SQL. If you have to build SQL, pass values with `EXECUTE ... USING`, quote identifiers with `quote_ident()`, and check table and column names against an allow-list.

### Example

Given this function:

```sql
create function public.exec_sql(query text) returns json
language plpgsql security definer as $$
declare r json;
begin
execute 'select json_agg(t) from (' || query || ') t' into r;
return r;
end $$;
```

Fix:

```sql
revoke execute on function public.exec_sql(text) from anon, authenticated, public;
```

### False Positives

- A `text` parameter used as an identifier through `format('%I', param)` is safe but can still be flagged. Use `quote_ident(param)` instead.
- A function that checks the string against a strict allow-list before running it. Confirm the allow-list is complete, then suppress the finding.
- Only PL/pgSQL bodies are inspected. Functions in other languages, or that build SQL in a helper function, are not detected.
64 changes: 64 additions & 0 deletions docs/0032_authenticated_dynamic_sql_function_executable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
**Level:** ERROR / WARN

**Summary:** This function is callable by signed-in users and appears to `EXECUTE` a SQL string passed in by the caller.

**Ramification:** The function is reachable through `POST /rest/v1/rpc/<name>`, so any signed-in user (with open signup, anyone with a throwaway account) can run arbitrary SQL through it. If the function is `SECURITY DEFINER` the SQL runs as the function owner and bypasses RLS. If it is `SECURITY INVOKER` the SQL runs as `authenticated` and is limited by that role's grants and RLS. The level is `ERROR` for a `SECURITY DEFINER` function whose parameter appears directly in an `EXECUTE`, and `WARN` for everything else, including `SECURITY INVOKER` functions and functions matched only because a parameter has a SQL-like name. Either way the caller can read your schema and any data `authenticated` can reach. Revoke `EXECUTE`, or replace the function with fixed operations.

> **See also: lint [0031_anon_dynamic_sql_function_executable](0031_anon_dynamic_sql_function_executable.md).** It is the same check for `anon`. Revoking from `authenticated` alone can leave the function callable without signing in, so address both together. Lint [0029_authenticated_security_definer_function_executable](0029_authenticated_security_definer_function_executable.md) covers `SECURITY DEFINER` functions exposed to `authenticated` in general.

---

### Rationale

Helpers like `exec_sql(query text)` or `run_query(sql text)` let a client send SQL to the database and get the result back. Postgres grants `EXECUTE` on new functions to `PUBLIC`, and Supabase also grants it to `anon` and `authenticated`, so a function like this is callable by clients unless `EXECUTE` is revoked. Checking that the string starts with `select` does not make it safe.

This lint reads the function body, and only for PL/pgSQL functions in exposed schemas. A function is flagged when:

1. a `text` or `varchar` parameter (or an array of either, including `VARIADIC`) is used inside an `EXECUTE` statement, or
2. a `text` or `varchar` parameter (or an array of either, including `VARIADIC`) is named like SQL (`sql`, `query`, `stmt`, `statement`, `command`, `cmd`, ...) and the body contains an `EXECUTE` anywhere.

Parameters bound with `EXECUTE ... USING` and parameters wrapped in `quote_ident()`, `quote_literal()` or `quote_nullable()` are not counted.

### How to Resolve

**Option 1: Revoke `EXECUTE`**

```sql
revoke execute on function public.exec_sql(text) from authenticated, public;
```

**Option 2: Drop the function**

```sql
drop function public.exec_sql(text);
```

**Option 3: Replace it with fixed operations**

Expose one function per operation with typed arguments and no string-built SQL. If you have to build SQL, pass values with `EXECUTE ... USING`, quote identifiers with `quote_ident()`, and check table and column names against an allow-list.

### Example

Given this function:

```sql
create function public.exec_sql(query text) returns json
language plpgsql security definer as $$
declare r json;
begin
execute 'select json_agg(t) from (' || query || ') t' into r;
return r;
end $$;
```

Fix:

```sql
revoke execute on function public.exec_sql(text) from anon, authenticated, public;
```

### False Positives

- A `text` parameter used as an identifier through `format('%I', param)` is safe but can still be flagged. Use `quote_ident(param)` instead.
- A function that checks the string against a strict allow-list before running it. Confirm the allow-list is complete, then suppress the finding.
- Only PL/pgSQL bodies are inspected. Functions in other languages, or that build SQL in a helper function, are not detected.
231 changes: 231 additions & 0 deletions lints/0031_anon_dynamic_sql_function_executable.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
create view lint."0031_anon_dynamic_sql_function_executable" as

-- Detects PL/pgSQL functions that the `anon` role can EXECUTE and that
-- appear to EXECUTE a SQL string passed in by the caller, e.g.
-- `exec_sql(query text)`. PostgREST exposes them at
-- `/rest/v1/rpc/<name>`, so anyone with the anon key can run arbitrary SQL as the
-- function owner (SECURITY DEFINER) or as `anon` (SECURITY INVOKER).
--
-- A function is flagged when either:
-- 1. a text/varchar parameter (or array of either) is used inside an EXECUTE statement,
-- or in an expression assigned to a variable (`v := ... || param`) that
-- is then used in an EXECUTE (EXECUTE ... USING bound values and
-- quote_ident/quote_literal/quote_nullable(param) are ignored), or
-- 2. a text/varchar parameter (or array of either) has a SQL-like name (sql, query, stmt,
-- ...) and the body contains an EXECUTE anywhere.
--
-- Level is ERROR for SECURITY DEFINER functions matching (1), WARN
-- otherwise. Unlike 0028/0029 this also covers SECURITY INVOKER.
--
-- See lint 0032 for the equivalent check against `authenticated`.
with candidates as (
select
n.nspname as schema_name,
p.proname as function_name,
pg_catalog.pg_get_function_identity_arguments(p.oid) as function_args,
l.lanname as function_language,
p.prosecdef as is_security_definer,
pg_catalog.pg_get_userbyid(p.proowner) as owner_name,
regexp_replace(lower(p.prosrc), '--[^\n]*', '', 'g') as body,
p.proargnames,
p.proargmodes,
coalesce(p.proallargtypes, p.proargtypes::pg_catalog.oid[]) as all_arg_types
from
pg_catalog.pg_proc p
join pg_catalog.pg_namespace n
on p.pronamespace = n.oid
join pg_catalog.pg_language l
on p.prolang = l.oid
where
l.lanname = 'plpgsql'
and pg_catalog.has_function_privilege('anon', p.oid, 'EXECUTE')
and n.nspname = any(array(select trim(unnest(string_to_array(coalesce(current_setting('pgrst.db_schemas', 't'), 'public'), ',')))))
and n.nspname not in (
'_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault'
)
-- exclude functions that belong to an extension
and not exists (
select 1
from pg_catalog.pg_depend d
where d.objid = p.oid
and d.classid = 'pg_catalog.pg_proc'::pg_catalog.regclass
and d.deptype = 'e'
)
and lower(p.prosrc) like '%execute%'
),
text_params as (
select
c.*,
ip.pos as arg_pos,
lower(coalesce(nullif(c.proargnames[a.ord], ''), '$' || ip.pos)) as arg_name
from
candidates c
cross join lateral unnest(c.all_arg_types) with ordinality as a(typ, ord)
-- position among input arguments, which is what `$N` refers to
cross join lateral (
select count(*) as pos
from generate_series(1, a.ord::int) g
where coalesce(c.proargmodes[g]::text, 'i') in ('i', 'b', 'v')
) ip
where
coalesce(c.proargmodes[a.ord]::text, 'i') in ('i', 'b', 'v')
and a.typ in (
'pg_catalog.text'::pg_catalog.regtype,
'pg_catalog.varchar'::pg_catalog.regtype,
'pg_catalog.text[]'::pg_catalog.regtype,
'pg_catalog.varchar[]'::pg_catalog.regtype
)
),
escaped as (
select
tp.*,
-- match the parameter by name or by position (`$N` also works for
-- named parameters in PL/pgSQL)
'(?:' || regexp_replace(tp.arg_name, '([^a-z0-9_])', '\\\1', 'g') || '|\$' || tp.arg_pos || ')' as arg_re
from
text_params tp
),
formatted as (
select
e.*,
-- a parameter passed to format() in a %I or %L slot is quoted, so
-- it is replaced with `null` (like quote_ident/quote_literal).
-- Only format() calls with simple, comma-free arguments and
-- sequential specifiers (no `%1$s`) are recognised; anything else
-- is left in place and counts as a use of the parameter.
regexp_replace(
e.body,
'(?<=(?:' || (
select string_agg(
'format\s*\(\s*''(?:[^''%]|%%|'''')*(?:%[-0-9]*[sil](?:[^''%]|%%|'''')*){' || (k - 1) || '}%[-0-9]*[il](?:[^'']|'''')*''\s*(?:,[^,()]+){' || (k - 1) || '},\s*',
'|'
)
from generate_series(1, 8) k
) || '))' || e.arg_re || '(?=\s*[,)])',
'null',
'g'
) as fmt_body
from
escaped e
),
cleaned as (
select
f.*,
-- string literals blanked out (so keywords and semicolons inside
-- them are not mistaken for code), `USING` bound values removed
-- (only a real USING clause is left to match), and quote_*(param)
-- wrappers removed
regexp_replace(
regexp_replace(
regexp_replace(
regexp_replace(
f.fmt_body,
'\$([a-z_]*?)\$.*?\$\1\$',
'''''',
'g'
),
'''([^'']|'''')*''',
'''''',
'g'
),
'\musing\s[^\x3b]*',
'',
'g'
),
'quote_(ident|literal|nullable)\s*\(\s*' || f.arg_re || '\s*\)',
'',
'g'
) as exec_body
from
formatted f
),
matches as (
select
cl.schema_name,
cl.function_name,
cl.function_args,
cl.function_language,
cl.is_security_definer,
cl.owner_name,
cl.arg_name,
(
-- the parameter is used inside an EXECUTE statement
cl.exec_body ~ ('\mexecute\M[^\x3b]*[^a-z0-9_$]' || cl.arg_re || '([^a-z0-9_]|$)')
-- or a variable assigned from the parameter (`v := ... || param`
-- or `declare v text := ... || param`) is used in an EXECUTE
or exists (
select 1
from regexp_matches(
cl.exec_body,
'(?:([a-z_][a-z0-9_]*)\s+(?:text|varchar(?:\(\d+\))?|character\s+varying(?:\(\d+\))?)|([a-z_][a-z0-9_]*))\s*:=([^\x3b]*[^a-z0-9_$])?' || cl.arg_re || '([^a-z0-9_]|$)',
'g'
) as m
where cl.exec_body ~ ('\mexecute\M[^\x3b]*[^a-z0-9_$]' || coalesce(m[1], m[2]) || '([^a-z0-9_]|$)')
)
) as direct_use,
cl.arg_name in ('sql', 'query', 'queries', 'stmt', 'statement', 'command', 'cmd', 'sql_text', 'sql_query', 'sql_string', 'query_text', 'query_string', 'raw_sql', 'raw_query')
and cl.exec_body ~ '\mexecute\M' as renamed_use
from
cleaned cl
),
flagged as (
select
schema_name,
function_name,
function_args,
function_language,
is_security_definer,
owner_name,
bool_or(direct_use) as has_direct_use,
array_agg(arg_name order by arg_name) as sql_parameters
from
matches
where
direct_use or renamed_use
group by
schema_name,
function_name,
function_args,
function_language,
is_security_definer,
owner_name
)
select
'anon_dynamic_sql_function_executable' as name,
'Public Can Execute Dynamic SQL Function' as title,
case when is_security_definer and has_direct_use then 'ERROR' else 'WARN' end as level,
'EXTERNAL' as facing,
array['SECURITY'] as categories,
'Detects functions callable without signing in that appear to run a SQL string passed in by the caller. Revoke `EXECUTE` or replace the function with fixed operations.' as description,
format(
'Function `%s.%s(%s)` is `SECURITY %s`, can be executed by the `anon` role via `/rest/v1/rpc/%s`, and appears to run SQL passed in `%s`. Revoke `EXECUTE` if that is not intentional.',
schema_name,
function_name,
function_args,
case when is_security_definer then 'DEFINER' else 'INVOKER' end,
function_name,
array_to_string(sql_parameters, ', ')
) as detail,
'https://supabase.com/docs/guides/database/database-linter?lint=0031_anon_dynamic_sql_function_executable' as remediation,
jsonb_build_object(
'schema', schema_name,
'name', function_name,
'type', 'function',
'arguments', function_args,
'language', function_language,
'security_definer', is_security_definer,
'owner', owner_name,
'sql_parameters', sql_parameters
) as metadata,
format(
'anon_dynamic_sql_function_executable_%s_%s_%s',
schema_name,
function_name,
function_args
) as cache_key
from
flagged
order by
schema_name,
function_name,
function_args;
Loading
Loading