Pin search_path, and close a write path that needed no login

Der Security Advisor meldete 34 Warnungen; nach dem Festnageln des
search_path sind es zehn. Von diesen zehn ist eine einzige ein echter Befund
— aber die hätte man in den 34 nicht gesehen.

search_path (34 Warnungen)

Alle betroffenen Funktionen sind SECURITY INVOKER, laufen also mit den
Rechten der aufrufenden Person; ein manipulierter Pfad bringt dort nichts zu
holen. Die vier DEFINER-Funktionen setzen ihn längst. Festgenagelt wird es
trotzdem, für den Tag, an dem jemand eine davon auf SECURITY DEFINER
umstellt, weil eine Mutation an RLS vorbei schreiben muss — dann wäre es eine
Rechteausweitung, und an den search_path denkt in dem Moment niemand.

Als Schleife statt als Liste von 34 Signaturen: die würde beim nächsten
Umbau veralten. Sie lässt Erweiterungen in Ruhe (pg_trgm legt show_trgm und
show_limit ebenfalls in public ab) und prüft am Ende selbst nach. pg_temp
steht ausdrücklich am Pfadende — ohne die Angabe durchsucht Postgres das
temporäre Schema zuerst, und dort darf jede Sitzung anlegen, was sie will.

Ausführungsrechte (8 Warnungen)

Hier trennt sich der Befund vom Rauschen, und zwar durch Messen mit dem
anon-Schlüssel gegen die laufende Datenbank:

  anon.rpc(is_hr_user)                -> false
  anon.rpc(current_hr_user_id)        -> null
  anon.rpc(apply_due_pending_changes) -> 0

Die ersten beiden bleiben offen, und das ist keine Nachlässigkeit: sie werden
aus den RLS-Policies heraus aufgerufen, und ein Policy-Ausdruck wird mit den
Rechten der abfragenden Rolle ausgewertet. Ohne EXECUTE scheitert jede
Abfrage auf jeder Tabelle. Preisgegeben wird nichts — beide nehmen keine
Argumente und beantworten nur eine Frage über die aufrufende Person selbst.

Der dritte ist der Befund. apply_due_pending_changes() wendet vorgemerkte
Versetzungen, Beförderungen und Abwesenheiten an, ist SECURITY DEFINER,
umgeht damit RLS — und war ohne Anmeldung aufrufbar. Der anon-Schlüssel steht
im ausgelieferten Browser-Bündel. Der Schaden wäre begrenzt, weil nur ohnehin
fällige Änderungen angewandt werden, aber es ist ein Schreibpfad für Fremde
und macht das Geheimnis der Cron-Route wirkungslos. Entzogen für anon und
authenticated; die Route benutzt die service_role und läuft weiter.

rls_auto_enable() stammt nicht aus diesen Migrationen und wird nirgends
aufgerufen. Der Entzug ist risikolos und beantwortet die Frage, was sie tut,
notfalls mit einer klaren Fehlermeldung.

Zwei Warnungen bleiben bewusst stehen

pg_trgm in public trägt die Operatorklasse gin_trgm_ops, auf der zwei
GIN-Indizes auf employees liegen. Ein Schemawechsel müsste Indizes und jeden
search_path mitziehen — Risiko für eine Konvention, keine Rechteausweitung.

„Leaked Password Protection" ist gegenstandslos: die Passwort-Anmeldung ist
abgeschaltet, eine Anmeldung gegen die API antwortet mit
email_provider_disabled. Es gibt kein Passwort, das kompromittiert sein
könnte.
This commit is contained in:
2026-07-28 11:54:02 +02:00
parent 2cce101c4b
commit ee1a38492c
3 changed files with 162 additions and 3 deletions

View File

@@ -0,0 +1,72 @@
-- Ausführungsrechte auf den SECURITY-DEFINER-Funktionen zurechtrücken.
--
-- Der Advisor meldet alle vier als „Public Can Execute" und „Signed-In Users
-- Can Execute". Das ist nicht bei allen vieren dasselbe Problem — nachgemessen
-- mit dem anon-Schlüssel gegen die laufende Datenbank:
--
-- anon.rpc(is_hr_user) -> false
-- anon.rpc(current_hr_user_id) -> null
-- anon.rpc(apply_due_pending_changes) -> 0 ← das ist der Befund
--
-- Nur der dritte ist einer.
-- ── Bleibt offen, und zwar mit Absicht ───────────────────────────
--
-- is_hr_user() und current_hr_user_id() werden *aus den RLS-Policies heraus*
-- aufgerufen. Ein Policy-Ausdruck wird mit den Rechten der abfragenden Rolle
-- ausgewertet; ohne EXECUTE für anon und authenticated scheitert damit jede
-- Abfrage auf jeder Tabelle mit „permission denied for function". Der Entzug
-- würde die Anwendung vollständig lahmlegen.
--
-- Preisgegeben wird dabei nichts: beide nehmen keine Argumente und beantworten
-- ausschliesslich eine Frage über die aufrufende Person selbst. Wer nicht
-- angemeldet ist, bekommt false beziehungsweise null — siehe Messung oben.
-- ── Wird entzogen ────────────────────────────────────────────────
--
-- apply_due_pending_changes() wendet vorgemerkte Versetzungen, Beförderungen
-- und Abwesenheiten an, sobald ihr Datum erreicht ist. Es ist SECURITY
-- DEFINER, umgeht also RLS, und war bis hierher ohne Anmeldung aufrufbar — der
-- anon-Schlüssel steht im ausgelieferten Browser-Bündel.
--
-- Der Schaden wäre begrenzt, weil nur ohnehin fällige Änderungen angewandt
-- werden. Aber es ist ein Schreibpfad, den Fremde auslösen können, und er
-- macht das Geheimnis der Cron-Route (app/api/cron/apply-pending-changes)
-- wirkungslos.
--
-- Diese Route ist der einzige Aufrufer und benutzt createAdminClient(), also
-- die service_role — der Entzug für anon und authenticated bricht sie nicht.
revoke execute on function apply_due_pending_changes() from anon, authenticated;
-- rls_auto_enable() stammt nicht aus diesen Migrationen und wird von der
-- Anwendung nirgends aufgerufen. Was sie tut, ist von hier aus nicht
-- feststellbar; eine Funktion, die RLS umschaltet und ohne Anmeldung
-- aufrufbar ist, wäre allerdings ernst. Der Entzug ist risikolos, weil kein
-- Aufrufer existiert — und falls doch jemand sie braucht, meldet er sich mit
-- einer klaren Fehlermeldung statt still etwas zu verstellen.
do $$
begin
if exists (
select 1 from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'rls_auto_enable'
) then
execute 'revoke execute on function public.rls_auto_enable() from anon, authenticated';
end if;
end;
$$;
-- ── Was bewusst *nicht* passiert ─────────────────────────────────
--
-- „Extension in Public" (pg_trgm) bleibt stehen. Die Erweiterung trägt die
-- Operatorklasse gin_trgm_ops, auf der zwei GIN-Indizes auf employees liegen
-- (20260714120400_performance_indexes.sql). Ein Schemawechsel müsste die
-- Indizes und jeden search_path mitziehen, der sie erreichen soll — gerade
-- jetzt, wo jede Funktion auf `public, pg_temp` festgenagelt ist. Das ist
-- Aufwand und Risiko für einen Hinweis, der keine Rechteausweitung beschreibt,
-- sondern eine Konvention.
--
-- „Leaked Password Protection Disabled" ist gegenstandslos: die
-- Passwort-Anmeldung ist abgeschaltet. Eine Anmeldung mit E-Mail und Passwort
-- gegen die API antwortet mit `email_provider_disabled` (422). Es gibt kein
-- Passwort, dessen Kompromittierung geprüft werden könnte.