Skip to content

v4.47.0 - #860

Merged
rzueger merged 29 commits into
masterfrom
develop
Aug 18, 2026
Merged

v4.47.0#860
rzueger merged 29 commits into
masterfrom
develop

Conversation

@rzueger

@rzueger rzueger commented Aug 18, 2026

Copy link
Copy Markdown
Member

No description provided.

claude added 29 commits August 14, 2026 22:54
The movement item-read rule compared createdBy to auth.token.email.
Guest and kiosk tokens carry no email claim, and guest-created
movements carry no createdBy, so null === null let any guest or
kiosk session read ownerless movement records directly by key.

Require a non-null auth.token.email before the owner comparison in
the generated item-read rule (personal-access projects only; shared
projects are unaffected). Add rule tests covering direct-key reads
of ownerless and owned records by a guest.
Database security rules checked administrator status with .exists()
on the /admins/<uid> node, which is satisfied by any stored value
(including false, 0 or an empty string). Align the rules with the API
layer, which already requires the value to equal true, so only an
explicit true grants administrator access.

Applies the change to the generated rule constant and to every static
occurrence in the rule template. Add rule tests covering a non-true
admin record (denied) alongside a genuine admin (allowed).
Workflows referenced actions by mutable refs (branch or major-version
tag), so the code executed by a run could change without review. Pin
every action to a full commit SHA, keeping a version comment for
readability and future updates.

Each action is pinned to the commit its current ref already resolves
to, so runner behaviour is unchanged:
  actions/checkout        -> v4.4.0
  actions/setup-node      -> v4.4.0
  anthropics/claude-code-action -> v1
  w9jds/setup-firebase    -> main tip (no release tag published)
The API auth middleware verified ID tokens without the revocation
check, so a token stayed valid until its natural expiry even after the
session was revoked or the account disabled. Pass checkRevoked=true to
verifyIdToken, matching the WebAuthn account-management path; the
existing catch already returns 401 for the resulting error.

Update the middleware unit test for the new call signature and add a
case asserting a revoked token yields 401.
On a save failure the client logged the entire movement object to the
browser console, including name, email, phone, remarks and member
number. On a shared or kiosk device that PII could persist in the
console for a later user. Log only non-identifying context (collection
path and record key), matching the payment-method save handler.
The public status page and the start-page banner read the /status node
directly from the database, which required a world-readable rule and
exposed the full status history (including author name and email) to
unauthenticated callers.

Serve both from the existing public status endpoint instead, polling for
updates, and restrict the raw /status database read to admins (the only
remaining direct reader is the admin settings view). The endpoint's
output is unchanged, so external consumers are unaffected.
The settlement trigger marked an arrival paid whenever a card payment
flipped to success, without checking the paid amount. Compare the paid
amount against the arrival's recorded fee (feeTotalGross) and refuse to
complete on a mismatch or when no fee is recorded, keeping the existing
not-already-completed guard.

Note: the recorded fee is still client-supplied; server-computed fees
are tracked as follow-up. This closes the amount-mismatch settlement
path. Adds tests for matching, mismatching and missing-fee cases.
User-controlled fields (registration, name, remarks, location, etc.)
were written to CSV exports unescaped, so a cell beginning with =, +,
-, @ (or tab/CR) would be executed as a formula when an admin opens the
report in a spreadsheet. Prefix such cells with a single quote so they
render as literal text. Applied in the shared CSV writer (covers the
movement report) and in the manually built landings report.
Report grouping indexed plain objects with user-controlled strings
(invoice recipient name, aircraft registration). A value such as
'__proto__' or 'constructor' then interacted with the object prototype
instead of being an ordinary key, corrupting the grouping or throwing
and breaking report generation. Build these accumulators with
Object.create(null) so untrusted keys are always plain data.
The customs completion URL stored on a movement was validated only as
a string, so an arbitrary (e.g. phishing) URL could be stored and later
opened from the "Open customs" action. Constrain it in the database
rules to begin with the configured customs baseUrl (falling back to the
previous behaviour when customs is not configured), which rejects the
write at the source. As defense in depth, the client now opens only
https completion URLs.
Hosting sent only cache headers. Add X-Frame-Options: DENY and a
Content-Security-Policy with frame-ancestors 'none' (clickjacking),
X-Content-Type-Options: nosniff, a strict Referrer-Policy, and a
Permissions-Policy disabling device APIs the app does not use. The CSP
is intentionally limited to frame-ancestors so it cannot break resource
loading; a full content policy is a separate, larger change.
The user directory is not maintained through the app: the server-side
sync endpoint (POST /api/users/import) and the admin CSV user import
were unused. Remove both, along with their now-dead supporting code.

Server: drop the /users/import route, the syncUsers module, the
Basic-auth middleware (its only consumer) and the member-management
route gating. Client: drop the imports module, the User import admin
tab/form, and the importUsers/importCsv/parseCsv helpers.

This also removes the unawaited-deletes bug in the sync path and the
Basic-auth-protected destructive route entirely, rather than fixing
them. The API_SERVICEUSER credentials and generated member-management
flag written by the deploy workflow are now unused and can be removed
separately.
airportName and themeColor come from the public sign-in request body
and were interpolated into the email HTML unescaped, allowing markup or
CSS to be injected into a message sent from the trusted sender. Escape
airportName for the HTML output and validate themeColor against a hex
or CSS-keyword format (falling back to a default otherwise). Plain-text
output keeps the raw name.
The scheduled aerodrome and aircraft-list jobs delete every existing
entry that is absent from the fetched feed. An empty, truncated or
tampered upstream feed therefore wipes the whole table in one run; the
only prior check (updates non-empty) does not prevent mass deletion.

Add three fail-closed guards to both jobs:
- skip the run when the imported feed yields no valid entries
- refuse runs that would delete more than 10% of a populated table
  (small tables stay exempt so seeding and normal churn still work)
- ignore malformed entries missing key fields (icao/name, registration)

Extend both specs with empty-feed, excessive-deletion and malformed
entry cases.
The public authentication-options endpoint returned real passkey
credentials for enrolled emails and an empty allowCredentials list for
unknown ones, so its response disclosed whether any given address is a
registered user with a passkey (an account enumeration oracle) despite a
comment claiming the opposite.

Substitute deterministic, secret-keyed decoy credentials when an email
has no real passkeys, so enrolled and non-enrolled responses look alike.
Decoys are keyed by WEBAUTHN_DECOY_SECRET (per-instance random fallback)
to stay unpredictable, and stable per email so repeated probes do not
reveal the difference. Real credentials are returned unchanged, so
genuine logins are unaffected.

Correct the misleading non-disclosure comment and add helper and
endpoint tests for the decoy behaviour.
Shared-device access (kiosk display, guest login) uses a fixed Firebase
uid and a shared access token. Four weaknesses are addressed:

- Kiosk link carried the token in the query string, leaking the shared
  secret into server logs and the Referer header. Move it into the URL
  fragment (like the guest link) and read it from there, with a
  query-string fallback for existing links.
- After the token is exchanged, scrub it from the address bar via
  history.replaceState so it no longer lingers in history or screenshots.
- Shared sessions used persistent (local) storage; use session
  persistence so they end with the browser session.
- unauth() did not return the signOut promise, so logout could redirect
  before sign-out completed; return and await it.

Server side:
- Drop the unused, unenforced ip custom claim from minted tokens.
- Revoke refresh tokens for the kiosk/guest uid whenever the stored
  access token changes, so rotating the token actually terminates
  existing sessions (on next refresh, <=1h).

Add tests for the new URL parsing/scrubbing, persistence selection,
saga behaviour, the token dispatcher, and the revocation triggers.
The /users node held pilot PII (name, email, phone, member number) and
was readable by any authenticated session, including guest and kiosk;
the contact masking was UI-only. The collection is effectively unused:
its only writer (the CSV import) was already removed, it is empty in
every project's database, and only one project ever displayed the
member field.

Remove the node and everything that touched it rather than patch a read
rule on a dead PII store:
- delete the /users node from the database rules (access now denied by
  default, so any residual data is unreadable)
- keep the member-number field on member-management projects as a plain
  input instead of the /users-backed autocomplete
- delete the users module, the user dropdown container and component,
  and the renderUserDropdown helpers
- drop the auth login name lookup that queried /users (the resolved
  name was never used and was always empty)

Update the affected specs.
The sign-in code verifier capped guesses per code at five, but counted
attempts with a read-snapshot-then-update. Concurrent guesses all read
the same attempt count and each wrote count + 1, so N parallel wrong
guesses advanced the counter by one instead of N. The cap never bit and
the six-digit code was brute-forceable within its lifetime.

Replace the snapshot-then-update with a per-code-node transaction (the
same atomic-claim pattern used for WebAuthn challenges): wrong guesses
increment atomically so concurrent attempts are each counted and the cap
holds, and a correct code is consumed inside the transaction so it
cannot be used twice. Rejections keep the existing generic message.

Rework the spec to the transaction model and add cap-enforcement cases.
The prepopulated-forms endpoint was guarded only by fbAuth and forwarded
the raw request body to the customs declaration app using the trusted
integration token. The whole payload was built on the client, so any
authenticated user (including guest/kiosk) could submit arbitrary
declarations under the privileged identity.

Build the payload on the server from the referenced movement instead:
- the client sends only { movementType, movementKey }
- the server loads the movement and aerodrome and constructs the payload
  from stored data; externalId is the movement key and aerodromeId comes
  from server config, never the client
- strict input validation; unknown movement returns 404
- restrict the route to non-shared sessions (reject guest/kiosk)
- audit-log the caller and movement

Move the payload builder and its helpers from the client saga to
functions/api/customs/buildCustomsPayload.js. Add specs for the builder
and the new middleware; update the client saga spec.
The server-side customs payload builder read movement.date and
movement.time, but the stored movement record has neither: it keeps a
combined ISO-UTC dateTime plus duration. The client only exposed
date/time because it splits dateTime into Europe/Zurich local parts when
loading. Server-side those reads were undefined, so the customs form
received date and arrivalTime as "Invalid date" and dropped
departureTime entirely.

Derive the local date and time from the movement's dateTime using Intl
(full-ICU, DST-aware), so no timezone dependency is needed, then format
the date and compute the arrival time as before. Update the spec to use
the real stored shape (dateTime + duration) and assert the derived
values.
Authenticated users can write to several collections. Unknown keys were
already rejected and numbers/dates/enums were type-bounded, but the
free-text string fields had no upper length, so a caller could store
multi-MB values and amplify storage/bandwidth cost.

Add a max-length validation to every string field on the non-admin
writable nodes: departures, arrivals, messages, profiles, and
card-payments (also non-admin creatable in the pending state). Caps are
generous so legitimate input is never rejected. This bounds size only
and does not change payment authorization or any other constraint; the
admin-only status node is left untouched.

Note: location is free-text (a place name when no ICAO code exists, e.g.
"HAGENBUCH"), so it is capped at 100 rather than a short code length.
The public authentication-options endpoint writes a challenge record on
every request with no throughput cap, and the cleanup job scanned the
whole challenge node hourly, so a flood produced unbounded write, storage
and cleanup cost.

- Cap concurrency with maxInstances on the challenge-writing option
  endpoints, giving a global throughput ceiling that bounds worst-case
  cost without a per-IP/NAT trade-off.
- Make cleanup read only expired records via an indexed expiry range
  query instead of scanning the entire node; index expiry in the rules.

App Check is the durable anti-abuse control and is tracked as a
follow-up (staged rollout). Per-IP limiting is intentionally avoided
(NAT-unsafe on a login endpoint, and adds a write per request).
Run npm audit fix (non-breaking only) to clear the safely-resolvable
advisories in the functions dependency tree. Lockfile-only change, no
package.json edits; the full functions test suite still passes.

The remaining advisories require major upgrades (nodemailer 8 to 9 and
firebase-admin to 14 for the uuid subtree) and are left for a separate,
tested upgrade rather than forced in. The reachable dependency
(nodemailer) is only vulnerable via the raw message option, which the
sign-in email flow does not use.
First step of moving fee computation server-side so fees become
authoritative rather than client-supplied (payment-integrity program).
Ports the lspl fee strategy and the shared orchestration (origin
classification, VAT, five-cent rounding) into functions/fees, with
parity tests copied from the client lspl spec so the server always
computes the same fee the client displays.

Engine only: not yet wired to a trigger or the rules. Only lspl is
ported (the pilot project); other projects are unaffected. An unknown
registration classifies as OTHER (no discount), so a faked registration
can only ever cost more.
Add an onValueWritten trigger on /arrivals that recomputes the
landing fee with the server-side fee engine and writes the
authoritative fee fields back to the record.

The trigger is gated per project by /settings/landingFeesStrategy:
absent means the project is not on server-owned fees and the
handler is a no-op, so it lands inert everywhere until a project
opts in. Only strategies ported into functions/fees are honoured;
an unrecognised one fails closed and writes nothing.

For a known registration the MTOW and category come from the
aircraft registry; otherwise the submitted values are used and
the record is flagged aircraftDataSource=manual. The pilot's own
declared mtow/aircraftCategory are never overwritten. A change
guard avoids re-triggering on the handler's own write.
Add an opt-in `scopeCardPaymentsToOwner` per-project flag. When set,
the card-payment rules only let the authenticated creator (or an
admin) read a payment and cancel their own pending one, and a new
payment must carry the creator's own uid as `owner`. Projects that
do not opt in keep the previous auth-only rule byte-for-byte, so
their behaviour is unchanged.

The client now stamps `owner` (the caller's uid) on the payment it
creates; the field is optional and permitted for every project via a
new `owner` validation rule, but only enforced where the flag is on.

Enable the flag for lspl. The payment webhook writes via the Admin
SDK and bypasses these rules, so its flow is unaffected.
The movement lock decided whether a record fell in the closed period
from the client-supplied numeric `negativeTimestamp`, which nothing
tied to the ISO `dateTime` the record actually stores and reports
display. A direct database writer could pass the lock with a recent
`negativeTimestamp` while backdating `dateTime` into a frozen period.

Rules cannot convert the numeric field to a date, so move the lock
onto `dateTime` itself. A new trigger derives `settings/lockDateIso`
(the lock threshold plus the existing one-day grace, as an ISO-UTC
string) from `settings/lockDate` whenever it changes; fixed-width
ISO-UTC strings sort chronologically, matching the `dateTime` regex,
so the rule comparison is exact. The derived field is server-written
and client-immutable (settings default-deny), so it cannot be forged.

Gated by an opt-in `lockOnDateTime` flag: projects that do not opt in
generate the previous numeric rule byte-for-byte, and the trigger only
writes the mirror where `/settings/lockOnDateTime` is set. While the
mirror is briefly absent the rule falls back to the numeric test, so
it is never weaker than before and never blocks writes. Enabled for
lspl.
The static username/password login had no rate limiting, so a shared
credential could be guessed with unlimited online attempts. Add a
per-IP failed-attempt counter: after 10 failures from one IP within
15 minutes, further attempts from that IP are rejected until the
window elapses; a successful login clears the counter, so normal use
never accumulates. A blocked attempt returns the same result as a
wrong password, revealing nothing.

The counter reuses the existing windowed-limiter pattern and is
stored under the server-only /staticAuthRateLimits node; the hourly
cleanup job prunes elapsed windows so it cannot grow unbounded. Only
projects that configure static credentials ever touch it.

This is a best-effort deterrent (the client IP comes from
X-Forwarded-For); the durable defenses remain a strong shared
credential and moving this login to the standard mechanisms.

Also make requestHelper.getIp tolerate a missing headers/connection.
Two remaining server-side controls for arrival billing integrity.

Rules now restrict paymentMethod.method to the known set (card,
checkout, cash, card_external, twint_external, invoice) instead of
any non-empty string.

On the trusted arrival write path, authorize the declared invoice
recipient: a recipient is only kept when the arrival author's
authenticated email is listed on it in settings/invoiceRecipients;
otherwise it is cleared. The recipient list is an array the rules
cannot search, so this is enforced server-side, gated by the same
landing-fee-strategy switch as the fee recompute.
@rzueger
rzueger merged commit 58bacd7 into master Aug 18, 2026
16 checks passed
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.

2 participants