Skip to content

Commit 5113aa3

Browse files
committed
chore(deps): update workspace dependencies safely
1 parent 933e83e commit 5113aa3

56 files changed

Lines changed: 1841 additions & 1971 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/.env.example

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,19 @@ HOST=0.0.0.0
55
# Cookie-authenticated POST/PUT/PATCH/DELETE requests must send one matching Origin.
66
CORS_ORIGIN=http://localhost:3000,http://127.0.0.1:3000
77

8-
# Number of reverse-proxy hops in front of the API.
9-
# - 0 — direct exposure (dev). request.ip is the actual TCP peer.
10-
# - 1 — single reverse proxy (default, e.g. Traefik in infra/docker).
11-
# - 2+ — chained proxies (e.g. Cloudflare → Traefik → API).
12-
# Setting this too high lets clients forge X-Forwarded-For; setting it too low
13-
# makes IP-keyed rate limits collapse to a single bucket per proxy. Pick the
14-
# exact count for your deployment.
15-
# TRUST_PROXY_HOPS=1
8+
# Comma-separated IPs, CIDRs, or proxy-addr aliases whose forwarding headers
9+
# may be trusted. Leave unset for direct development exposure. The generated
10+
# container deployment sets `uniquelocal`, covering its private Docker bridge.
11+
# For a custom proxy network, use the narrowest exact ranges possible.
12+
# TRUST_PROXY_RANGES=127.0.0.1,172.16.0.0/12,fd4d:5058::/64
1613

1714
# Public base URL of the API (e.g. https://maps.example.com). Used to rewrite
1815
# upstream tile/style URLs in /api/maptiler responses. If unset, falls back to
1916
# https://${DOMAIN}. Never derived from X-Forwarded-Host (header-spoofable).
2017
# PUBLIC_BASE_URL=https://maps.example.com
2118

2219
# Rate limits for the public surface. Keys are the client IP resolved through
23-
# TRUST_PROXY_HOPS. Defaults are sized for an interactive map session and
20+
# TRUST_PROXY_RANGES. Defaults are sized for an interactive map session and
2421
# capping scripted abuse. Loopback peers and /health are exempt; admin routes
2522
# use their own per-action limiters.
2623
# RATE_LIMIT_PUBLIC_MAX=600 # broad floor, requests per window

apps/api/package.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"@better-auth/oauth-provider": "1.6.26",
2626
"@better-auth/passkey": "1.6.26",
2727
"@fastify/cors": "^11.3.0",
28-
"@fastify/helmet": "^13.1.0",
28+
"@fastify/helmet": "^13.1.1",
2929
"@hey-api/client-fetch": "workspace:*",
3030
"@js-temporal/polyfill": "0.5.1",
3131
"@motis-project/motis-client": "^2.11.0",
@@ -46,12 +46,12 @@
4646
"cached-hafas-client": "^5.1.9",
4747
"db-vendo-client": "^6.11.1",
4848
"drizzle-orm": "^0.45.2",
49-
"fast-xml-parser": "^5.10.1",
50-
"fastify": "^5.11.3",
51-
"gtfs-realtime-bindings-transit": "^1.10.0",
49+
"fast-xml-parser": "^5.11.0",
50+
"fastify": "^5.12.1",
51+
"gtfs-realtime-bindings-transit": "^1.11.0",
5252
"hafas-client": "^6.3.6",
5353
"ioredis": "^6.0.0",
54-
"lettermint": "^2.1.0",
54+
"lettermint": "^2.3.0",
5555
"nodemailer": "^9.0.5",
5656
"opening_hours": "^3.14.0",
5757
"pino": "^10.3.1",
@@ -63,12 +63,12 @@
6363
"devDependencies": {
6464
"@fastify/swagger": "^9.8.1",
6565
"@testcontainers/postgresql": "^12.1.0",
66-
"@types/node": "^26.2.0",
66+
"@types/node": "^26.3.0",
6767
"@types/nodemailer": "^8.0.1",
68-
"@vitest/coverage-v8": "^4.1.10",
68+
"@vitest/coverage-v8": "^4.1.11",
6969
"drizzle-kit": "^0.31.10",
7070
"esbuild": "^0.28.2",
7171
"typescript": "^6.0.3",
72-
"vitest": "^4.1.10"
72+
"vitest": "^4.1.11"
7373
}
7474
}

apps/api/src/auth-oauth-provider.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ describe("managed OAuth provider policy", () => {
134134
});
135135
});
136136

137-
it("does not enable public registration or a trusted-client cache", async () => {
137+
it("does not enable public registration, multiple audiences, or a trusted-client cache", async () => {
138138
const { managedOAuthProviderOptions } = await import("./managed-oauth-provider");
139139

140140
expect(managedOAuthProviderOptions).not.toHaveProperty(
@@ -144,6 +144,10 @@ describe("managed OAuth provider policy", () => {
144144
expect(managedOAuthProviderOptions).not.toHaveProperty("cachedTrustedClients");
145145
expect(managedOAuthProviderOptions).not.toHaveProperty("disableJwtPlugin");
146146
expect(managedOAuthProviderOptions).not.toHaveProperty("storeClientSecret");
147+
// Better Auth 1.6's resource-indicator implementation is safe from
148+
// cross-audience escalation only with its single default audience.
149+
expect(managedOAuthProviderOptions).not.toHaveProperty("validAudiences");
150+
expect(managedOAuthProviderOptions).not.toHaveProperty("customAccessTokenClaims");
147151
});
148152

149153
it("assigns the managed-services reference only to authenticated administrators", async () => {

apps/api/src/server-wiring.test.ts

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,29 +23,38 @@ afterEach(() => {
2323
});
2424

2525
describe("trustProxyConfig", () => {
26-
it("defaults to 1 when unset", () => {
27-
vi.stubEnv("TRUST_PROXY_HOPS", "");
28-
expect(trustProxyConfig()).toBe(1);
26+
it("trusts no forwarding headers when proxy ranges are unset", () => {
27+
vi.stubEnv("TRUST_PROXY_RANGES", "");
28+
expect(trustProxyConfig()).toBe(false);
2929
});
3030

31-
it("parses 0 for direct exposure", () => {
32-
vi.stubEnv("TRUST_PROXY_HOPS", "0");
33-
expect(trustProxyConfig()).toBe(0);
34-
});
35-
36-
it("parses a positive hop count", () => {
37-
vi.stubEnv("TRUST_PROXY_HOPS", "3");
38-
expect(trustProxyConfig()).toBe(3);
31+
it("parses explicit IP, CIDR, and private-network aliases", () => {
32+
vi.stubEnv("TRUST_PROXY_RANGES", " loopback, 172.16.0.0/12, fd4d:5058::/64, uniquelocal ");
33+
expect(trustProxyConfig()).toEqual([
34+
"loopback",
35+
"172.16.0.0/12",
36+
"fd4d:5058::/64",
37+
"uniquelocal",
38+
]);
3939
});
4040

41-
it("throws on a non-integer value", () => {
42-
vi.stubEnv("TRUST_PROXY_HOPS", "yes");
43-
expect(() => trustProxyConfig()).toThrow(/TRUST_PROXY_HOPS/);
41+
it.each([
42+
"yes",
43+
"127.0.0.1,",
44+
"127.0.0.1/33",
45+
"fd4d:5058::/129",
46+
"10.0.0.1/nope",
47+
"10.0.0.1/1e1",
48+
"10.0.0.1/+8",
49+
"10.0.0.1/8.5",
50+
])("rejects invalid proxy range %s", (value) => {
51+
vi.stubEnv("TRUST_PROXY_RANGES", value);
52+
expect(() => trustProxyConfig()).toThrow(/TRUST_PROXY_RANGES/);
4453
});
4554

46-
it("throws on a negative value", () => {
47-
vi.stubEnv("TRUST_PROXY_HOPS", "-1");
48-
expect(() => trustProxyConfig()).toThrow(/TRUST_PROXY_HOPS/);
55+
it.each(["0.0.0.0/0", "::/0"])("rejects trust-all proxy range %s", (value) => {
56+
vi.stubEnv("TRUST_PROXY_RANGES", value);
57+
expect(() => trustProxyConfig()).toThrow(/trust every address/i);
4958
});
5059
});
5160

apps/api/src/server-wiring.ts

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isIP } from "node:net";
12
import type {
23
FastifyBaseLogger,
34
FastifyError,
@@ -102,28 +103,43 @@ export function registerControlledRequestLogging(
102103
});
103104
}
104105

105-
// Trust proxy hops in front of the API. The default deployment terminates TLS
106-
// at Traefik (one hop) and forwards to this container, so `request.ip` must be
107-
// derived from the leftmost untrusted X-Forwarded-For entry rather than from
108-
// the socket peer (which would always be the proxy). Without this, IP-keyed
109-
// rate limits collapse to a single bucket per upstream proxy.
110-
//
111-
// SECURITY: never set this to `true` (trust everyone) on a public deployment
112-
// — that would let any client spoof their IP via X-Forwarded-For and bypass
113-
// rate limits, audit attribution, and the loopback admin short-circuit. Set
114-
// `TRUST_PROXY_HOPS` to the *exact* number of proxies between the public
115-
// internet and this process (default 1 = one Traefik hop). Set it to `0` for
116-
// direct exposure (development).
117-
export function trustProxyConfig(): number | boolean {
118-
const raw = process.env.TRUST_PROXY_HOPS?.trim();
119-
if (raw === undefined || raw === "") return 1; // default: assume one Traefik hop
120-
const n = Number(raw);
121-
if (!Number.isInteger(n) || n < 0) {
122-
throw new Error(
123-
`TRUST_PROXY_HOPS must be a non-negative integer (got "${raw}"). Use 0 for direct exposure, 1 for a single reverse proxy (default).`,
124-
);
106+
const TRUST_PROXY_ALIASES = new Set(["loopback", "linklocal", "uniquelocal"]);
107+
108+
function validateProxyRange(value: string): void {
109+
if (TRUST_PROXY_ALIASES.has(value)) return;
110+
const parts = value.split("/");
111+
const address = parts[0] ?? "";
112+
const family = isIP(address);
113+
if (family === 0 || parts.length > 2) {
114+
throw new Error(`TRUST_PROXY_RANGES contains an invalid IP or CIDR: "${value}"`);
115+
}
116+
if (parts.length === 1) return;
117+
const prefixText = parts[1] ?? "";
118+
const prefix = Number(prefixText);
119+
const maximum = family === 4 ? 32 : 128;
120+
if (!/^\d+$/.test(prefixText) || !Number.isInteger(prefix) || prefix > maximum) {
121+
throw new Error(`TRUST_PROXY_RANGES contains an invalid CIDR prefix: "${value}"`);
122+
}
123+
if (prefix === 0) {
124+
throw new Error(`TRUST_PROXY_RANGES must not trust every address: "${value}"`);
125+
}
126+
}
127+
128+
// Forwarding headers are ignored unless the immediate socket peer belongs to
129+
// an explicitly trusted IP/CIDR range. The default container deployment sets
130+
// `uniquelocal`, which covers Docker's private IPv4 and ULA IPv6 networks; a
131+
// directly run development server leaves the variable unset and trusts none.
132+
// Address-based trust is essential: hop counts cannot distinguish the real
133+
// reverse proxy from a client that reaches the origin directly.
134+
export function trustProxyConfig(): false | string[] {
135+
const raw = process.env.TRUST_PROXY_RANGES?.trim();
136+
if (raw === undefined || raw === "") return false;
137+
const ranges = raw.split(",").map((value) => value.trim());
138+
if (ranges.some((value) => value.length === 0)) {
139+
throw new Error("TRUST_PROXY_RANGES must be a comma-separated list without empty entries");
125140
}
126-
return n;
141+
for (const range of ranges) validateProxyRange(range);
142+
return ranges;
127143
}
128144

129145
// Uniform error body. Throwing handlers/guards (requireAuth/requireAdmin throw

apps/api/src/utils/__tests__/rate-limit.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ afterEach(() => {
77
// because each test instantiates its own RateLimiter.
88
});
99

10-
function buildApp(limiter: RateLimiter, opts: { trustProxy?: boolean | number } = {}) {
10+
function buildApp(limiter: RateLimiter, opts: { trustProxy?: boolean | string | string[] } = {}) {
1111
const app = Fastify({ trustProxy: opts.trustProxy ?? false });
1212
app.get("/ping", { preHandler: [limiter.preHandler()] }, async () => ({ ok: true }));
1313
return app;
@@ -52,13 +52,13 @@ describe("RateLimiter", () => {
5252
limiter.destroy();
5353
});
5454

55-
it("under trustProxy=1, the resolved client IP (not the proxy) is the bucket key", async () => {
55+
it("uses the resolved client IP behind an explicitly trusted private proxy", async () => {
5656
// The bucket key is `req.ip`, which under `trustProxy` is the leftmost
5757
// X-Forwarded-For entry. Two real clients behind the same proxy must
5858
// therefore get separate buckets — otherwise rate limits collapse to a
5959
// single bucket per upstream proxy in deployment.
6060
const limiter = new RateLimiter({ max: 1, windowMs: 60_000 });
61-
const app = buildApp(limiter, { trustProxy: 1 });
61+
const app = buildApp(limiter, { trustProxy: "uniquelocal" });
6262

6363
const a = await app.inject({
6464
method: "GET",

apps/api/src/utils/__tests__/require-admin.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,23 +142,23 @@ describe("requireAdmin loopback short-circuit", () => {
142142
expect(res.statusCode).toBe(401);
143143
});
144144

145-
it("rejects a non-loopback request that spoofs X-Forwarded-For=127.0.0.1 under trustProxy", async () => {
145+
it("rejects a request through a trusted proxy that spoofs X-Forwarded-For=127.0.0.1", async () => {
146146
// Simulate a deployment behind Traefik: the API trusts the immediate proxy
147147
// hop and resolves `request.ip` from XFF. A malicious public client sends
148148
// `X-Forwarded-For: 127.0.0.1` to try to claim the loopback short-circuit.
149149
// The check uses `request.socket.remoteAddress` (the actual TCP peer), so
150150
// the forgery must be rejected.
151151
process.env.OPENMAPX_LOCAL_ADMIN_TOKEN = "s3cret";
152152
mockGetSession.mockResolvedValue(null);
153-
const app = Fastify({ trustProxy: 1 });
153+
const app = Fastify({ trustProxy: "uniquelocal" });
154154
app.get("/protected", async (request) => {
155155
const session = await requireAdmin(request);
156156
return { ok: true, userId: session.user.id };
157157
});
158158
const res = await app.inject({
159159
method: "GET",
160160
url: "/protected",
161-
remoteAddress: "203.0.113.7", // public attacker IP (TEST-NET-3)
161+
remoteAddress: "10.0.0.5", // the private reverse proxy
162162
headers: {
163163
"x-forwarded-for": "127.0.0.1",
164164
"x-openmapx-local-admin": "s3cret",

apps/mobile/package.json

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,18 @@
2424
"@openmapx/core": "workspace:^",
2525
"@openmapx/i18n": "workspace:^",
2626
"@react-native-community/netinfo": "12.0.1",
27-
"babel-preset-expo": "~57.0.6",
28-
"expo": "~57.0.0",
27+
"babel-preset-expo": "~57.0.8",
28+
"expo": "~57.0.16",
2929
"expo-application": "~57.0.2",
30-
"expo-build-properties": "~57.0.9",
31-
"expo-constants": "~57.0.9",
30+
"expo-build-properties": "~57.0.14",
31+
"expo-constants": "~57.0.14",
3232
"expo-keep-awake": "~57.0.1",
33-
"expo-location": "~57.0.8",
34-
"expo-notifications": "~57.0.9",
35-
"expo-splash-screen": "~57.0.5",
33+
"expo-location": "~57.0.13",
34+
"expo-notifications": "~57.0.14",
35+
"expo-splash-screen": "~57.0.8",
3636
"expo-sqlite": "~57.0.1",
3737
"expo-system-ui": "~57.0.2",
38-
"expo-task-manager": "~57.0.8",
38+
"expo-task-manager": "~57.0.13",
3939
"react": "19.2.8",
4040
"react-native": "0.86.2",
4141
"react-native-webview": "13.16.1",
@@ -46,14 +46,14 @@
4646
"@react-native/jest-preset": "0.86.2",
4747
"@testing-library/react-native": "^14.0.1",
4848
"@types/jest": "^30.0.0",
49-
"@types/node": "^26.2.0",
49+
"@types/node": "^26.3.0",
5050
"@types/react": "^19.2.18",
5151
"ajv": "^8.20.0",
5252
"ajv-formats": "^3.0.1",
5353
"esbuild": "^0.28.2",
54-
"expo-doctor": "^1.20.1",
54+
"expo-doctor": "^1.20.3",
5555
"jest": "^30.4.2",
56-
"jest-expo": "^57.0.3",
56+
"jest-expo": "^57.0.4",
5757
"test-renderer": "^1.2.0",
5858
"tsx": "4.23.11",
5959
"typescript": "^6.0.3"

apps/ops-agent/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@
1212
},
1313
"dependencies": {
1414
"@openmapx/core": "workspace:^",
15-
"fastify": "^5.11.3",
15+
"fastify": "^5.12.1",
1616
"tsx": "4.23.5"
1717
},
1818
"devDependencies": {
19-
"@types/node": "^26.2.0",
19+
"@types/node": "^26.3.0",
2020
"typescript": "^6.0.3",
21-
"vitest": "^4.1.10"
21+
"vitest": "^4.1.11"
2222
}
2323
}

apps/ops-agent/src/server-review-2.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,19 @@ describe("reviewed durable admission boundary", () => {
598598
payload: request(operation, "ops1_explicitRetry000", "opk1_explicitRetry000"),
599599
});
600600
expect(explicitRetry.statusCode).toBe(202);
601-
expect(explicitRetry.json().result.operationId).not.toBe("job1_restoredOperation0");
601+
const explicitOperationId = explicitRetry.json().result.operationId as string;
602+
expect(explicitOperationId).not.toBe("job1_restoredOperation0");
603+
let explicitState = explicitRetry.json().result.state as string;
604+
for (let attempt = 0; attempt < 100 && explicitState !== "succeeded"; attempt += 1) {
605+
await new Promise((resolve) => setTimeout(resolve, 1));
606+
const status = await app.inject({
607+
method: "GET",
608+
url: `/v1/operations/${explicitOperationId}`,
609+
headers: { ...auth(), "x-ops-request-id": "ops1_explicitStatus000" },
610+
});
611+
explicitState = status.json().result.state as string;
612+
}
613+
expect(explicitState).toBe("succeeded");
602614
await app.close();
603615
});
604616

0 commit comments

Comments
 (0)