Skip to content
Closed
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,16 @@ Sessions survive and nobody signs in again.
is unavailable never blocks a sign-in.

### Fixed
- **The audit trail could be erased with one statement.** It is append-only because a database
trigger refuses updates and deletes, and that trigger is row-level, so `TRUNCATE` never reached it.
Anything holding `DATABASE_URL` could empty the table and nothing raised. That is the case the
guarantee exists for: the trail is enforced in the database rather than the application precisely
because the application is not the only thing that reaches it. A statement-level trigger now refuses
a truncate, and it answers before the retention setting is read, so declaring a retention window no
longer permits one. Retention itself is unchanged: rows older than the window are still removed, and
recent ones are still refused. Note that the connection the application uses is the database owner
in the shipped compose file, and an owner can still disable or drop a trigger; closing that needs a
role with `INSERT` and `SELECT` only, which is a separate change.
- **A Bot could reach the deployment's own network by writing the address a different way.** The
guard refused `169.254.169.254` and the private ranges as usually written, but not the same
addresses spelled as an IPv6-mapped or NAT64 form, an integer, or with a trailing dot, so a Bot
Expand Down
62 changes: 62 additions & 0 deletions server/drizzle/0012_audit_trail_survives_truncate.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
-- TRUNCATE erases the audit trail, and the append-only trigger never runs.
--
-- `audit_events_append_only` is BEFORE UPDATE OR DELETE ... FOR EACH ROW
-- (0000_schema.sql:403). A row-level trigger cannot fire on TRUNCATE at all, so
-- `TRUNCATE audit_events` removes every row without raising. The guarantee the
-- schema states -- "a trail anybody can edit after the fact answers no question
-- worth asking", enforced in the database "because the application is not the
-- only thing that can reach this table" -- is exactly what TRUNCATE defeats, and
-- for exactly the reader that comment has in mind.
--
-- The obvious fix is wrong, so this does not use it. Pointing a BEFORE TRUNCATE
-- trigger at the existing function looks correct and fails open: a statement
-- trigger has no OLD record, so `OLD.created_at >= now() - ...` is NULL, the IF
-- does not fire, and control reaches RETURN OLD. It refuses only while
-- `openbot.audit_retention_days` is unset. Set it -- which is what the retention
-- sweep does -- and the truncate is permitted. Verified both ways against
-- Postgres before writing this: unset raised, set to '30' truncated the table.
--
-- So the TRUNCATE branch is answered before the setting is read. Retention
-- removes rows it can name and date; it never removes the table's contents
-- wholesale, so there is no window in which TRUNCATE is the intended path.
CREATE OR REPLACE FUNCTION prevent_audit_event_mutation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
retention_days integer;
BEGIN
-- Before the setting is consulted: TG_OP is the only thing a statement-level
-- invocation can be trusted to have, and every row-level field is absent.
IF TG_OP = 'TRUNCATE' THEN
RAISE EXCEPTION 'Audit events are append-only';
END IF;

IF TG_OP = 'UPDATE' THEN
RAISE EXCEPTION 'Audit events are append-only';
END IF;

-- `true` so a session that never set it reads NULL instead of raising, which is the ordinary case
-- and has to stay a plain refusal.
BEGIN
retention_days := nullif(current_setting('openbot.audit_retention_days', true), '')::integer;
EXCEPTION WHEN others THEN
retention_days := NULL;
END;

IF retention_days IS NULL OR retention_days < 1 THEN
RAISE EXCEPTION 'Audit events are append-only';
END IF;

IF OLD.created_at >= now() - (retention_days || ' days')::interval THEN
RAISE EXCEPTION 'Audit events are append-only within the retention window';
END IF;

RETURN OLD;
END;
$$;--> statement-breakpoint

CREATE TRIGGER audit_events_no_truncate
BEFORE TRUNCATE ON audit_events
FOR EACH STATEMENT
EXECUTE FUNCTION prevent_audit_event_mutation();
Loading