firebase: make the rules template impossible to deploy verbatim
Some checks failed
file length / file-length (push) Waiting to run
palette drift / palette (push) Waiting to run
sync_settings_ui tests / test (push) Has been cancelled

database.rules.json carries YOUR_FIREBASE_UID on purpose -- this repo is
public and the uid is the one value that must not be in it. But nothing
stopped anyone publishing the template as-is, and rules pinned to a uid that
matches no account deny every read and write. The symptom is every device
failing at once with a bare permission error, which reads like an outage
rather than a bad paste.

render_rules.sh substitutes the uid from ~/.config/crdt-sync/firebase.json
and fails closed on anything unsafe: a surviving placeholder, missing
`auth != null`, a missing uid pin (that one matters -- `auth != null` alone
lets any stranger who self-registers through the public Web API key read
everything), or output that is not JSON.

Verified: its output is byte-identical to the deployed
~/.config/crdt-sync/database.rules.json, and the live database still answers
an unauthenticated read with 401. All three failure paths exercised.

Also corrects the README, which described the in-repo copy as "the deployed
rules, with the real uid filled in" -- it never was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
This commit is contained in:
Krzysztof Rudnicki 2026-08-22 19:10:10 +02:00
parent db3d8f827f
commit 4c024a24f0
2 changed files with 103 additions and 3 deletions

View File

@ -34,8 +34,23 @@ through the identitytoolkit REST API to anyone holding the Web API key, and
that key ships inside the Android APKs. Pinning the uid means a stranger who
registers an account still reads and writes nothing.
To deploy, substitute the real uid (Firebase console → Authentication → Users
→ User UID) and paste into **Realtime Database → Rules → Publish**.
The uid here is a **placeholder on purpose** — this repo is public, and the
uid is the one value that must not appear in it. Publishing this file
verbatim yields rules matching no account, which locks every app out of the
project until someone notices; the failure looks like a permission error
from every device at once.
So do not hand-substitute. Render it:
```bash
./render_rules.sh # prints deployable rules, real uid filled in
./render_rules.sh --check # validates without printing the uid
```
It reads the uid from `~/.config/crdt-sync/firebase.json` and refuses to emit
anything still containing the placeholder, missing `auth != null`, missing a
uid pin, or not parsing as JSON. Paste its output into **Realtime Database →
Rules → Publish**.
Verify the rules are live — an unauthenticated request must be refused:
@ -53,7 +68,7 @@ outside version control:
|---|---|
| `firebase.json` | `apiKey`, `databaseUrl`, `projectId`, `uid`, `email` |
| `password` | the sync account's password, no trailing newline |
| `database.rules.json` | the deployed rules, with the real uid filled in |
| `database.rules.json` | the rendered rules, real uid filled in (see `render_rules.sh`) |
The Web API key is **not** a secret — it is a public project identifier that
ships inside client apps. The password and refresh tokens are the real

85
firebase/render_rules.sh Executable file
View File

@ -0,0 +1,85 @@
#!/bin/bash
# ============================================================================
# Render deployable Realtime Database rules from the public template.
#
# The template in this repo carries a placeholder on purpose: the repository
# is public and the uid is the one value that must not appear in it. That
# leaves a trap -- publishing the template verbatim yields rules that match
# no account, which locks every app out of the shared project until someone
# notices. This script closes it: it substitutes the real uid from the local
# config and refuses to emit anything still containing a placeholder.
# ============================================================================
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME
readonly SCRIPT_DIR
readonly TEMPLATE="$SCRIPT_DIR/database.rules.json"
readonly CONFIG_DIR="${CRDT_SYNC_CONFIG_DIR:-$HOME/.config/crdt-sync}"
readonly PLACEHOLDER="YOUR_FIREBASE_UID"
usage() {
echo "Usage: $SCRIPT_NAME [--check]"
echo " (no args) print deployable rules with the real uid substituted"
echo " --check verify the rendered rules are sane; print nothing"
exit 0
}
# The uid lives in firebase.json, the single source of truth for this machine.
read_uid() {
local config="$CONFIG_DIR/firebase.json"
if [[ ! -r "$config" ]]; then
echo "Error: $config is missing or unreadable." >&2
echo "It holds the uid these rules pin; see README.md." >&2
exit 1
fi
python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['uid'])" \
"$config"
}
render() {
local uid="$1"
sed "s/$PLACEHOLDER/$uid/g" "$TEMPLATE"
}
# Fail closed: a rules file that still names the placeholder, or that any
# unauthenticated request would satisfy, must never reach the console.
validate() {
local rendered="$1"
if [[ "$rendered" == *"$PLACEHOLDER"* ]]; then
echo "Error: rendered rules still contain $PLACEHOLDER." >&2
exit 1
fi
if ! grep -q 'auth != null' <<< "$rendered"; then
echo "Error: rendered rules do not require authentication." >&2
exit 1
fi
if ! grep -q "auth.uid ===" <<< "$rendered"; then
echo "Error: rendered rules do not pin a uid; 'auth != null' alone" >&2
echo "lets any stranger who self-registers read and write." >&2
exit 1
fi
python3 -c "import json,sys; json.loads(sys.stdin.read())" <<< "$rendered"
}
main() {
local check_only=false
[[ "${1:-}" == "--check" ]] && check_only=true
[[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] && usage
local uid rendered
uid="$(read_uid)"
rendered="$(render "$uid")"
validate "$rendered"
if [[ "$check_only" == true ]]; then
echo "Rules render cleanly and pin a uid." >&2
else
printf '%s\n' "$rendered"
fi
}
main "$@"