Open a private security advisory on the repository, or email the maintainers. Please do not open a public issue for anything exploitable.
A false negative is a vulnerability in this project. A scanner that misses an
exposure is worse than no scanner, because it produces confidence that was not
earned. Reports of missed findings are treated with the same priority as
crashes, and are expected to arrive with a reproducer — ideally a fixture in
fixtures/ whose posture is known by construction.
Two of unruly's techniques rest on behaviour of the upstream platform rather than on any misconfiguration by the operator. Both were measured during development and both are documented here rather than left implicit, because a technique nobody has disclosed is a technique whose disappearance will silently break the tool.
Requesting a near-miss name returns the real one:
GET /rest/v1/signator → hint: "Perhaps you meant the table 'public.signatory_submissions'"
POST /rest/v1/rpc/admin_lis → hint: "Perhaps you meant to call the function public.admin_list_submissions"
This is an information disclosure: it reveals schema structure — including
SECURITY DEFINER routines that appear in no client bundle — to anyone holding
the public anon key, regardless of row-level security. RLS protects rows, not
names, so the disclosure is not a policy failure by the operator.
It is arguably intended developer ergonomics rather than a defect. unruly uses it because attackers can, and because a scanner that ignores an available channel gives its user a falsely reassuring picture. Maintainers should report this to Supabase and PostgREST before publishing any writeup of the technique, and treat the enumeration behaviour as liable to change.
Supabase Realtime acknowledges a postgres_changes subscription for a relation
that does not exist, identically to one that does:
zzz_definitely_not_a_table {"status":"ok","response":{"postgres_changes":[{"id":21942019,...}]}}
cves (RLS-protected) {"status":"ok","response":{"postgres_changes":[{"id":46914639,...}]}}
RLS is applied at delivery time, not subscription time. This is not a vulnerability, but it is a trap for tool authors: any scanner reporting "Realtime exposure" from a subscription acknowledgement is emitting false positives. unruly reports nothing from this signal and runs a control probe each scan to detect if the behaviour ever changes.
Do not give it a service_role key. It refuses one, and the reason is worth stating: every finding here is a claim about what an ANONYMOUS caller can reach, and service_role bypasses row-level security by design. Scanned with one, the reference target reported 14 read-exposed relations where the truth is 7 — describing seven correctly protected relations as readable by anonymous callers. A missed finding hides one problem; that invents seven and discredits the real ones alongside them.
Enumeration and write probing against systems you do not control is
unauthorised access in most jurisdictions, regardless of intent. -write is
refused unless -yes-i-own-this is also passed; that flag is a statement about
authorisation, not a formality.
-write permits writes that create data: an INSERT the probe cleans up after
itself, and a POST to an application route.
-invoke additionally permits CALLING discovered database routines and Edge
Functions, which RUNS code the operator did not write. A routine named purge or
a function named send-email does what it says.
They are separate because the risks are not comparable, and because a single
flag meant a user authorising a test INSERT was also authorising arbitrary
function execution. -invoke requires -write.
Audited exhaustively, because three separate default behaviours turned out to contradict the safety model this file describes. The write gate existed the whole time; nobody had checked which writes went through it.
| Request | When |
|---|---|
INSERT {} into a relation |
-write |
DELETE of a probe row it created |
-write (cleanup only) |
POST to an application route |
-write |
POST to a discovered routine (invokes it) |
-write -invoke |
POST to an Edge Function name (invokes it) |
-write -invoke |
INSERT into a writable relation to trigger a Realtime payload |
-write |
POST to a name that cannot exist (self-check, enumeration) |
always |
The last row is the residual risk and it is not fully eliminable. Routine
enumeration works by POSTing deliberate near-misses — truncations of real words
— to make PostgREST volunteer names. A truncation is chosen precisely because
it should NOT be a real routine, but nothing guarantees that: if admin_purg
happens to be a real zero-argument function on some project, discovering the
schema calls it.
Removing that would remove the enumeration capability entirely, which is the one thing this scanner does that others do not. It is stated here instead so the trade-off is the reader's to accept rather than one made silently on their behalf.
There is no way to ask a database "may I call this function" without calling it: PostgREST checks EXECUTE when the statement runs, so the probe invokes the routine and reads what comes back. A routine that is callable therefore RUNS.
Verified on a fixture with a zero-argument SECURITY DEFINER function that
deletes rows: a -write scan invoked it and the table was emptied. That is
what an admin routine named purge, reset or delete does for a living, and
SECURITY DEFINER ones run with the owner's rights regardless of what the caller
is allowed to touch.
So invocation needs -write. Without it, routine DISCLOSURE is still reported
— that is a read — and callability is reported as unknown, which is true.
Detecting one requires a POST. The platform answers 404 for absent, 401 when
verify_jwt rejects the caller, and anything else means the request reached
the function — which is to say the function ran. A function deployed with
verify_jwt off is invoked by the act of detecting it, and Edge Functions send
email, charge cards and write to queues.
-write includes them. Without it no Edge Function is detected at all, and the
scan says so rather than leaving an apparently clean result.
Comparing route families needs to know which siblings demand credentials, and
many API routes are POST-only. Sending POST {} to discover that is a write to
somebody's application — /api/subscribe, /api/send-email, /api/orders —
and it used to run by default while a single database INSERT was gated behind
two flags. It now needs -write.
Without it, a family whose siblings are POST-only answers 405 to GET, which is
not an authorisation signal, and a real inconsistency can be missed. On the
reference target the open admin route is only detected with -write. The scan
logs which mode it used.
-write normally cleans up after itself. There is exactly one state where it
cannot, and it is inherent rather than a shortcoming: a relation that accepts
anonymous INSERT, refuses anonymous SELECT, and has no NOT NULL column.
PostgREST answers 201 Created with no Location header and an empty body,
and recovering the new row's primary key would require the very SELECT the
relation forbids.
The scanner reports this as a high finding naming the relation, so the row is
never left behind silently. -no-residue skips the probe entirely, trading
write recall on that one state for a guarantee that the scan changes nothing.
This was found by the generated state-space fixture (fixtures/matrix), not by
review — three hand-written fixtures had never produced that exact combination.
Findings quote real rows from the target database as proof. Treat scan output as containing production data.
-redact suppresses every sampled value while keeping counts, column names and
verdicts, so a redacted report still evidences each exposure without carrying
the data. It covers sampled rows, response excerpts from application routes,
and recovered key prefixes.
That coverage is asserted by test rather than assumed. An earlier version threaded the flag into the relation probe but not the route probe, so a redacted report still carried the body an unauthenticated caller received — which on the reference target is internal pipeline records. It was found by grepping a real redacted scan for values known to be in the database, not by reading the code, and that check is now a test.
This tool points at hosts nobody controls -- that is its purpose -- and it carries the operator's credentials on every request to the target. Two consequences are worth stating plainly.
It does not follow redirects. Go strips Authorization when a redirect
crosses domains but never strips a custom header, and Supabase's apikey is
one. A host that answers 302 would otherwise harvest the project key from any
scanner that follows. Measured against a local pair of servers before this was
fixed: the second origin received both the apikey and the bearer token. A 3xx
is now returned to the caller as the answer, which is also more informative --
PostgREST, GoTrue, Storage and Realtime do not redirect in normal operation.
Names recovered from a target are validated before use. The hint oracle
returns strings chosen by the host being scanned, and those names reach both a
URL and the -fix remediation, which exists to be pasted into a SQL console.
Measured before this was fixed: a hint reading
Perhaps you meant the table 'public.users; DROP TABLE audit_log; --'
was accepted verbatim and would have been emitted as
ALTER TABLE users; DROP TABLE audit_log; -- ENABLE ROW LEVEL SECURITY;
so a hostile host could get arbitrary SQL run by whoever scanned it. Two others
got through: a name carrying ?select=*&limit=999999, and one using ../ to
reach other endpoints. Names must now match [A-Za-z0-9_$]{1,63}. That is
narrower than Postgres allows -- a quoted identifier may contain spaces, so a
table called "my table" is skipped -- and the false negative is taken
knowingly, because the alternative is executing what a scanned host asks for.
Target text cannot drive the terminal. Findings carry strings the scanned
host chose -- error codes and messages, object names, response snippets, row
identifiers -- and the renderer emits ANSI escapes of its own, so its output is
a terminal control stream. Measured before this was fixed: a hostile evidence
value rendered verbatim, clearing the screen and printing a green
scan complete: 0 findings. Control characters are now shown inert (\x1b)
at the render boundary, so an operator sees what the target sent rather than
obeying it. Sampled rows and -json output were already safe: json.Marshal
escapes control characters.
The report never carries a whole credential. The scan holds the operator's
key and recovers others -- from the page, from a web archive, from a preview
host -- and every finding quotes at most a sixteen-character prefix: enough to
say WHICH key, not enough to use. Credential-shaped strings inside an Edge
Function's response body are masked too, and that one is unconditional rather
than tied to -redact, because declining to republish somebody's key is not a
preference. Sampled ROWS are deliberately left alone: there the data is the
exposure being reported, and -redact exists for readers who need it gone.
Asserted end to end against fixtures that serve four different credentials.
Responses are bounded. A reply is read up to 1MB and drained up to 8MB, so a hostile host cannot exhaust memory, and a host that accepts a connection then says nothing hits the request timeout rather than stalling every probe behind it. Both are tested in internal/client/hostile_test.go.