@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
|
@@ -25,14 +25,21 @@
|
|
|
25
25
|
// - `config` — für die Tenant-Config-Keys
|
|
26
26
|
// - `secrets` — für das verschlüsselte SMTP-Password
|
|
27
27
|
|
|
28
|
+
import type { lookup } from "node:dns/promises";
|
|
28
29
|
import {
|
|
29
30
|
createSmtpTransport,
|
|
30
31
|
type EmailTransport,
|
|
31
32
|
} from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
32
33
|
import {
|
|
34
|
+
BlockedHostError,
|
|
35
|
+
HostResolutionError,
|
|
36
|
+
MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
|
|
37
|
+
type MailConnectTarget,
|
|
38
|
+
readAllowedPrivateMailHostsFromEnv,
|
|
33
39
|
requireDefined,
|
|
34
40
|
requireNonEmpty,
|
|
35
41
|
requireSecretSet,
|
|
42
|
+
resolveMailConnectTarget,
|
|
36
43
|
} from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
37
44
|
import {
|
|
38
45
|
MAIL_TRANSPORT_EXTENSION,
|
|
@@ -41,9 +48,41 @@ import {
|
|
|
41
48
|
} from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
|
|
42
49
|
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
43
50
|
import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
51
|
+
import { UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
|
|
52
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
53
|
+
import * as z from "zod";
|
|
44
54
|
|
|
45
55
|
const FEATURE_NAME = "mail-transport-smtp";
|
|
46
56
|
|
|
57
|
+
const log = createFallbackLogger(FEATURE_NAME);
|
|
58
|
+
|
|
59
|
+
// Operator escape hatch for an internal relay or a dev/test SMTP server
|
|
60
|
+
// (mailpit, MailHog): KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS, an operator env
|
|
61
|
+
// var never a tenant-config value, so a tenant can never grant themselves
|
|
62
|
+
// the private-host bypass. Declared here (not duplicated in
|
|
63
|
+
// inbound-provider-imap's envSchema) since both features read the same
|
|
64
|
+
// var and composeEnvSchema rejects two features declaring the same key —
|
|
65
|
+
// see foundation-shared/mail-host-policy.ts for the shared reader + the
|
|
66
|
+
// guard this feeds.
|
|
67
|
+
export const mailTransportSmtpEnvSchema = z.object({
|
|
68
|
+
[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
|
|
69
|
+
.string()
|
|
70
|
+
.optional()
|
|
71
|
+
.describe(
|
|
72
|
+
"Comma-separated operator allowlist of private/internal hosts (e.g. a local mailpit/greenmail dev server) that bypass the public-address check for SMTP and IMAP host config. Shared with inbound-provider-imap — never a tenant-config value.",
|
|
73
|
+
),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Test-only DNS seam — production never calls this, resolveMailConnectTarget
|
|
77
|
+
// defaults to the real resolver. Lets tests pin deterministic, network-free
|
|
78
|
+
// host resolutions instead of depending on real DNS for a placeholder host.
|
|
79
|
+
// Reset it in afterEach/afterAll — this is module-global state.
|
|
80
|
+
let mailHostLookup: typeof lookup | undefined;
|
|
81
|
+
|
|
82
|
+
export function setSmtpMailHostLookup(fn: typeof lookup | undefined): void {
|
|
83
|
+
mailHostLookup = fn;
|
|
84
|
+
}
|
|
85
|
+
|
|
47
86
|
// =============================================================================
|
|
48
87
|
// Feature-definition
|
|
49
88
|
// =============================================================================
|
|
@@ -60,6 +99,7 @@ export const mailTransportSmtpFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
60
99
|
r.requires("config");
|
|
61
100
|
r.requires("secrets");
|
|
62
101
|
r.requires("mail-foundation");
|
|
102
|
+
r.envSchema(mailTransportSmtpEnvSchema);
|
|
63
103
|
|
|
64
104
|
// Provider-secret. Sensitive: redact-helper for admin-UI display.
|
|
65
105
|
const password = r.secret("smtp.password", {
|
|
@@ -134,6 +174,18 @@ export const SMTP_PASSWORD = mailTransportSmtpFeature.exports.password;
|
|
|
134
174
|
// Internal: build the EmailTransport from tenant config + secret
|
|
135
175
|
// =============================================================================
|
|
136
176
|
|
|
177
|
+
// Tenant-visible for both a blocked host and a DNS failure — must not
|
|
178
|
+
// reveal which one occurred, or the host itself. Built once so a
|
|
179
|
+
// HostResolutionError raised for the other branch can reuse the exact same
|
|
180
|
+
// `.message` string byte-for-byte instead of just a similar hint.
|
|
181
|
+
function mailHostUnreachableError(): UnconfiguredError {
|
|
182
|
+
return new UnconfiguredError({
|
|
183
|
+
feature: FEATURE_NAME,
|
|
184
|
+
key: "host",
|
|
185
|
+
hint: "host is not reachable or not allowed",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
137
189
|
async function buildSmtpTransport(
|
|
138
190
|
ctx: MailTransportContext,
|
|
139
191
|
tenantId: string,
|
|
@@ -177,12 +229,35 @@ async function buildSmtpTransport(
|
|
|
177
229
|
|
|
178
230
|
const password = await readPassword(ctx, tenantId);
|
|
179
231
|
|
|
232
|
+
let target: MailConnectTarget;
|
|
233
|
+
try {
|
|
234
|
+
target = await resolveMailConnectTarget(host, {
|
|
235
|
+
allowedPrivateMailHosts: readAllowedPrivateMailHostsFromEnv(),
|
|
236
|
+
lookupFn: mailHostLookup,
|
|
237
|
+
});
|
|
238
|
+
} catch (err) {
|
|
239
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
240
|
+
// Both branches throw the exact same tenant-visible message text — the
|
|
241
|
+
// blocked-vs-unresolvable distinction and the host itself stay
|
|
242
|
+
// server-log-only. Class stays distinct: UnconfiguredError (422, a
|
|
243
|
+
// config problem, no retry) for a blocked host vs HostResolutionError
|
|
244
|
+
// (transient, retried) for a DNS failure — see mailHostUnreachableError().
|
|
245
|
+
const unreachable = mailHostUnreachableError();
|
|
246
|
+
if (err instanceof BlockedHostError) {
|
|
247
|
+
log.warn("rejected blocked host", { host, reason });
|
|
248
|
+
throw unreachable;
|
|
249
|
+
}
|
|
250
|
+
log.warn("host resolution failed", { host, reason });
|
|
251
|
+
throw new HostResolutionError(unreachable.message);
|
|
252
|
+
}
|
|
253
|
+
|
|
180
254
|
return createSmtpTransport({
|
|
181
|
-
host,
|
|
255
|
+
host: target.host,
|
|
182
256
|
port,
|
|
183
257
|
secure,
|
|
184
258
|
from,
|
|
185
259
|
auth: { user: authUser, pass: password },
|
|
260
|
+
...(target.servername && { servername: target.servername }),
|
|
186
261
|
});
|
|
187
262
|
}
|
|
188
263
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
isPiiCiphertext,
|
|
10
10
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
11
11
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
+
import { createInMemoryFileProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
12
13
|
import {
|
|
13
14
|
setupTestStack,
|
|
14
15
|
type TestStack,
|
|
@@ -124,6 +125,10 @@ beforeAll(async () => {
|
|
|
124
125
|
// ≤2 requests per (distinct) token, so this ceiling never trips them.
|
|
125
126
|
patRateLimiter: createInMemoryLoginRateLimiter(3, 60_000),
|
|
126
127
|
},
|
|
128
|
+
// Only exercised by the route-guard describe block below (POST/GET
|
|
129
|
+
// /api/files) — proves the guard blocks a PAT there even though the
|
|
130
|
+
// route is actually mounted and reachable for a JWT/cookie caller.
|
|
131
|
+
files: { storageProvider: createInMemoryFileProvider() },
|
|
127
132
|
});
|
|
128
133
|
h = makeSessionHelpers(stack, TENANT);
|
|
129
134
|
|
|
@@ -550,3 +555,57 @@ describe("PAT list projection (fw#2548 Teil B)", () => {
|
|
|
550
555
|
expect(expired.rows[0]?.status).toBe("expired");
|
|
551
556
|
});
|
|
552
557
|
});
|
|
558
|
+
|
|
559
|
+
describe("PAT route guard: dispatcher routes only (#security)", () => {
|
|
560
|
+
test("PAT → GET /api/sse → 403, even with a granted scope", async () => {
|
|
561
|
+
const actor = await actorFor("route-guard-sse@example.com");
|
|
562
|
+
const token = await mintToken(actor);
|
|
563
|
+
const res = await stack.http.raw("GET", "/api/sse", undefined, {
|
|
564
|
+
Authorization: `Bearer ${token}`,
|
|
565
|
+
});
|
|
566
|
+
expect(res.status).toBe(403);
|
|
567
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
568
|
+
expect(body.error.code).toBe("access_denied");
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("PAT → POST /api/files → 403 (route is mounted — a JWT gets past the guard to the 400 validation)", async () => {
|
|
572
|
+
const actor = await actorFor("route-guard-files-post@example.com");
|
|
573
|
+
const token = await mintToken(actor);
|
|
574
|
+
const patRes = await h.authedPost("/api/files", token);
|
|
575
|
+
expect(patRes.status).toBe(403);
|
|
576
|
+
|
|
577
|
+
const jwt = await loginToken("route-guard-files-post@example.com");
|
|
578
|
+
const jwtRes = await h.authedPost("/api/files", jwt);
|
|
579
|
+
expect(jwtRes.status).toBe(400);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("PAT → GET /api/files/:id → 403 (a JWT gets past the guard to the 404 not_found)", async () => {
|
|
583
|
+
const actor = await actorFor("route-guard-files-get@example.com");
|
|
584
|
+
const token = await mintToken(actor);
|
|
585
|
+
const patRes = await stack.http.raw("GET", "/api/files/does-not-exist", undefined, {
|
|
586
|
+
Authorization: `Bearer ${token}`,
|
|
587
|
+
});
|
|
588
|
+
expect(patRes.status).toBe(403);
|
|
589
|
+
|
|
590
|
+
const jwt = await loginToken("route-guard-files-get@example.com");
|
|
591
|
+
const jwtRes = await stack.http.raw("GET", "/api/files/does-not-exist", undefined, {
|
|
592
|
+
Authorization: `Bearer ${jwt}`,
|
|
593
|
+
});
|
|
594
|
+
expect(jwtRes.status).toBe(404);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test("PAT → GET /api/auth/tenants → 403 (a JWT reaches the real 200)", async () => {
|
|
598
|
+
const actor = await actorFor("route-guard-tenants@example.com");
|
|
599
|
+
const token = await mintToken(actor);
|
|
600
|
+
const patRes = await stack.http.raw("GET", "/api/auth/tenants", undefined, {
|
|
601
|
+
Authorization: `Bearer ${token}`,
|
|
602
|
+
});
|
|
603
|
+
expect(patRes.status).toBe(403);
|
|
604
|
+
|
|
605
|
+
const jwt = await loginToken("route-guard-tenants@example.com");
|
|
606
|
+
const jwtRes = await stack.http.raw("GET", "/api/auth/tenants", undefined, {
|
|
607
|
+
Authorization: `Bearer ${jwt}`,
|
|
608
|
+
});
|
|
609
|
+
expect(jwtRes.status).toBe(200);
|
|
610
|
+
});
|
|
611
|
+
});
|
|
@@ -100,6 +100,11 @@ beforeEach(async () => {
|
|
|
100
100
|
userSessionTable,
|
|
101
101
|
eventsTable,
|
|
102
102
|
]);
|
|
103
|
+
// login.write.ts is `per: "ip+handler"` rate-limited (20/60s). h.login()
|
|
104
|
+
// goes through stack.http.raw with no socket address, so every test in
|
|
105
|
+
// this file shares one "unknown" IP bucket — flush it per test so the
|
|
106
|
+
// file's cumulative login() calls can't push a later test over the cap.
|
|
107
|
+
await stack.redis.flushNamespace();
|
|
103
108
|
});
|
|
104
109
|
|
|
105
110
|
describe("sessions feature — login → check → revoke → rejected", () => {
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
|
|
3
3
|
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
samples.sort((a, b) => a - b);
|
|
12
|
-
return
|
|
4
|
+
const timeMs = async (fn: () => Promise<unknown>): Promise<number> => {
|
|
5
|
+
const start = performance.now();
|
|
6
|
+
await fn();
|
|
7
|
+
return performance.now() - start;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const median = (samples: readonly number[]): number => {
|
|
11
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
12
|
+
return sorted[Math.floor(sorted.length / 2)] ?? 0;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
// #774: the login no-user path must cost the same argon2 latency as a real
|
|
@@ -24,12 +24,20 @@ describe("verifyDummyPassword (anti-enumeration timing)", () => {
|
|
|
24
24
|
// warm the lazily-cached dummy hash so we time the verify, not the one-off hash
|
|
25
25
|
await verifyDummyPassword("warmup");
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// Interleaved so both paths see the same machine load: two back-to-back
|
|
28
|
+
// median blocks drift apart on a busy CI runner and skew the ratio.
|
|
29
|
+
const realSamples: number[] = [];
|
|
30
|
+
const dummySamples: number[] = [];
|
|
31
|
+
for (let i = 0; i < 7; i++) {
|
|
32
|
+
realSamples.push(await timeMs(() => verifyPassword(realHash, "wrong-password")));
|
|
33
|
+
dummySamples.push(await timeMs(() => verifyDummyPassword("wrong-password")));
|
|
34
|
+
}
|
|
35
|
+
const realMs = median(realSamples);
|
|
36
|
+
const dummyMs = median(dummySamples);
|
|
29
37
|
|
|
30
38
|
// A skipped miss-path (the bug) is sub-millisecond; a real argon2 verify
|
|
31
|
-
// is ~20ms. A half-of-real floor
|
|
32
|
-
//
|
|
39
|
+
// is ~20ms. A half-of-real floor still fails hard if the dummy verify is
|
|
40
|
+
// ever removed.
|
|
33
41
|
expect(dummyMs).toBeGreaterThan(realMs * 0.5);
|
|
34
42
|
});
|
|
35
43
|
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Host-egress guard tests for performWebhookDispatch — spec.url is
|
|
2
|
+
// request-controlled (see webhook-runner.ts header), so it must resolve to
|
|
3
|
+
// a public address before any connect is attempted, mirroring the SMTP/
|
|
4
|
+
// IMAP host guard.
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
7
|
+
import type { lookup } from "node:dns/promises";
|
|
8
|
+
import {
|
|
9
|
+
performWebhookDispatch,
|
|
10
|
+
setWebhookFetch,
|
|
11
|
+
setWebhookHostLookup,
|
|
12
|
+
WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
|
|
13
|
+
} from "../webhook-runner";
|
|
14
|
+
|
|
15
|
+
function fakeLookupFor(addressesByHost: Readonly<Record<string, string>>): typeof lookup {
|
|
16
|
+
return (async (hostname: string) => {
|
|
17
|
+
const address = addressesByHost[hostname];
|
|
18
|
+
if (!address) throw new Error(`ENOTFOUND ${hostname}`);
|
|
19
|
+
return [{ address, family: 4 }];
|
|
20
|
+
}) as unknown as typeof lookup;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const fetchMock = mock<typeof fetch>();
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
fetchMock.mockReset();
|
|
27
|
+
setWebhookFetch(fetch);
|
|
28
|
+
setWebhookHostLookup(undefined);
|
|
29
|
+
delete process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("performWebhookDispatch — host-egress guard", () => {
|
|
33
|
+
test("a private IP-literal host is rejected before any connect attempt", async () => {
|
|
34
|
+
setWebhookFetch(fetchMock as unknown as typeof fetch);
|
|
35
|
+
const result = await performWebhookDispatch({
|
|
36
|
+
url: "http://10.0.0.5/hook",
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {},
|
|
39
|
+
});
|
|
40
|
+
expect(result.ok).toBe(false);
|
|
41
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a hostname resolving to a private address is rejected, without leaking the resolved address", async () => {
|
|
45
|
+
setWebhookHostLookup(fakeLookupFor({ "internal.example": "127.0.0.1" }));
|
|
46
|
+
setWebhookFetch(fetchMock as unknown as typeof fetch);
|
|
47
|
+
const result = await performWebhookDispatch({
|
|
48
|
+
url: "http://internal.example/hook",
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: {},
|
|
51
|
+
});
|
|
52
|
+
expect(result.ok).toBe(false);
|
|
53
|
+
if (!result.ok) {
|
|
54
|
+
expect(result.error).not.toContain("127.0.0.1");
|
|
55
|
+
}
|
|
56
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a DNS resolution failure surfaces as a delivery error, not a thrown exception", async () => {
|
|
60
|
+
setWebhookHostLookup(fakeLookupFor({}));
|
|
61
|
+
setWebhookFetch(fetchMock as unknown as typeof fetch);
|
|
62
|
+
const result = await performWebhookDispatch({
|
|
63
|
+
url: "http://nowhere.example/hook",
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: {},
|
|
66
|
+
});
|
|
67
|
+
expect(result.ok).toBe(false);
|
|
68
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a blocked host and a DNS resolution failure produce the exact same tenant-visible error, without the host", async () => {
|
|
72
|
+
setWebhookFetch(fetchMock as unknown as typeof fetch);
|
|
73
|
+
|
|
74
|
+
const blocked = await performWebhookDispatch({
|
|
75
|
+
url: "http://10.0.0.5/hook",
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: {},
|
|
78
|
+
});
|
|
79
|
+
setWebhookHostLookup(fakeLookupFor({}));
|
|
80
|
+
const unresolvable = await performWebhookDispatch({
|
|
81
|
+
url: "http://nowhere.example/hook",
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: {},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(blocked.ok).toBe(false);
|
|
87
|
+
expect(unresolvable.ok).toBe(false);
|
|
88
|
+
if (blocked.ok || unresolvable.ok) throw new Error("unreachable");
|
|
89
|
+
expect(blocked.error).toBe(unresolvable.error);
|
|
90
|
+
expect(blocked.error).not.toContain("10.0.0.5");
|
|
91
|
+
expect(unresolvable.error).not.toContain("nowhere.example");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("an operator-allowlisted private host bypasses resolution and keeps its raw url", async () => {
|
|
95
|
+
process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = "webhook-receiver.internal";
|
|
96
|
+
setWebhookFetch(fetchMock as unknown as typeof fetch);
|
|
97
|
+
fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
|
98
|
+
|
|
99
|
+
const result = await performWebhookDispatch({
|
|
100
|
+
url: "http://webhook-receiver.internal/hook",
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: {},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result.ok).toBe(true);
|
|
106
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
107
|
+
const [calledUrl] = fetchMock.mock.calls[0]!;
|
|
108
|
+
expect(String(calledUrl)).toBe("http://webhook-receiver.internal/hook");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a public hostname is pinned to its resolved address, keeping the original host as Host header and TLS SNI", async () => {
|
|
112
|
+
setWebhookHostLookup(fakeLookupFor({ "hooks.example.com": "203.0.113.9" }));
|
|
113
|
+
setWebhookFetch(fetchMock as unknown as typeof fetch);
|
|
114
|
+
fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
|
115
|
+
|
|
116
|
+
const result = await performWebhookDispatch({
|
|
117
|
+
url: "https://hooks.example.com/incident",
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: {},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(result.ok).toBe(true);
|
|
123
|
+
const [calledUrl, init] = fetchMock.mock.calls[0]!;
|
|
124
|
+
expect(String(calledUrl)).toBe("https://203.0.113.9/incident");
|
|
125
|
+
const headers = init?.headers as Headers;
|
|
126
|
+
expect(headers.get("host")).toBe("hooks.example.com");
|
|
127
|
+
expect((init as unknown as { tls?: { servername: string } })?.tls?.servername).toBe(
|
|
128
|
+
"hooks.example.com",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -1 +1,14 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.320.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "webhook dispatch failures no longer distinguish a blocked host from a DNS failure",
|
|
6
|
+
"detail": "`performWebhookDispatch` returns the same generic error\n(\"webhook host is not reachable or not allowed\") for both a blocked\nhost and a DNS resolution failure, instead of two distinguishable\nstrings that also embedded the target hostname in the delivery-attempt\nevent payload. The hostname and the underlying error are now logged via\nthe feature's own logger instead of being included in the result."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.320.0",
|
|
10
|
+
"type": "breaking",
|
|
11
|
+
"title": "A webhook.send target host must resolve to a public address",
|
|
12
|
+
"migration": "A workflow or handler pointing `r.step.webhook.send` at an internal\nreceiver or a local dev/test endpoint (localhost or a private IP) now\ngets a delivery error (step.dispatch-failed) unless that host is\nexplicitly allowed. Set the operator env var (comma-separated, read at\ndispatch time — no boot-time code call needed) before starting the\nprocess:\n KUMIKO_WEBHOOK_ALLOWED_PRIVATE_HOSTS=webhook-receiver.internal\nThis is an operator env var, not a tenant/workflow-config value — a\nworkflow author cannot add their own host to this list. A DNS resolution\nfailure for a genuinely unreachable host now surfaces as a distinct\ndelivery error instead of only failing later inside `fetch()`."
|
|
13
|
+
}
|
|
14
|
+
]
|
|
@@ -8,8 +8,22 @@
|
|
|
8
8
|
// the audit trail lives in the event log only — no separate status table.
|
|
9
9
|
|
|
10
10
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import * as z from "zod";
|
|
11
12
|
import { type MailSpec, performMailDispatch } from "./mail-runner";
|
|
12
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
performWebhookDispatch,
|
|
15
|
+
WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
|
|
16
|
+
type WebhookSpec,
|
|
17
|
+
} from "./webhook-runner";
|
|
18
|
+
|
|
19
|
+
export const stepDispatcherEnvSchema = z.object({
|
|
20
|
+
[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
|
|
21
|
+
.string()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe(
|
|
24
|
+
"Comma-separated operator allowlist of private/internal hosts (e.g. a local webhook-receiver for dev/test) that bypass the public-address check for webhook.send targets. Never a tenant-config value.",
|
|
25
|
+
),
|
|
26
|
+
});
|
|
13
27
|
|
|
14
28
|
export const STEP_DISPATCH_AGGREGATE_TYPE = "step-dispatch";
|
|
15
29
|
export const STEP_DISPATCH_REQUESTED_TYPE = "kumiko:system:step.dispatch-requested";
|
|
@@ -37,6 +51,7 @@ export function createStepDispatcherFeature(): FeatureDefinition {
|
|
|
37
51
|
category: "infrastructure",
|
|
38
52
|
recommended: false,
|
|
39
53
|
});
|
|
54
|
+
r.envSchema(stepDispatcherEnvSchema);
|
|
40
55
|
|
|
41
56
|
r.multiStreamProjection({
|
|
42
57
|
name: "step-dispatcher",
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
createStepDispatcherFeature,
|
|
3
|
+
STEP_DISPATCH_AGGREGATE_TYPE,
|
|
4
|
+
stepDispatcherEnvSchema,
|
|
5
|
+
} from "./feature";
|
|
2
6
|
export {
|
|
3
7
|
type MailDispatchResult,
|
|
4
8
|
type MailSpec,
|
|
@@ -8,8 +12,11 @@ export {
|
|
|
8
12
|
} from "./mail-runner";
|
|
9
13
|
export {
|
|
10
14
|
performWebhookDispatch,
|
|
15
|
+
readAllowedPrivateWebhookHostsFromEnv,
|
|
11
16
|
setWebhookFetch,
|
|
17
|
+
setWebhookHostLookup,
|
|
12
18
|
setWebhookSecretResolver,
|
|
19
|
+
WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
|
|
13
20
|
type WebhookDispatchResult,
|
|
14
21
|
type WebhookSpec,
|
|
15
22
|
webhookSpecSchema,
|
|
@@ -1,8 +1,56 @@
|
|
|
1
1
|
// Webhook execution logic — separated from feature.ts so tests can stub
|
|
2
2
|
// the fetch without touching the MSP wiring.
|
|
3
|
+
//
|
|
4
|
+
// `spec.url` is request-controlled (a write-handler or workflow step reads
|
|
5
|
+
// it straight from tenant/user input — see samples/recipes/webhook-step for
|
|
6
|
+
// the reference usage), so it gets the same connect-time host-egress guard
|
|
7
|
+
// as tenant-supplied SMTP/IMAP hosts: resolve once, reject a private/
|
|
8
|
+
// reserved address, and pin the connect to the resolved address (Host
|
|
9
|
+
// header + TLS SNI keep the original hostname for cert validation).
|
|
10
|
+
//
|
|
11
|
+
// `allowedPrivateWebhookHosts` is the operator's own escape hatch for an
|
|
12
|
+
// internal receiver or a dev/test endpoint — an operator env var
|
|
13
|
+
// (KUMIKO_WEBHOOK_ALLOWED_PRIVATE_HOSTS), never a tenant-config key, so a
|
|
14
|
+
// tenant can never grant themselves the bypass. Own key, not the mail
|
|
15
|
+
// features' — step-dispatcher has no dependency relation to mail-transport-
|
|
16
|
+
// smtp/inbound-provider-imap and shouldn't require mounting them.
|
|
3
17
|
|
|
18
|
+
import type { lookup } from "node:dns/promises";
|
|
19
|
+
import {
|
|
20
|
+
BlockedHostError,
|
|
21
|
+
buildPinnedRequest,
|
|
22
|
+
HostResolutionError,
|
|
23
|
+
resolvePublicHostname,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/http";
|
|
25
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
4
26
|
import * as z from "zod";
|
|
5
27
|
|
|
28
|
+
const log = createFallbackLogger("step-dispatcher");
|
|
29
|
+
|
|
30
|
+
export const WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR = "KUMIKO_WEBHOOK_ALLOWED_PRIVATE_HOSTS";
|
|
31
|
+
|
|
32
|
+
/** Parses the comma-separated operator allowlist env var. Never throws —
|
|
33
|
+
* an unset or empty value just means no bypass. */
|
|
34
|
+
export function readAllowedPrivateWebhookHostsFromEnv(
|
|
35
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
36
|
+
): readonly string[] {
|
|
37
|
+
const raw = env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
|
|
38
|
+
if (!raw) return [];
|
|
39
|
+
return raw
|
|
40
|
+
.split(",")
|
|
41
|
+
.map((host) => host.trim())
|
|
42
|
+
.filter((host) => host.length > 0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Test-only DNS seam — production never calls this, resolvePublicHostname
|
|
46
|
+
// defaults to the real resolver. Reset it in afterEach/afterAll — this is
|
|
47
|
+
// module-global state.
|
|
48
|
+
let webhookHostLookup: typeof lookup | undefined;
|
|
49
|
+
|
|
50
|
+
export function setWebhookHostLookup(fn: typeof lookup | undefined): void {
|
|
51
|
+
webhookHostLookup = fn;
|
|
52
|
+
}
|
|
53
|
+
|
|
6
54
|
export const webhookSpecSchema = z.object({
|
|
7
55
|
url: z.string(),
|
|
8
56
|
method: z.enum(["POST", "PUT", "PATCH"]),
|
|
@@ -38,11 +86,55 @@ export function setWebhookFetch(fn: typeof fetch): void {
|
|
|
38
86
|
fetchImpl = fn;
|
|
39
87
|
}
|
|
40
88
|
|
|
89
|
+
function buildWebhookHeaders(
|
|
90
|
+
spec: WebhookSpec,
|
|
91
|
+
): { ok: true; headers: Record<string, string> } | { ok: false; error: string } {
|
|
92
|
+
const headers: Record<string, string> = { "content-type": "application/json", ...spec.headers };
|
|
93
|
+
if (!spec.auth) return { ok: true, headers };
|
|
94
|
+
const secret = secretResolver(spec.auth.secretRef);
|
|
95
|
+
if (!secret) {
|
|
96
|
+
return { ok: false, error: `secret "${spec.auth.secretRef}" not configured` };
|
|
97
|
+
}
|
|
98
|
+
if (spec.auth.kind === "bearer") {
|
|
99
|
+
headers["authorization"] = `Bearer ${secret}`;
|
|
100
|
+
} else {
|
|
101
|
+
headers[spec.auth.name] = secret;
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, headers };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function resolveWebhookFetchTarget(
|
|
107
|
+
rawUrl: string,
|
|
108
|
+
url: URL,
|
|
109
|
+
headers: Record<string, string>,
|
|
110
|
+
): Promise<
|
|
111
|
+
{ ok: true; fetchUrl: string | URL; requestInit: RequestInit } | { ok: false; error: string }
|
|
112
|
+
> {
|
|
113
|
+
const isAllowedPrivateHost = readAllowedPrivateWebhookHostsFromEnv().some(
|
|
114
|
+
(candidate) => candidate.toLowerCase() === url.hostname.toLowerCase(),
|
|
115
|
+
);
|
|
116
|
+
if (isAllowedPrivateHost) return { ok: true, fetchUrl: rawUrl, requestInit: { headers } };
|
|
117
|
+
try {
|
|
118
|
+
const resolved = await resolvePublicHostname(url.hostname, webhookHostLookup);
|
|
119
|
+
const pinned = buildPinnedRequest(url, resolved, { headers });
|
|
120
|
+
return { ok: true, fetchUrl: pinned.url, requestInit: pinned.init };
|
|
121
|
+
} catch (err) {
|
|
122
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
123
|
+
if (err instanceof BlockedHostError || err instanceof HostResolutionError) {
|
|
124
|
+
log.warn("webhook host unreachable", { host: url.hostname, reason });
|
|
125
|
+
return { ok: false, error: "webhook host is not reachable or not allowed" };
|
|
126
|
+
}
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
41
131
|
export async function performWebhookDispatch(spec: WebhookSpec): Promise<WebhookDispatchResult> {
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
132
|
+
// Host-egress guard at the primitive boundary: only http(s), the target
|
|
133
|
+
// host must resolve to a public address (unless operator-allowlisted),
|
|
134
|
+
// and redirects are never followed — a 3xx could point at an internal/
|
|
135
|
+
// metadata target and the spec carries secrets (auth) that would be
|
|
136
|
+
// forwarded there. A webhook destination that redirects now surfaces as
|
|
137
|
+
// a delivery error instead.
|
|
46
138
|
let url: URL;
|
|
47
139
|
try {
|
|
48
140
|
url = new URL(spec.url);
|
|
@@ -53,22 +145,16 @@ export async function performWebhookDispatch(spec: WebhookSpec): Promise<Webhook
|
|
|
53
145
|
return { ok: false, error: `unsupported url scheme "${url.protocol}"` };
|
|
54
146
|
}
|
|
55
147
|
|
|
56
|
-
const headers
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (spec.auth.kind === "bearer") {
|
|
63
|
-
headers["authorization"] = `Bearer ${secret}`;
|
|
64
|
-
} else {
|
|
65
|
-
headers[spec.auth.name] = secret;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
148
|
+
const headers = buildWebhookHeaders(spec);
|
|
149
|
+
if (!headers.ok) return headers;
|
|
150
|
+
|
|
151
|
+
const target = await resolveWebhookFetchTarget(spec.url, url, headers.headers);
|
|
152
|
+
if (!target.ok) return target;
|
|
153
|
+
|
|
68
154
|
try {
|
|
69
|
-
const res = await fetchImpl(
|
|
155
|
+
const res = await fetchImpl(target.fetchUrl, {
|
|
156
|
+
...target.requestInit,
|
|
70
157
|
method: spec.method,
|
|
71
|
-
headers,
|
|
72
158
|
redirect: "manual",
|
|
73
159
|
body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined,
|
|
74
160
|
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.320.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "subscription-stripe narrows a recurring price interval to a closed union instead of widening on an unrecognized Stripe value",
|
|
6
|
+
"detail": "stripe >= 22.5 widened Recurring.Interval to an open union. mapStripePrice now maps anything outside RecurringInterval (\"day\" | \"week\" | \"month\" | \"year\") to null via isKnownRecurringInterval, the same as a one-off price, instead of letting an unrecognized interval leak into ProviderPrice untyped. stripe bumped to 22.6.2. Closes #3312."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.319.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -75,6 +75,12 @@ afterAll(async () => {
|
|
|
75
75
|
beforeEach(async () => {
|
|
76
76
|
verifyCalls.length = 0;
|
|
77
77
|
await resetTestTables(stack.db, [userTable, tenantComplianceProfileTable, eventsTable]);
|
|
78
|
+
// request-deletion-by-email/confirm-deletion-by-token are `per: "ip"`
|
|
79
|
+
// rate-limited (10/60s). All calls here go through stack.http.raw with no
|
|
80
|
+
// socket address, so every test in this file lands in the same shared
|
|
81
|
+
// "unknown" IP bucket — flush it per test so one test's calls can't push
|
|
82
|
+
// a later, unrelated test over the cap.
|
|
83
|
+
await stack.redis.flushNamespace();
|
|
78
84
|
});
|
|
79
85
|
|
|
80
86
|
async function seedAlice(status: string = USER_STATUS.Active, email: string = ALICE_EMAIL) {
|
|
@@ -198,6 +204,11 @@ describe("anonymous deletion flow", () => {
|
|
|
198
204
|
// instead of a single run (probabilistic test).
|
|
199
205
|
for (let i = 0; i < 20; i++) {
|
|
200
206
|
await resetTestTables(stack.db, [userTable, tenantComplianceProfileTable, eventsTable]);
|
|
207
|
+
// Both handlers below are `per: "ip"` rate-limited (10/60s); this loop's
|
|
208
|
+
// own 3 calls/iteration would otherwise trip that limiter well before
|
|
209
|
+
// the 20th repetition, which has nothing to do with the concurrency
|
|
210
|
+
// behaviour under test.
|
|
211
|
+
await stack.redis.flushNamespace();
|
|
201
212
|
await seedAlice();
|
|
202
213
|
verifyCalls.length = 0;
|
|
203
214
|
await stack.http.raw("POST", "/api/write", {
|