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
- Baseline poisoning: a caller can drive means and variance for accepted metric types and arbitrary regions, suppressing real anomalies or creating false ones.
- Global integrity failure: all visitors read shared baseline keys; values are not scoped to the submitting session.
- 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.
- Lost updates: concurrent legitimate clients collapse into one write because the Welford update is not atomic.
- Key amplification: arbitrary region strings create attacker-selected Redis keys retained for 90 days.
- 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
- Remove browser-authoritative writes. Compute and sample the three client-only metrics in one trusted server/relay job at a fixed cadence.
- Restrict writes to an internal authenticated principal; a public/anonymously mintable session must not mutate shared model state.
- Validate type, a finite non-negative bounded count, and region against a canonical allowlist before key construction.
- Use an atomic Lua script or transaction for Welford updates.
- Make sample identity/cadence explicit so duplicate observations for the same interval are idempotent.
- Version/reset the poisoned baseline namespace after deployment.
- 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
- Public browser traffic cannot write global temporal baseline state — MET. The endpoint refuses every client write.
- One sample per interval — moot; there are no client samples.
- Invalid, non-finite, out-of-range counts and unknown regions rejected — MET, and now unreachable regardless.
- Concurrent updates preserve sample count and moments — moot on this path; the server-side v2 rebuild retains its lock.
- 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.
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-108sends baseline updates from the browser.src/app/data-loader.ts:2592-2595,2895-2897, and3150-3153submit global military-flight, vessel, and AIS-gap counts during client data loads/refreshes.server/worldmonitor/infrastructure/v1/record-baseline-snapshot.ts:20-69accepts the submitted values and writes the sharedbaseline:*namespace.regionis inserted directly into the Redis key bymakeBaselineKey()(server/worldmonitor/infrastructure/v1/_shared.ts:29-31).countother thanNaN; negative values and positive/negative infinity after generatedNumber(...)coercion are not rejected.MGET, calculate Welford state in the edge function, then issue independentSETs. Concurrent requests reading the same prior value overwrite one another, so sample count and moments are not atomic.Impact
NaNvalues intonull, breaking later calculations.These baselines feed the anomaly severity path used by the dashboard and downstream signal/CII ingestion.
Recommended fix
Acceptance criteria
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-snapshotwas traced end to end inserver/gateway.ts. The route is absent fromENDPOINT_ENTITLEMENTS, absent fromPREMIUM_RPC_PATHS, and absent fromPUBLIC_NO_AUTH_RPC_PATHS. The only real gate isvalidateApiKey, which an anonymously mintablewms_session token satisfies. Origin checking is CSRF protection only and passes any request with noOriginheader.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}')yieldsInfinity, which istypeof 'number'and notNaN, 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 readsbaseline:v2:*, written only by the server-sidelistTemporalAnomaliesrebuild 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
messageis built client-side from the requesting client's ownregion(src/services/temporal-baseline.ts:42-54), never from a stored value. A poisonedregioncreates 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:Nbecause an anonymous token suffices,C:Nbecause nothing is disclosed,I:L/A:Lbecause 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:
regionis 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.countmust be finite, non-negative and bounded, closing theInfinitycorruption.SET NXinterval 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.makeBaselineKeygains av1segment, 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:
mgetJsonreads unprefixed whilesetCachedJsonprefixes, 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.mtsfail 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.recordBaselineSnapshotno longer writes anything: it fails closed withApiError(403, 'Client baseline writes are disabled')before touching Redis.Verified in
mainat3b87335d6by reading the shipped handler, not by trusting the merge: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
makeBaselineKeygained av1segment, 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
mainat8683e8e47(PR #7573). Version2.10.0is the last release carrying the vulnerable code; the fix is onmainahead of the next release.A related latent bug found during this validation, where
mgetJsonread unprefixed whilesetCachedJsonprefixed, is tracked in #7575; the baseline lock half of it was fixed by #7595.