@cosmicdrift/kumiko-bundled-features 0.319.0 → 0.320.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -9
- package/src/auth-email-password/__tests__/auth-tenants-rate-limit.integration.test.ts +3 -0
- package/src/auth-email-password/__tests__/post-auth-landing.integration.test.ts +321 -0
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +4 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +4 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +21 -1
- package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +10 -0
- package/src/auth-email-password/auth-paths.ts +10 -6
- package/src/auth-email-password/changes.json +12 -0
- package/src/auth-email-password/index.ts +1 -1
- package/src/auth-email-password/web/__tests__/auth-form-logic.test.ts +34 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +30 -0
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +26 -0
- package/src/auth-email-password/web/auth-client.ts +16 -1
- package/src/auth-email-password/web/auth-form-logic.ts +10 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +10 -5
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -7
- package/src/auth-mfa/web/mfa-client.ts +18 -2
- package/src/channel-email/__tests__/smtp-transport-pinning.test.ts +42 -0
- package/src/channel-email/smtp-transport.ts +7 -0
- package/src/file-derivatives/feature.ts +1 -1
- package/src/file-derivatives/handlers/public-variant.query.ts +5 -7
- package/src/foundation-shared/__tests__/mail-host-policy.test.ts +68 -0
- package/src/foundation-shared/index.ts +9 -0
- package/src/foundation-shared/mail-host-policy.ts +70 -0
- package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +11 -0
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +14 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +88 -3
- package/src/inbound-provider-imap/changes.json +14 -1
- package/src/inbound-provider-imap/feature.ts +6 -3
- package/src/inbound-provider-imap/imap-client.ts +60 -2
- package/src/inbound-provider-imap/index.ts +1 -1
- package/src/ledger/__tests__/ledger.integration.test.ts +80 -0
- package/src/ledger/changes.json +9 -1
- package/src/ledger/entity.ts +14 -1
- package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +86 -1
- package/src/mail-transport-smtp/__tests__/feature.test.ts +123 -3
- package/src/mail-transport-smtp/changes.json +14 -1
- package/src/mail-transport-smtp/feature.ts +76 -1
- package/src/mail-transport-smtp/index.ts +6 -1
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +59 -0
- package/src/sessions/__tests__/sessions.integration.test.ts +5 -0
- package/src/shared/password-hashing.test.ts +21 -13
- package/src/step-dispatcher/__tests__/webhook-runner.test.ts +131 -0
- package/src/step-dispatcher/changes.json +14 -1
- package/src/step-dispatcher/feature.ts +16 -1
- package/src/step-dispatcher/index.ts +8 -1
- package/src/step-dispatcher/webhook-runner.ts +104 -18
- package/src/subscription-stripe/changes.json +6 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
- package/src/user-data-rights/__tests__/download-by-token-ip-bucket.integration.test.ts +170 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +12 -6
- package/src/user-data-rights/__tests__/extract-audit-meta.test.ts +36 -0
- package/src/user-data-rights/changes.json +6 -0
- package/src/user-data-rights/feature.ts +35 -37
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// #1307 regression: the `/user-export/by-token` httpRoute wrapper re-dispatches
|
|
2
|
+
// to `download-by-token` (rateLimit: { per: "ip", limit: 30 }) via an internal
|
|
3
|
+
// `app.fetch(new Request(...))` call. That internal request has no socket of
|
|
4
|
+
// its own, so the L3 per-ip bucket only sees a real client IP if the wrapper
|
|
5
|
+
// hands it one explicitly. Before the fix it forwarded a synthetic single-hop
|
|
6
|
+
// X-Forwarded-For header — which the shared client-ip resolver only honours
|
|
7
|
+
// at exactly `trustedProxyHops: 1` (see client-ip.ts). At the framework
|
|
8
|
+
// default (0) the header is ignored outright, and at >=2 a single-entry XFF
|
|
9
|
+
// is always short, so both cases fell back to the shared "unknown" bucket:
|
|
10
|
+
// one caller could exhaust the 30/min cap for every anonymous downloader.
|
|
11
|
+
// The fix passes the already-resolved `clientIp` straight through as the
|
|
12
|
+
// internal request's env (2nd `app.fetch` arg), independent of hop count.
|
|
13
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
15
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
16
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
17
|
+
import {
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
testTenantId,
|
|
21
|
+
unsafeCreateEntityTable,
|
|
22
|
+
unsafePushTables,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
24
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
25
|
+
import {
|
|
26
|
+
createComplianceProfilesFeature,
|
|
27
|
+
tenantComplianceProfileEntity,
|
|
28
|
+
} from "../../compliance-profiles";
|
|
29
|
+
import { createConfigFeature } from "../../config";
|
|
30
|
+
import { createConfigAccessorFactory } from "../../config/feature";
|
|
31
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
32
|
+
import { configValuesTable } from "../../config/table";
|
|
33
|
+
import { createDataRetentionFeature } from "../../data-retention";
|
|
34
|
+
import { fileFoundationFeature } from "../../file-foundation";
|
|
35
|
+
import { fileProviderInMemoryFeature } from "../../file-provider-inmemory";
|
|
36
|
+
import { createSessionsFeature } from "../../sessions";
|
|
37
|
+
import { createUserFeature } from "../../user";
|
|
38
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
39
|
+
import { exportDownloadTokenEntity } from "../schema/download-token";
|
|
40
|
+
import { exportJobEntity } from "../schema/export-job";
|
|
41
|
+
|
|
42
|
+
const tenant = testTenantId(1);
|
|
43
|
+
|
|
44
|
+
async function buildStack(trustedProxyHops: number): Promise<TestStack> {
|
|
45
|
+
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
46
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
47
|
+
|
|
48
|
+
const stack = await setupTestStack({
|
|
49
|
+
features: [
|
|
50
|
+
createConfigFeature(),
|
|
51
|
+
createUserFeature(),
|
|
52
|
+
createDataRetentionFeature(),
|
|
53
|
+
createComplianceProfilesFeature(),
|
|
54
|
+
fileFoundationFeature,
|
|
55
|
+
fileProviderInMemoryFeature,
|
|
56
|
+
authFoundationFeature,
|
|
57
|
+
createSessionsFeature(),
|
|
58
|
+
createUserDataRightsFeature(),
|
|
59
|
+
],
|
|
60
|
+
extraContext: ({ registry }) => ({
|
|
61
|
+
configResolver: resolver,
|
|
62
|
+
configEncryption: encryption,
|
|
63
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
64
|
+
}),
|
|
65
|
+
anonymousAccess: { defaultTenantId: tenant },
|
|
66
|
+
trustedProxyHops,
|
|
67
|
+
});
|
|
68
|
+
await unsafeCreateEntityTable(stack.db, exportJobEntity);
|
|
69
|
+
await unsafeCreateEntityTable(stack.db, exportDownloadTokenEntity);
|
|
70
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
71
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
72
|
+
await asRawClient(stack.db).unsafe(`
|
|
73
|
+
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
74
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
75
|
+
tenant_id UUID NOT NULL,
|
|
76
|
+
user_id TEXT NOT NULL,
|
|
77
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
78
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
79
|
+
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
80
|
+
inserted_by_id TEXT,
|
|
81
|
+
modified_by_id TEXT,
|
|
82
|
+
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
83
|
+
deleted_at TIMESTAMPTZ,
|
|
84
|
+
deleted_by_id TEXT,
|
|
85
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
86
|
+
UNIQUE(user_id, tenant_id)
|
|
87
|
+
)
|
|
88
|
+
`);
|
|
89
|
+
return stack;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Rate-limit runs before the handler body (dispatch-query.ts: feature-gate →
|
|
93
|
+
// rate-limit → access → validation → handler), so a garbage token still
|
|
94
|
+
// consumes the bucket and lets the test avoid seeding a real export job —
|
|
95
|
+
// each call 404s (download.notFound) until the bucket is exhausted, then 429.
|
|
96
|
+
async function postByToken(
|
|
97
|
+
stack: TestStack,
|
|
98
|
+
headers: Record<string, string>,
|
|
99
|
+
socketAddress?: string,
|
|
100
|
+
): Promise<Response> {
|
|
101
|
+
const request = new Request("http://test/user-export/by-token", {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: { "content-type": "application/json", ...headers },
|
|
104
|
+
body: JSON.stringify({ token: "not-a-real-token" }),
|
|
105
|
+
});
|
|
106
|
+
return socketAddress ? stack.app.fetch(request, socketAddress) : stack.app.fetch(request);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function exhaustBucket(
|
|
110
|
+
stack: TestStack,
|
|
111
|
+
headers: Record<string, string>,
|
|
112
|
+
socketAddress?: string,
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
for (let i = 0; i < 30; i++) {
|
|
115
|
+
const res = await postByToken(stack, headers, socketAddress);
|
|
116
|
+
expect(res.status).toBe(404);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe("download-by-token IP bucket — trustedProxyHops: 0 (socket-only)", () => {
|
|
121
|
+
let stack: TestStack;
|
|
122
|
+
|
|
123
|
+
beforeAll(async () => {
|
|
124
|
+
stack = await buildStack(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
afterAll(async () => {
|
|
128
|
+
await stack.cleanup();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("two distinct callers each get their own 30/min bucket, not a shared one", async () => {
|
|
132
|
+
// hops=0 ignores X-Forwarded-For outright — distinct callers are
|
|
133
|
+
// simulated via distinct socket addresses (2nd app.fetch arg) on the
|
|
134
|
+
// OUTER request; a spoofed XFF must have no effect at all.
|
|
135
|
+
const spoofedHeaders = { "x-forwarded-for": "ignored-at-hops-0" };
|
|
136
|
+
await exhaustBucket(stack, spoofedHeaders, "203.0.113.10");
|
|
137
|
+
const blocked = await postByToken(stack, spoofedHeaders, "203.0.113.10");
|
|
138
|
+
expect(blocked.status).toBe(429);
|
|
139
|
+
|
|
140
|
+
// A second caller (different socket) must not inherit caller A's
|
|
141
|
+
// exhausted bucket, even though it sends the exact same forged XFF.
|
|
142
|
+
const first = await postByToken(stack, spoofedHeaders, "203.0.113.20");
|
|
143
|
+
expect(first.status).toBe(404);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("download-by-token IP bucket — trustedProxyHops: 2", () => {
|
|
148
|
+
let stack: TestStack;
|
|
149
|
+
|
|
150
|
+
beforeAll(async () => {
|
|
151
|
+
stack = await buildStack(2);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterAll(async () => {
|
|
155
|
+
await stack.cleanup();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("two distinct callers (resolved via a 2-hop XFF chain) each get their own bucket", async () => {
|
|
159
|
+
// entries[length-hops] is the real client: index 1 of a 3-entry chain.
|
|
160
|
+
const callerA = { "x-forwarded-for": "client-forged, 10.1.1.1, 10.0.0.9" };
|
|
161
|
+
const callerB = { "x-forwarded-for": "client-forged, 10.2.2.2, 10.0.0.9" };
|
|
162
|
+
|
|
163
|
+
await exhaustBucket(stack, callerA);
|
|
164
|
+
const blocked = await postByToken(stack, callerA);
|
|
165
|
+
expect(blocked.status).toBe(429);
|
|
166
|
+
|
|
167
|
+
const first = await postByToken(stack, callerB);
|
|
168
|
+
expect(first.status).toBe(404);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -134,6 +134,9 @@ beforeAll(async () => {
|
|
|
134
134
|
anonymousAccess: {
|
|
135
135
|
defaultTenantId: tenantA,
|
|
136
136
|
},
|
|
137
|
+
// "download-by-job" below asserts the literal ip stored by requestContext,
|
|
138
|
+
// so the resolver needs a trusted hop to read x-forwarded-for at all.
|
|
139
|
+
trustedProxyHops: 1,
|
|
137
140
|
});
|
|
138
141
|
await unsafeCreateEntityTable(stack.db, exportJobEntity);
|
|
139
142
|
await unsafeCreateEntityTable(stack.db, exportDownloadTokenEntity);
|
|
@@ -383,10 +386,10 @@ describe("download-by-job :: happy path", () => {
|
|
|
383
386
|
test("session-auth: Job-Owner → returns signed URL + audit (IP aus X-Forwarded-For)", async () => {
|
|
384
387
|
const { jobId } = await seedDoneJobWithToken();
|
|
385
388
|
|
|
386
|
-
//
|
|
387
|
-
// X-CSRF-Token).
|
|
388
|
-
// (X-Forwarded-For
|
|
389
|
-
//
|
|
389
|
+
// The UI click runs as a direct download-by-job query (client sends
|
|
390
|
+
// X-CSRF-Token). The audit IP is server-trusted from the RequestContext
|
|
391
|
+
// (X-Forwarded-For entry appended by the trusted proxy at
|
|
392
|
+
// trustedProxyHops=1), never from a client-supplied field.
|
|
390
393
|
const res = await stack.http.queryWithHeaders(
|
|
391
394
|
"user-data-rights:query:download-by-job",
|
|
392
395
|
{ jobId },
|
|
@@ -403,7 +406,7 @@ describe("download-by-job :: happy path", () => {
|
|
|
403
406
|
lastUsedFromIp: string | null;
|
|
404
407
|
}>;
|
|
405
408
|
expect(row?.useCount).toBe(1);
|
|
406
|
-
expect(row?.lastUsedFromIp).toBe("10.0.0.
|
|
409
|
+
expect(row?.lastUsedFromIp).toBe("10.0.0.1");
|
|
407
410
|
});
|
|
408
411
|
|
|
409
412
|
test("failed Job (status != done) → 404 download.unavailable (job-Pfad)", async () => {
|
|
@@ -485,6 +488,8 @@ describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
|
|
|
485
488
|
test("POST-Exchange (Fragment-Pfad): happy-path + IP/UA-Audit aus httpRoute-Headers", async () => {
|
|
486
489
|
const { jobId, plainToken } = await seedDoneJobWithToken();
|
|
487
490
|
|
|
491
|
+
// trustedProxyHops=1 here → the resolver trusts the last XFF entry, not
|
|
492
|
+
// the client-spoofable first one.
|
|
488
493
|
const res = await stack.app.fetch(
|
|
489
494
|
new Request("http://test/user-export/by-token", {
|
|
490
495
|
method: "POST",
|
|
@@ -506,7 +511,8 @@ describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
|
|
|
506
511
|
lastUsedUserAgent: string | null;
|
|
507
512
|
}>;
|
|
508
513
|
expect(row?.useCount).toBe(1);
|
|
509
|
-
expect(row?.lastUsedFromIp).toBe("
|
|
514
|
+
expect(row?.lastUsedFromIp).toBe("10.0.0.1");
|
|
515
|
+
expect(row?.lastUsedFromIp).not.toBe("203.0.113.9");
|
|
510
516
|
expect(row?.lastUsedUserAgent).toBe("e2e-test/3.0");
|
|
511
517
|
});
|
|
512
518
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// extractAuditMeta takes the framework-resolved clientIp instead of reading
|
|
2
|
+
// X-Forwarded-For itself, so a caller can't plant a fake audit IP.
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { UNKNOWN_CLIENT_IP } from "@cosmicdrift/kumiko-framework/api";
|
|
5
|
+
import { extractAuditMeta } from "../feature";
|
|
6
|
+
|
|
7
|
+
describe("extractAuditMeta", () => {
|
|
8
|
+
test("uses the resolved clientIp, ignoring a spoofed X-Forwarded-For header on the same request", () => {
|
|
9
|
+
const headers = new Headers({
|
|
10
|
+
"x-forwarded-for": "203.0.113.1, 10.0.0.1",
|
|
11
|
+
"x-real-ip": "203.0.113.1",
|
|
12
|
+
"user-agent": "test-agent",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const auditMeta = extractAuditMeta(headers, "198.51.100.7");
|
|
16
|
+
|
|
17
|
+
expect(auditMeta.ip).toBe("198.51.100.7");
|
|
18
|
+
expect(auditMeta.ip).not.toBe("203.0.113.1");
|
|
19
|
+
expect(auditMeta.userAgent).toBe("test-agent");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("maps the resolver's unknown sentinel to null instead of storing the literal string", () => {
|
|
23
|
+
const auditMeta = extractAuditMeta(new Headers(), UNKNOWN_CLIENT_IP);
|
|
24
|
+
|
|
25
|
+
expect(auditMeta.ip).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("reads user-agent from headers independently of the resolved IP", () => {
|
|
29
|
+
const auditMeta = extractAuditMeta(
|
|
30
|
+
new Headers({ "user-agent": "spoofable-but-fine" }),
|
|
31
|
+
"1.2.3.4",
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(auditMeta.userAgent).toBe("spoofable-but-fine");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.320.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Audit IP comes from the framework's trustedProxyHops-aware resolver, not a self-parsed X-Forwarded-For",
|
|
6
|
+
"detail": "`extractAuditMeta` no longer reads `X-Forwarded-For`/`X-Real-IP` itself —\nit takes the `clientIp` the `/user-export/by-token` httpRoute handler now\nreceives from `HttpRouteHandlerDeps`, mapping the resolver's `unknown`\nsentinel to `null`. Closes the spoofed-first-XFF-entry gap for that\nroute. A caller invoking `/api/query`'s `download-by-token` handler\ndirectly (not through this httpRoute) can still pass its own `auditMeta`\nin the payload — a pre-existing, documented tradeoff (the handler's own\ncomment: audit data isn't security-relevant), unchanged by this fix."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.313.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312 (feature.ts > 300 lines)
|
|
2
|
+
import { UNKNOWN_CLIENT_IP } from "@cosmicdrift/kumiko-framework/api";
|
|
2
3
|
import {
|
|
3
4
|
createSystemConfig,
|
|
4
5
|
defineFeature,
|
|
@@ -486,10 +487,11 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
486
487
|
// as a POST body instead of a query string (issue #1271). A dedicated
|
|
487
488
|
// route rather than letting the browser JS call /api/query directly,
|
|
488
489
|
// for two reasons:
|
|
489
|
-
// 1. IP/UA audit — extractAuditMeta needs the raw request headers
|
|
490
|
-
// which only the server has, not
|
|
491
|
-
// above). Without this route every
|
|
492
|
-
// land in download-attempt with
|
|
490
|
+
// 1. IP/UA audit — extractAuditMeta needs the raw request headers and
|
|
491
|
+
// the framework-resolved client IP, which only the server has, not
|
|
492
|
+
// the browser (see the GET path above). Without this route every
|
|
493
|
+
// fragment-path download would land in download-attempt with
|
|
494
|
+
// lastUsedFromIp/UA = null.
|
|
493
495
|
// 2. /api/query is the reserved dispatcher namespace for
|
|
494
496
|
// authenticated writes/queries — an anonymous cross-origin POST
|
|
495
497
|
// there isn't guaranteed to be reachable (origin guard).
|
|
@@ -497,7 +499,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
497
499
|
method: "POST",
|
|
498
500
|
path: "/user-export/by-token",
|
|
499
501
|
anonymous: true,
|
|
500
|
-
handler: async (c, { app }) => {
|
|
502
|
+
handler: async (c, { app, clientIp }) => {
|
|
501
503
|
const url = new URL(c.req.url);
|
|
502
504
|
const parsedBody = await c.req.json().catch(() => null);
|
|
503
505
|
const token =
|
|
@@ -507,28 +509,29 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
507
509
|
if (typeof token !== "string" || token.length === 0) {
|
|
508
510
|
return c.json({ error: "missing_token" }, 400);
|
|
509
511
|
}
|
|
510
|
-
const auditMeta = extractAuditMeta(c.req.raw.headers);
|
|
512
|
+
const auditMeta = extractAuditMeta(c.req.raw.headers, clientIp);
|
|
511
513
|
return app.fetch(
|
|
512
514
|
new Request(`${url.origin}/api/query`, {
|
|
513
515
|
method: "POST",
|
|
514
|
-
headers: {
|
|
515
|
-
"content-type": "application/json",
|
|
516
|
-
// download-by-token.query.ts's `rateLimit: { per: "ip", limit: 30 }`
|
|
517
|
-
// keys off request-id-middleware's own x-forwarded-for read on
|
|
518
|
-
// THIS internal request — without forwarding it, every fragment-
|
|
519
|
-
// POST download shares the request's own (empty/localhost) IP,
|
|
520
|
-
// turning the per-user 30/min backstop into one global bucket
|
|
521
|
-
// (#1307). Forwarding the same value extractAuditMeta already
|
|
522
|
-
// computed from the original request's headers doesn't widen the
|
|
523
|
-
// trust boundary: it's the identical XFF-trusts-first-hop model
|
|
524
|
-
// request-id-middleware.ts already applies framework-wide.
|
|
525
|
-
...(auditMeta.ip ? { "x-forwarded-for": auditMeta.ip } : {}),
|
|
526
|
-
},
|
|
516
|
+
headers: { "content-type": "application/json" },
|
|
527
517
|
body: JSON.stringify({
|
|
528
518
|
type: "user-data-rights:query:download-by-token",
|
|
529
519
|
payload: { token, auditMeta },
|
|
530
520
|
}),
|
|
531
521
|
}),
|
|
522
|
+
// download-by-token.query.ts's `rateLimit: { per: "ip", limit: 30 }`
|
|
523
|
+
// resolves the client IP from this internal request's env (2nd
|
|
524
|
+
// app.fetch arg), same as Bun.serve's socket address — without it,
|
|
525
|
+
// the resolver has neither a trusted XFF entry (trustedProxyHops
|
|
526
|
+
// defaults to 0, ignoring headers entirely) nor a socket, so every
|
|
527
|
+
// fragment-POST download would fall to the shared "unknown" bucket,
|
|
528
|
+
// turning the per-user 30/min backstop into one global bucket an
|
|
529
|
+
// attacker can exhaust for everyone (#1307). Passing `clientIp`
|
|
530
|
+
// directly as env — the same server-resolved, trustedProxyHops-
|
|
531
|
+
// aware value the outer request already trusted — reaches
|
|
532
|
+
// extractSocketAddress() without going through header/hop-count
|
|
533
|
+
// logic at all.
|
|
534
|
+
clientIp,
|
|
532
535
|
);
|
|
533
536
|
},
|
|
534
537
|
});
|
|
@@ -727,24 +730,19 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
727
730
|
});
|
|
728
731
|
}
|
|
729
732
|
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
}
|
|
743
|
-
if (!ip) {
|
|
744
|
-
const real = headers.get("x-real-ip");
|
|
745
|
-
if (real && real.length > 0) ip = real;
|
|
746
|
-
}
|
|
747
|
-
return { ip, userAgent: headers.get("user-agent") };
|
|
733
|
+
// `clientIp` comes from the server's trustedProxyHops-aware resolver instead
|
|
734
|
+
// of reading X-Forwarded-For here, where a forged first entry would spoof the
|
|
735
|
+
// audit IP. A caller hitting /api/query directly (bypassing this httpRoute
|
|
736
|
+
// wrapper) with its own auditMeta can still lie — accepted, see
|
|
737
|
+
// download-by-token.query.ts.
|
|
738
|
+
export function extractAuditMeta(
|
|
739
|
+
headers: Headers,
|
|
740
|
+
clientIp: string,
|
|
741
|
+
): { ip: string | null; userAgent: string | null } {
|
|
742
|
+
return {
|
|
743
|
+
ip: clientIp === UNKNOWN_CLIENT_IP ? null : clientIp,
|
|
744
|
+
userAgent: headers.get("user-agent"),
|
|
745
|
+
};
|
|
748
746
|
}
|
|
749
747
|
|
|
750
748
|
// Interstitial page for the magic-link fragment exchange (see the
|