Skip to content

fix(security): close the credential gaps the hardening series left behind - #69

Merged
marselsel merged 2 commits into
mainfrom
fix/residual-credential-defaults
Aug 6, 2026
Merged

fix(security): close the credential gaps the hardening series left behind#69
marselsel merged 2 commits into
mainfrom
fix/residual-credential-defaults

Conversation

@marselsel

Copy link
Copy Markdown
Collaborator

The rest of the security review of #42#66. Independent of #68 — different files, no shared logic (both touch .env.example, in different sections).

Three findings, all the same shape: a credential that stays published or absent when the operator takes a path the scripts don't cover.

1. yavio_app kept the published yavio_dev — even if you did everything right

0001_row_level_security.sql creates the role with that literal under an IF NOT EXISTS guard. migrate.ts repairs it from POSTGRES_APP_PASSWORD, and setup-env.sh generates one — but the compose migrate service never passed the variable, and compose-injected environment is not inherited from .env.

So the host path (pnpm migrate, which reads .env directly) repaired the role and the compose path did not. This is the one credential in the system that survived the documented remediation.

What it grants: DML on all 14 tables. RLS is not a barrier — the policies key on current_setting('app.current_user_id'), a GUC the connecting session sets for itself, and verification_tokens, login_attempts and stripe_webhook_events have no RLS at all. The role is dormant (nothing connects as it), which is why nobody would ever have noticed.

2. The two most privileged accounts still fell back to yavio_dev

#57 made POSTGRES_API_PASSWORD fail closed with :?, but left :-yavio_dev on:

  • POSTGRES_SERVICE_PASSWORD — this is POSTGRES_USER on the postgres:16 image, i.e. the bootstrap superuser, not just a table owner. Superuser means COPY ... FROM PROGRAM.
  • CLICKHOUSE_PASSWORD — unrestricted, and carrying CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1, so it can also create users and grants.

Both now use the same :? form, and .env.example ships them blank instead of pre-filled. Compose refuses to start rather than substituting a value.

⚠️ This is the one behaviour change that can block a deploy. Before merging, confirm the VM's .env has both POSTGRES_SERVICE_PASSWORD and CLICKHOUSE_PASSWORD set — if either is missing, docker compose up will now refuse to start instead of silently using the published default. I couldn't check the VM myself; the gcloud session expired mid-review. Happy to verify once you've re-authed.

3. setup-env.sh put every secret on a command line

sed -i "s|^${key}=.*|${key}=${value}|" makes the value part of argv. /proc/<pid>/cmdline is world-readable (mode 444); /proc/<pid>/environ is owner-only (400). So all seven datastore passwords and four app secrets were exposed to any local user, and the chmod 600 two lines above bought nothing (CWE-214).

The value now travels through the environment into awk. That also drops the BSD/GNU sed -i split and removes the escaping question entirely — awk prints the value literally, so a |, & or backslash from some future generator can't break the substitution or inject a line. Today's base64/hex values can't contain those; this is about not depending on that. The temp file is created under umask 077 because mv keeps the temp file's mode.

Also

  • docker-compose.test.yml published a password-test Postgres and ClickHouse on 0.0.0.0 from every developer's machine. The dev compose file has bound to loopback since fix(security): bind datastores to loopback and stop shipping a default DB password #42; this one never did.
  • Self-hosting docs still advertised yavio_dev as the default for five variables, omitted POSTGRES_API_PASSWORD and CLICKHOUSE_ERASER_PASSWORD entirely, and told readers to "change all default passwords" that no longer exist.

Verification

Worth recording how the first attempt went wrong, because it's the same trap as verification-must-prove-it-ran. I proved the yavio_app rotation by connecting via 127.0.0.1 from inside the container — where pg_hba.conf says host all all 127.0.0.1/32 trust. Both the old and the new password "authenticated", because no password was being checked at all. The test was measuring nothing.

Re-run over a scram-checked connection from the host:

=== un-wired state: yavio_app on the published literal ===
  published 'yavio_dev' -> AUTHENTICATED
[migrate:postgres] Applied POSTGRES_APP_PASSWORD to role yavio_app.
=== after the migrator runs with the variable compose now supplies ===
  published 'yavio_dev' -> REJECTED (28P01)
  rotated password     -> AUTHENTICATED

Also verified:

  • Compose fails closed with no .env: required variable POSTGRES_API_PASSWORD is missing a value, exit 1. Validates cleanly (exit 0) with the variables set.
  • setup-env.sh produces a 0600 .env, leaves no temp file behind, generates all seven passwords, rewrites both connection URLs to match, and writes no placeholder or yavio_dev into any value — the only two remaining mentions in the generated file are explanatory comments.

marselsel and others added 2 commits August 6, 2026 08:45
…hind

Three residual findings from reviewing #42#66, all the same shape: a
credential that stays published or absent when the operator takes a path the
scripts do not cover.

yavio_app kept the published 'yavio_dev'. 0001 creates the role with that
literal under an IF NOT EXISTS guard; migrate.ts repairs it from
POSTGRES_APP_PASSWORD, and setup-env.sh generates one — but the compose
`migrate` service never passed the variable, and compose-injected environment
is not inherited from .env. So a deployment migrating through compose kept a
role with a password anyone can read on GitHub and DML on all 14 tables, even
for an operator who ran setup-env.sh and followed every documented step. RLS is
no barrier: its policies key on current_setting('app.current_user_id'), which
the connecting session sets for itself, and verification_tokens,
login_attempts and stripe_webhook_events have no RLS at all.

The two MOST privileged accounts still fell back to 'yavio_dev'. #57 made
POSTGRES_API_PASSWORD fail closed with `:?` but left `:-yavio_dev` on
POSTGRES_SERVICE_PASSWORD — the Postgres SUPERUSER, which can COPY ... FROM
PROGRAM — and on CLICKHOUSE_PASSWORD, which is unrestricted and carries
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1. Both now use the same `:?` form, and
.env.example ships them blank rather than pre-filled. Compose refuses to start
instead of substituting; a startup error costs a minute, a published default is
a credential in a public repo.

setup-env.sh put every secret on a command line. `sed -i "s|^${key}=.*|..."`
makes the value part of argv, which is world-readable via ps and
/proc/<pid>/cmdline while /proc/<pid>/environ is owner-only — so all seven
datastore passwords and four app secrets were exposed to any local user, and
the chmod 600 two lines above bought nothing (CWE-214). Now the value goes
through the environment into awk. That also drops the BSD/GNU `sed -i` split
and the escaping question: awk prints the value literally, so a `|`, `&` or
backslash from a future generator cannot break the substitution or inject a
line. The temp file is created under umask 077 because mv keeps its mode.

Also: docker-compose.test.yml published a password-`test` Postgres and
ClickHouse on 0.0.0.0 from every developer's machine — the dev compose file has
bound to loopback since #42. And the self-hosting docs still advertised
'yavio_dev' as the default for five variables, omitted POSTGRES_API_PASSWORD
and CLICKHOUSE_ERASER_PASSWORD entirely, and told readers to change defaults
that no longer exist.

Verified rather than assumed. The first attempt at proving the yavio_app
rotation connected via 127.0.0.1 from inside the container, where pg_hba says
`host all all 127.0.0.1/32 trust` — both the old and new passwords
"authenticated" because no password was being checked at all. Re-run over a
scram-checked connection from the host: 'yavio_dev' REJECTED (28P01), rotated
password AUTHENTICATED. Compose fails closed with no .env and validates with
the variables set; setup-env.sh produces a 0600 .env, leaves no temp file, and
writes no placeholder into any value.
@marselsel
marselsel merged commit 08d6cf8 into main Aug 6, 2026
12 checks passed
@marselsel
marselsel deleted the fix/residual-credential-defaults branch August 6, 2026 10:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant