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

@@ -32,8 +32,14 @@ drop table if exists reorg_scenarios;
-- Planstelle, und eine offene Stelle ist schlicht eine unbesetzte. Anlegen
-- und Schliessen sind deshalb Operationen auf om_positions.
-- set search_path bei jeder Funktion: siehe die folgende Migration, dort steht
-- warum. Kurz: heute sind das INVOKER-Funktionen und der Pfad ist harmlos,
-- aber sobald eine davon einmal SECURITY DEFINER wird, wäre er es nicht mehr —
-- und daran denkt dann niemand.
create or replace function next_position_number()
returns text language sql stable as $$
returns text language sql stable
set search_path = public, pg_temp
as $$
select '6' || lpad((coalesce(max(substring(position_number from 2)::bigint), 0) + 1)::text, 7, '0')
from om_positions
where position_number ~ '^6[0-9]{7}$';
@@ -43,7 +49,9 @@ comment on function next_position_number() is
'Nächste freie Planstellennummer im Nummernkreis 6xxxxxxx.';
create or replace function create_position(payload jsonb)
returns uuid language plpgsql as $$
returns uuid language plpgsql
set search_path = public, pg_temp
as $$
declare
v_org_unit_id uuid := (payload->>'org_unit_id')::uuid;
v_job_title text := nullif(trim(payload->>'job_title'), '');
@@ -95,7 +103,9 @@ end;
$$;
create or replace function delete_position(payload jsonb)
returns void language plpgsql as $$
returns void language plpgsql
set search_path = public, pg_temp
as $$
declare
v_position_id uuid := (payload->>'position_id')::uuid;
v_label text;