Skip to content

Browser sessions can poison shared temporal baselines

Moderate
koala73 published GHSA-gxj5-54wh-7vgr Sep 4, 2026

Software

koala73/worldmonitor

Affected versions

<= 2.10.0 (main @ commit 8683e8e47)

Patched versions

main @ 8683e8e47

Description

Summary

The production dashboard lets ordinary browser sessions write global temporal-baseline state through POST /api/infrastructure/v1/record-baseline-snapshot. The endpoint trusts client-supplied counts and region names, performs non-atomic read/modify/write updates, and records every page load as another statistical sample.

This allows baseline poisoning and Redis key amplification, while normal traffic already biases the model toward visitor volume rather than independent time observations.

Reviewed at commit 396efb905fadda74c4ae77080a1e72658c37aa0e. No production mutation was performed.

Evidence

  • src/services/temporal-baseline.ts:100-108 sends baseline updates from the browser.
  • src/app/data-loader.ts:2592-2595, 2895-2897, and 3150-3153 submit global military-flight, vessel, and AIS-gap counts during client data loads/refreshes.
  • server/worldmonitor/infrastructure/v1/record-baseline-snapshot.ts:20-69 accepts the submitted values and writes the shared baseline:* namespace.
  • The route is not premium or internal-service gated. A normal anonymously mintable dashboard session is sufficient.
  • region is inserted directly into the Redis key by makeBaselineKey() (server/worldmonitor/infrastructure/v1/_shared.ts:29-31).
  • The handler accepts any numeric count other than NaN; negative values and positive/negative infinity after generated Number(...) coercion are not rejected.
  • Each request can create up to 20 keys with a 90-day TTL.
  • Updates use MGET, calculate Welford state in the edge function, then issue independent SETs. Concurrent requests reading the same prior value overwrite one another, so sample count and moments are not atomic.

Impact

  1. Baseline poisoning: a caller can drive means and variance for accepted metric types and arbitrary regions, suppressing real anomalies or creating false ones.
  2. Global integrity failure: all visitors read shared baseline keys; values are not scoped to the submitting session.
  3. Traffic-weighted statistics: repeated browser loads add duplicate near-simultaneous observations. Ten visitors can satisfy the ten-sample learning threshold without ten independent time observations.
  4. Lost updates: concurrent legitimate clients collapse into one write because the Welford update is not atomic.
  5. Key amplification: arbitrary region strings create attacker-selected Redis keys retained for 90 days.
  6. Non-finite corruption: an accepted infinite count can propagate non-finite Welford state; JSON serialization can turn resulting NaN values into null, breaking later calculations.

These baselines feed the anomaly severity path used by the dashboard and downstream signal/CII ingestion.

Recommended fix

  1. Remove browser-authoritative writes. Compute and sample the three client-only metrics in one trusted server/relay job at a fixed cadence.
  2. Restrict writes to an internal authenticated principal; a public/anonymously mintable session must not mutate shared model state.
  3. Validate type, a finite non-negative bounded count, and region against a canonical allowlist before key construction.
  4. Use an atomic Lua script or transaction for Welford updates.
  5. Make sample identity/cadence explicit so duplicate observations for the same interval are idempotent.
  6. Version/reset the poisoned baseline namespace after deployment.
  7. Add per-endpoint fail-closed rate limiting as defense in depth, not as the authorization mechanism.

Acceptance criteria

  • Public browser traffic cannot write global temporal baseline state.
  • One scheduled observation interval contributes at most one sample per metric/region.
  • Invalid/non-finite/out-of-range counts and unknown regions are rejected before Redis access.
  • Concurrent updates preserve the exact sample count and Welford moments.
  • Existing baseline keys are invalidated or migrated after the trust-boundary fix.

Maintainer validation (2026-09-02)

Every mechanical claim in this report is CONFIRMED at origin/main. Two corrections narrow the blast radius, and one detail in the report understated the authorization picture in a way worth recording.

Confirmed

The gate chain for POST /api/infrastructure/v1/record-baseline-snapshot was traced end to end in server/gateway.ts. The route is absent from ENDPOINT_ENTITLEMENTS, absent from PREMIUM_RPC_PATHS, and absent from PUBLIC_NO_AUTH_RPC_PATHS. The only real gate is validateApiKey, which an anonymously mintable wms_ session token satisfies. Origin checking is CSRF protection only and passes any request with no Origin header.

One nuance the report did not have: the endpoint is not merely missing a rate limit, it is explicitly exempted from the per-endpoint mutation policy via RATE_LIMIT_MUTATION_FALLBACK_EXEMPT (server/_shared/rate-limit.ts:655), on the stated grounds that a Redis-only write with no provider or LLM call "carries no spend/abuse risk". That reasoning is sound about spend and silent about integrity, which is precisely the gap this report identifies. The global limiter does still apply.

Also confirmed by execution: JSON.parse('{"count":1e999}') yields Infinity, which is typeof 'number' and not NaN, so it passed the old check.

Correction 1: the poisonable namespace is not the one the dashboard reads

Browser writes land in baseline:* (v1). The dashboard's anomaly feed reads baseline:v2:*, written only by the server-side listTemporalAnomalies rebuild under a Redis lock with a 60-minute sampling throttle. The two namespaces do not intersect. The reachable surface is the three client-only types (military_flights, vessels, ais_gaps) plus unused v1 rows.

Correction 2: no cross-user string injection

The displayed anomaly message is built client-side from the requesting client's own region (src/services/temporal-baseline.ts:42-54), never from a stored value. A poisoned region creates junk Redis keys but never reaches another viewer's DOM.

Severity

Scored CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L (6.4). PR:N because an anonymous token suffices, C:N because nothing is disclosed, I:L/A:L because the damage is to a secondary signal rather than the primary anomaly feed. CWE-345 and CWE-20.

Remediation status: MITIGATED, not fully fixed

PR #7573 closes acceptance criteria 3, 4, 5 and part of 2:

  • region is allowlisted before any key is built, so unbounded 90-day key creation is closed. Validation also now runs ahead of key construction; previously every key was built from the caller's region and MGET'd before the update was judged usable.
  • count must be finite, non-negative and bounded, closing the Infinity corruption.
  • A SET NX interval claim caps a baseline at one sample per hour however many callers report one, and makes the claim winner the sole writer, which closes the lost-update race without a Lua script.
  • makeBaselineKey gains a v1 segment, orphaning every previously written row, since a mean accumulated from unvalidated anonymous samples cannot be told apart from a poisoned one.

Criterion 1 is not met. Public browser traffic can still write shared baseline state; it simply can no longer submit malformed values or outvote anyone. Moving the three metrics to a trusted producer needs a decision the code cannot make alone: the client reports post-filter counts it computed after its own load, so a server-side count of the same Redis source would not carry the same meaning. Tracked in #7574. This advisory should stay open until that lands.

A separate latent bug surfaced during validation and is tracked in #7575: mgetJson reads unprefixed while setCachedJson prefixes, so on preview deployments this handler reads production baselines and writes prefixed preview keys. Harmless in production, where the prefix is empty.

All eight regression guards in tests/temporal-baseline-write-guard.test.mts fail against the pre-fix commit.

Thanks for the report. The endpoint analysis was accurate throughout, and the non-finite-count observation in particular was easy to overlook and genuinely damaging.


Correction (2026-09-04): now fully remediated, superseding the note above

The note above concluded "MITIGATED, not fully fixed" and "criterion 1 is not met". That is no longer accurate and is superseded by this entry.

Before merge, PR #7573 gained a further commit (aa751e31a, "disable untrusted shared writes") that went past the hardening described above and removed the trust boundary problem outright. recordBaselineSnapshot no longer writes anything: it fails closed with ApiError(403, 'Client baseline writes are disabled') before touching Redis.

Verified in main at 3b87335d6 by reading the shipped handler, not by trusting the merge:

export async function recordBaselineSnapshot(_ctx, _req) {
  throw new ApiError(403, 'Client baseline writes are disabled', '');
}

Its own comment states the reasoning this report argued for: "Anonymous sessions are intentionally easy to mint. Any scheme that chooses or aggregates their submitted counts can therefore be controlled by one caller."

Acceptance criteria, final

  1. Public browser traffic cannot write global temporal baseline state — MET. The endpoint refuses every client write.
  2. One sample per interval — moot; there are no client samples.
  3. Invalid, non-finite, out-of-range counts and unknown regions rejected — MET, and now unreachable regardless.
  4. Concurrent updates preserve sample count and moments — moot on this path; the server-side v2 rebuild retains its lock.
  5. Existing baseline keys invalidated — MET. makeBaselineKey gained a v1 segment, orphaning every row accumulated from unvalidated anonymous samples.

Consequence, recorded honestly

The three client-only metrics (military_flights, vessels, ais_gaps) no longer receive samples at all, so anomaly detection for them is inert until a trusted server-side producer replaces the client path. That is tracked in #7574 and is a deliberate availability-for-integrity trade: no signal is preferable to a signal any anonymous caller can steer.

Patched in main at 8683e8e47 (PR #7573). Version 2.10.0 is the last release carrying the vulnerable code; the fix is on main ahead of the next release.

A related latent bug found during this validation, where mgetJson read unprefixed while setCachedJson prefixed, is tracked in #7575; the baseline lock half of it was fixed by #7595.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
Low
Availability
Low

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L

CVE ID

No known CVE

Weaknesses

Improper Input Validation

The product receives input or data, but it does not validate or incorrectly validates that the input has the properties that are required to process the data safely and correctly. Learn more on MITRE.

Insufficient Verification of Data Authenticity

The product does not sufficiently verify the origin or authenticity of data, in a way that causes it to accept invalid data. Learn more on MITRE.