@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
|
@@ -33,6 +33,9 @@ export type LoginResponse = {
|
|
|
33
33
|
readonly tenantId: string;
|
|
34
34
|
readonly roles: readonly string[];
|
|
35
35
|
};
|
|
36
|
+
// Present only when the server's auth.postAuthLanding resolver returned a
|
|
37
|
+
// valid path — see auth-routes.ts. Screens prefer this over loggedInHref.
|
|
38
|
+
readonly landingPath?: string;
|
|
36
39
|
};
|
|
37
40
|
|
|
38
41
|
// Reason codes the login-handler is known to emit today (login-screen.tsx's
|
|
@@ -93,6 +96,14 @@ export type LoginResult =
|
|
|
93
96
|
| { readonly kind: "mfa-setup-required"; readonly preauthSetupToken: string }
|
|
94
97
|
| { readonly kind: "failure"; readonly error: LoginFailure };
|
|
95
98
|
|
|
99
|
+
function toLoginResponse(
|
|
100
|
+
token: string,
|
|
101
|
+
user: LoginResponse["user"],
|
|
102
|
+
landingPath: unknown,
|
|
103
|
+
): LoginResponse {
|
|
104
|
+
return typeof landingPath === "string" ? { token, user, landingPath } : { token, user };
|
|
105
|
+
}
|
|
106
|
+
|
|
96
107
|
// POST /api/auth/login. Success → token + user; MFA-enrolled user → a
|
|
97
108
|
// challenge token the caller completes via auth-mfa's verify screen;
|
|
98
109
|
// unenrolled user blocked by enforcement policy → mfa-setup-required;
|
|
@@ -118,6 +129,7 @@ export async function login(req: LoginRequest): Promise<LoginResult> {
|
|
|
118
129
|
challengeToken?: string;
|
|
119
130
|
mfaSetupRequired?: boolean;
|
|
120
131
|
preauthSetupToken?: string;
|
|
132
|
+
landingPath?: string;
|
|
121
133
|
error?:
|
|
122
134
|
| {
|
|
123
135
|
code?: string;
|
|
@@ -140,7 +152,7 @@ export async function login(req: LoginRequest): Promise<LoginResult> {
|
|
|
140
152
|
return { kind: "failure", error: { reason: "mfa_setup_required" } };
|
|
141
153
|
}
|
|
142
154
|
if (body.token !== undefined && body.user !== undefined) {
|
|
143
|
-
return { kind: "success", data:
|
|
155
|
+
return { kind: "success", data: toLoginResponse(body.token, body.user, body.landingPath) };
|
|
144
156
|
}
|
|
145
157
|
}
|
|
146
158
|
// Der Server schickt error entweder als string ("invalid_body") oder als
|
|
@@ -343,6 +355,9 @@ export type SignupConfirmSuccess = {
|
|
|
343
355
|
// Present only when a bound handover grant existed and its claim
|
|
344
356
|
// succeeded — see signup-confirm.write.ts.
|
|
345
357
|
readonly handover?: { readonly entityType: string; readonly id: string };
|
|
358
|
+
// Present only when the server's auth.postAuthLanding resolver returned a
|
|
359
|
+
// valid path — see auth-routes.ts.
|
|
360
|
+
readonly landingPath?: string;
|
|
346
361
|
};
|
|
347
362
|
|
|
348
363
|
export async function confirmSignup(
|
|
@@ -28,3 +28,13 @@ export function resolveLoggedInHref<Args>(
|
|
|
28
28
|
): string {
|
|
29
29
|
return typeof href === "function" ? href(args) : href;
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
/** The server's `landingPath` (auth.postAuthLanding) wins over the screen's
|
|
33
|
+
* loggedInHref prop — that prop is a deprecated per-app fallback. */
|
|
34
|
+
export function resolvePostAuthHref<Args>(
|
|
35
|
+
landingPath: string | undefined,
|
|
36
|
+
href: string | ((args: Args) => string),
|
|
37
|
+
args: Args,
|
|
38
|
+
): string {
|
|
39
|
+
return landingPath ?? resolveLoggedInHref(href, args);
|
|
40
|
+
}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
20
20
|
import { type FormEvent, type ReactNode, useContext, useState } from "react";
|
|
21
21
|
import { csrfHeader } from "./auth-client";
|
|
22
|
-
import {
|
|
22
|
+
import { resolvePostAuthHref } from "./auth-form-logic";
|
|
23
23
|
import { AuthCard, useUrlToken } from "./auth-form-primitives";
|
|
24
24
|
import { SessionContext, UNAUTHENTICATED } from "./session";
|
|
25
25
|
|
|
@@ -29,7 +29,9 @@ export type InviteAcceptScreenProps = {
|
|
|
29
29
|
/** Where to redirect on success. Default "/" — multi-tenant apps can pass
|
|
30
30
|
* `(data) => "/${data.tenantId}/"`. Function-form receives the roles this
|
|
31
31
|
* flow grants in the target tenant; branch 1 reports the invitation's
|
|
32
|
-
* role, which for an existing member is not their full role set there.
|
|
32
|
+
* role, which for an existing member is not their full role set there.
|
|
33
|
+
* @deprecated Configure auth.postAuthLanding on the server; its landingPath wins.
|
|
34
|
+
* Kept as fallback. */
|
|
33
35
|
readonly loggedInHref?:
|
|
34
36
|
| string
|
|
35
37
|
| ((args: { tenantId: string; roles: readonly string[] }) => string);
|
|
@@ -46,6 +48,9 @@ type InviteAcceptResponse = {
|
|
|
46
48
|
readonly tenantId: string;
|
|
47
49
|
readonly role?: string;
|
|
48
50
|
readonly user?: { readonly roles?: readonly string[] };
|
|
51
|
+
// Present only when the server's auth.postAuthLanding resolver returned a
|
|
52
|
+
// valid path — see auth-routes.ts.
|
|
53
|
+
readonly landingPath?: string;
|
|
49
54
|
};
|
|
50
55
|
|
|
51
56
|
function grantedRoles(data: InviteAcceptResponse): readonly string[] {
|
|
@@ -92,7 +97,7 @@ export function InviteAcceptScreen({
|
|
|
92
97
|
// @cast-boundary engine-payload — auth route JSON
|
|
93
98
|
const data = (await res.json()) as InviteAcceptResponse;
|
|
94
99
|
window.location.assign(
|
|
95
|
-
|
|
100
|
+
resolvePostAuthHref(data.landingPath, loggedInHref, {
|
|
96
101
|
tenantId: data.tenantId,
|
|
97
102
|
roles: grantedRoles(data),
|
|
98
103
|
}),
|
|
@@ -117,7 +122,7 @@ export function InviteAcceptScreen({
|
|
|
117
122
|
// @cast-boundary engine-payload — auth route JSON
|
|
118
123
|
const data = (await res.json()) as InviteAcceptResponse;
|
|
119
124
|
window.location.assign(
|
|
120
|
-
|
|
125
|
+
resolvePostAuthHref(data.landingPath, loggedInHref, {
|
|
121
126
|
tenantId: data.tenantId,
|
|
122
127
|
roles: grantedRoles(data),
|
|
123
128
|
}),
|
|
@@ -142,7 +147,7 @@ export function InviteAcceptScreen({
|
|
|
142
147
|
// @cast-boundary engine-payload — auth route JSON
|
|
143
148
|
const data = (await res.json()) as InviteAcceptResponse;
|
|
144
149
|
window.location.assign(
|
|
145
|
-
|
|
150
|
+
resolvePostAuthHref(data.landingPath, loggedInHref, {
|
|
146
151
|
tenantId: data.tenantId,
|
|
147
152
|
roles: grantedRoles(data),
|
|
148
153
|
}),
|
|
@@ -10,15 +10,17 @@
|
|
|
10
10
|
// server-side token pass `token` as a prop instead.
|
|
11
11
|
//
|
|
12
12
|
// On success: shows a confirmation (account active, signed in) with a
|
|
13
|
-
// button to
|
|
14
|
-
// server already logged the user in via cookies, but a silent redirect
|
|
15
|
-
// leaves no signal that activation worked.
|
|
16
|
-
//
|
|
13
|
+
// button to the continue target, instead of navigating away immediately —
|
|
14
|
+
// the server already logged the user in via cookies, but a silent redirect
|
|
15
|
+
// leaves no signal that activation worked. Server-side auth.postAuthLanding
|
|
16
|
+
// (landingPath) wins over loggedInHref; loggedInHref is the deprecated
|
|
17
|
+
// per-app fallback. Default pattern is "/" — apps with multi-tenant routing
|
|
18
|
+
// pass `(data) => "/" + data.tenantKey + "/"`.
|
|
17
19
|
|
|
18
20
|
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
19
21
|
import { type FormEvent, type ReactNode, useState } from "react";
|
|
20
22
|
import { confirmSignup, type SignupConfirmSuccess } from "./auth-client";
|
|
21
|
-
import { passwordPairIssue,
|
|
23
|
+
import { passwordPairIssue, resolvePostAuthHref } from "./auth-form-logic";
|
|
22
24
|
import { AuthCard, useUrlToken } from "./auth-form-primitives";
|
|
23
25
|
|
|
24
26
|
export type SignupCompleteScreenProps = {
|
|
@@ -27,7 +29,9 @@ export type SignupCompleteScreenProps = {
|
|
|
27
29
|
readonly token?: string;
|
|
28
30
|
/** Where to send the user after activation. Function-form receives the tenantKey, the
|
|
29
31
|
* roles granted by this flow in that tenant, and the claimed `handover` entity when
|
|
30
|
-
* signup claimed a try-first grant. Default "/".
|
|
32
|
+
* signup claimed a try-first grant. Default "/".
|
|
33
|
+
* @deprecated Configure auth.postAuthLanding on the server; its landingPath wins.
|
|
34
|
+
* Kept as fallback. */
|
|
31
35
|
readonly loggedInHref?:
|
|
32
36
|
| string
|
|
33
37
|
| ((args: {
|
|
@@ -73,7 +77,7 @@ export function SignupCompleteScreen({
|
|
|
73
77
|
// explicit continue button instead of navigating away silently —
|
|
74
78
|
// the user otherwise gets no signal that activation worked.
|
|
75
79
|
setContinueHref(
|
|
76
|
-
|
|
80
|
+
resolvePostAuthHref(res.data.landingPath, loggedInHref, {
|
|
77
81
|
tenantKey: res.data.tenantKey,
|
|
78
82
|
roles: res.data.user.roles,
|
|
79
83
|
...(res.data.handover !== undefined && { handover: res.data.handover }),
|
|
@@ -32,6 +32,7 @@ export async function verifyMfaChallenge(
|
|
|
32
32
|
isSuccess?: boolean;
|
|
33
33
|
token?: string;
|
|
34
34
|
user?: LoginResponse["user"];
|
|
35
|
+
landingPath?: string;
|
|
35
36
|
error?:
|
|
36
37
|
| {
|
|
37
38
|
code?: string;
|
|
@@ -41,7 +42,14 @@ export async function verifyMfaChallenge(
|
|
|
41
42
|
| string;
|
|
42
43
|
};
|
|
43
44
|
if (body.isSuccess === true && body.token !== undefined && body.user !== undefined) {
|
|
44
|
-
return {
|
|
45
|
+
return {
|
|
46
|
+
kind: "success",
|
|
47
|
+
data: {
|
|
48
|
+
token: body.token,
|
|
49
|
+
user: body.user,
|
|
50
|
+
...(typeof body.landingPath === "string" && { landingPath: body.landingPath }),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
45
53
|
}
|
|
46
54
|
const err = body.error;
|
|
47
55
|
if (typeof err === "string") {
|
|
@@ -156,6 +164,7 @@ export async function confirmMfaSetupPreauth(
|
|
|
156
164
|
isSuccess?: boolean;
|
|
157
165
|
token?: string;
|
|
158
166
|
user?: LoginResponse["user"];
|
|
167
|
+
landingPath?: string;
|
|
159
168
|
error?:
|
|
160
169
|
| {
|
|
161
170
|
code?: string;
|
|
@@ -165,7 +174,14 @@ export async function confirmMfaSetupPreauth(
|
|
|
165
174
|
| string;
|
|
166
175
|
};
|
|
167
176
|
if (body.isSuccess === true && body.token !== undefined && body.user !== undefined) {
|
|
168
|
-
return {
|
|
177
|
+
return {
|
|
178
|
+
kind: "success",
|
|
179
|
+
data: {
|
|
180
|
+
token: body.token,
|
|
181
|
+
user: body.user,
|
|
182
|
+
...(typeof body.landingPath === "string" && { landingPath: body.landingPath }),
|
|
183
|
+
},
|
|
184
|
+
};
|
|
169
185
|
}
|
|
170
186
|
const err = body.error;
|
|
171
187
|
if (typeof err === "string") {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Proves createSmtpTransport actually forwards a pinned host + SNI
|
|
2
|
+
// servername into nodemailer's createTransport, since EmailTransport's
|
|
3
|
+
// public surface (send() only) doesn't expose that otherwise.
|
|
4
|
+
//
|
|
5
|
+
// Restore hygiene: Bun runs a test-run's files in one process, so a
|
|
6
|
+
// top-level mock.module leaks into every file that runs after this one —
|
|
7
|
+
// the real module is captured before mocking and restored in afterAll.
|
|
8
|
+
|
|
9
|
+
import { afterAll, describe, expect, mock, test } from "bun:test";
|
|
10
|
+
|
|
11
|
+
const realNodemailer = await import("nodemailer");
|
|
12
|
+
let capturedOptions: Record<string, unknown> | undefined;
|
|
13
|
+
mock.module("nodemailer", () => ({
|
|
14
|
+
createTransport: (options: Record<string, unknown>) => {
|
|
15
|
+
capturedOptions = options;
|
|
16
|
+
return { sendMail: async () => {} };
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const { createSmtpTransport } = await import("../smtp-transport");
|
|
21
|
+
|
|
22
|
+
afterAll(() => {
|
|
23
|
+
mock.module("nodemailer", () => realNodemailer);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("createSmtpTransport — nodemailer wiring", () => {
|
|
27
|
+
test("pins host to the resolved address and sets servername to the original hostname", () => {
|
|
28
|
+
createSmtpTransport({
|
|
29
|
+
host: "203.0.113.5",
|
|
30
|
+
servername: "smtp.tenant.example",
|
|
31
|
+
from: "noreply@test.local",
|
|
32
|
+
});
|
|
33
|
+
expect(capturedOptions?.["host"]).toBe("203.0.113.5");
|
|
34
|
+
expect(capturedOptions?.["servername"]).toBe("smtp.tenant.example");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("omits servername when the connect target is already the raw host (IP-literal or allowlisted)", () => {
|
|
38
|
+
createSmtpTransport({ host: "mailpit.internal", from: "noreply@test.local" });
|
|
39
|
+
expect(capturedOptions?.["host"]).toBe("mailpit.internal");
|
|
40
|
+
expect(capturedOptions?.["servername"]).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -40,6 +40,12 @@ export type SmtpTransportOptions = {
|
|
|
40
40
|
* override this applies. Accepts both "noreply@ex.com" and
|
|
41
41
|
* "Name <noreply@ex.com>". */
|
|
42
42
|
readonly from: string;
|
|
43
|
+
/** TLS SNI hostname. Only needed when `host` is an IP address pinned by
|
|
44
|
+
* the caller for a hostname it already resolved — nodemailer otherwise
|
|
45
|
+
* skips SNI for an IP `host` and certificate validation would then check
|
|
46
|
+
* the wrong name. Unset for the common case where `host` is already the
|
|
47
|
+
* hostname to validate against. */
|
|
48
|
+
readonly servername?: string;
|
|
43
49
|
};
|
|
44
50
|
|
|
45
51
|
export function createSmtpTransport(options: SmtpTransportOptions): EmailTransport {
|
|
@@ -48,6 +54,7 @@ export function createSmtpTransport(options: SmtpTransportOptions): EmailTranspo
|
|
|
48
54
|
port: options.port ?? 587,
|
|
49
55
|
secure: options.secure ?? false,
|
|
50
56
|
...(options.auth && { auth: options.auth }),
|
|
57
|
+
...(options.servername && { servername: options.servername }),
|
|
51
58
|
pool: true,
|
|
52
59
|
maxConnections: 5,
|
|
53
60
|
});
|
|
@@ -102,7 +102,7 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
102
102
|
|
|
103
103
|
return defineFeature(FEATURE_NAME, (r) => {
|
|
104
104
|
r.describe(
|
|
105
|
-
"Declares the `derivativeRenderer` extension point. `ctx.derivatives.variant(fileRefId, spec, name)` derives a variant of a tracked FileRef the first time it's requested and reuses the stored result afterwards (derive-on-first-use, keyed by a hash of the spec). Mount at least one `derivatives-*` renderer feature alongside this one — without a registered renderer for the FileRef's MIME type, every `variant(...)` call throws. Also declares the `derivativePublicPredicate` extension point (`r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, '<entityType>', { isPublic })`) and, when `createFileDerivativesFeature({resolveApexTenant})` is passed a host-resolver, mounts an anonymous `GET {basePath}/:fileRefId/:variant` route that serves any variant name the FileRef's field declared in its `variants` for a FileRef whose entityType has a registered predicate returning true — default-deny (404) otherwise, same as an unknown FileRef or an undeclared variant name. The route's only rate-limit (`per: \"ip\"`)
|
|
105
|
+
"Declares the `derivativeRenderer` extension point. `ctx.derivatives.variant(fileRefId, spec, name)` derives a variant of a tracked FileRef the first time it's requested and reuses the stored result afterwards (derive-on-first-use, keyed by a hash of the spec). Mount at least one `derivatives-*` renderer feature alongside this one — without a registered renderer for the FileRef's MIME type, every `variant(...)` call throws. Also declares the `derivativePublicPredicate` extension point (`r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, '<entityType>', { isPublic })`) and, when `createFileDerivativesFeature({resolveApexTenant})` is passed a host-resolver, mounts an anonymous `GET {basePath}/:fileRefId/:variant` route that serves any variant name the FileRef's field declared in its `variants` for a FileRef whose entityType has a registered predicate returning true — default-deny (404) otherwise, same as an unknown FileRef or an undeclared variant name. The route's only rate-limit (`per: \"ip\"`) depends on the server's `trustedProxyHops` matching the deployment's actual reverse-proxy count — at the default 0 it ignores `x-forwarded-for` entirely, so every caller shares one bucket until that's configured. `publicTenantResolution: \"fileRef\"` keeps resolveApexTenant as the host gate but reads the variant from the FileRef row's own tenant instead of the host's, so one shared platform host serves every tenant's public variants — each FileRef-tenant's own `isPublic` predicate still default-denies. Also declares the `derivativeOverlayResolver` extension point (`r.useExtension(EXT_DERIVATIVE_OVERLAY_RESOLVER, '<entityType>', { resolve })`), used to turn a variant's `overlays[].dataToken` into the actual QR payload for that FileRef's entityType before the variant is rendered — a variant declaring a `qr` overlay throws at request-time if no resolver is registered for the FileRef's entityType.",
|
|
106
106
|
);
|
|
107
107
|
r.uiHints({
|
|
108
108
|
displayLabel: "File Derivatives",
|
|
@@ -92,13 +92,11 @@ export const publicVariantQuery = defineQueryHandler({
|
|
|
92
92
|
schema: publicVariantPayloadSchema,
|
|
93
93
|
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
94
94
|
agent: { expose: false },
|
|
95
|
-
// ponytail: "ip"
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// Upgrade path if that assumption doesn't hold: trusted-proxy-count config
|
|
101
|
-
// on buildRequestContextData, same fix point as every other /api/* route.
|
|
95
|
+
// ponytail: "ip" is the ONLY throttle on an anonymous, internet-facing
|
|
96
|
+
// render/storage-cost route — its accuracy depends on the server's
|
|
97
|
+
// `trustedProxyHops` matching the real number of reverse proxies in
|
|
98
|
+
// front of it (see api/client-ip.ts). Left at the default 0 with a proxy
|
|
99
|
+
// actually in front, every caller collapses into one shared bucket.
|
|
102
100
|
rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
|
|
103
101
|
handler: async (query, ctx) => {
|
|
104
102
|
const row = await fetchOne<FileRefRow>(ctx.db, fileRefsTable, {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { lookup } from "node:dns/promises";
|
|
3
|
+
import {
|
|
4
|
+
BlockedHostError,
|
|
5
|
+
HostResolutionError,
|
|
6
|
+
resolveMailConnectTarget,
|
|
7
|
+
} from "../mail-host-policy";
|
|
8
|
+
|
|
9
|
+
describe("resolveMailConnectTarget", () => {
|
|
10
|
+
test("pins a public hostname to its resolved address and sets servername to the original host", async () => {
|
|
11
|
+
const fakeLookup = (async () => [
|
|
12
|
+
{ address: "203.0.113.5", family: 4 },
|
|
13
|
+
]) as unknown as typeof lookup;
|
|
14
|
+
|
|
15
|
+
await expect(
|
|
16
|
+
resolveMailConnectTarget("smtp.tenant.example", { lookupFn: fakeLookup }),
|
|
17
|
+
).resolves.toEqual({ host: "203.0.113.5", servername: "smtp.tenant.example" });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("connects a public IP-literal host directly, without a servername", async () => {
|
|
21
|
+
await expect(resolveMailConnectTarget("93.184.216.34")).resolves.toEqual({
|
|
22
|
+
host: "93.184.216.34",
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("rejects a private IP-literal host", async () => {
|
|
27
|
+
await expect(resolveMailConnectTarget("10.0.0.5")).rejects.toThrow(BlockedHostError);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("rejects a hostname that resolves to a private address", async () => {
|
|
31
|
+
const fakeLookup = (async () => [
|
|
32
|
+
{ address: "127.0.0.1", family: 4 },
|
|
33
|
+
]) as unknown as typeof lookup;
|
|
34
|
+
|
|
35
|
+
await expect(
|
|
36
|
+
resolveMailConnectTarget("internal.example", { lookupFn: fakeLookup }),
|
|
37
|
+
).rejects.toThrow(BlockedHostError);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("surfaces a DNS failure distinctly from a blocked host", async () => {
|
|
41
|
+
const failingLookup = (async () => {
|
|
42
|
+
throw new Error("ENOTFOUND");
|
|
43
|
+
}) as unknown as typeof lookup;
|
|
44
|
+
|
|
45
|
+
await expect(
|
|
46
|
+
resolveMailConnectTarget("nowhere.example", { lookupFn: failingLookup }),
|
|
47
|
+
).rejects.toThrow(HostResolutionError);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("allowlisted private host bypasses resolution entirely, case-insensitively", async () => {
|
|
51
|
+
const lookupFn = (() => {
|
|
52
|
+
throw new Error("must not be called for an allowlisted host");
|
|
53
|
+
}) as unknown as typeof lookup;
|
|
54
|
+
|
|
55
|
+
await expect(
|
|
56
|
+
resolveMailConnectTarget("Mailpit.internal", {
|
|
57
|
+
allowedPrivateMailHosts: ["mailpit.internal"],
|
|
58
|
+
lookupFn,
|
|
59
|
+
}),
|
|
60
|
+
).resolves.toEqual({ host: "Mailpit.internal" });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a host absent from the allowlist still goes through resolution", async () => {
|
|
64
|
+
await expect(
|
|
65
|
+
resolveMailConnectTarget("10.0.0.5", { allowedPrivateMailHosts: ["mailpit.internal"] }),
|
|
66
|
+
).rejects.toThrow(BlockedHostError);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -2,3 +2,12 @@
|
|
|
2
2
|
// Foundation packages (ai-foundation, mail-foundation, file-foundation).
|
|
3
3
|
|
|
4
4
|
export { requireDefined, requireNonEmpty, requireSecretSet } from "./config-helpers";
|
|
5
|
+
export {
|
|
6
|
+
BlockedHostError,
|
|
7
|
+
HostResolutionError,
|
|
8
|
+
MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
|
|
9
|
+
type MailConnectTarget,
|
|
10
|
+
type MailHostGuardOptions,
|
|
11
|
+
readAllowedPrivateMailHostsFromEnv,
|
|
12
|
+
resolveMailConnectTarget,
|
|
13
|
+
} from "./mail-host-policy";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Connect-time egress guard for tenant-supplied SMTP/IMAP hosts (shared by
|
|
2
|
+
// mail-transport-smtp and inbound-provider-imap): a tenant-controlled host
|
|
3
|
+
// must resolve to a public address, never an internal one. Resolution
|
|
4
|
+
// happens exactly once and the caller connects to the resolved address
|
|
5
|
+
// directly (see kumiko-http's resolvePublicHostname for why that matters),
|
|
6
|
+
// so this also covers hosts that were valid when a tenant set them and
|
|
7
|
+
// only later re-resolve to a private address.
|
|
8
|
+
//
|
|
9
|
+
// `allowedPrivateMailHosts` is the operator's own escape hatch for an
|
|
10
|
+
// internal relay or a dev/test server (mailpit, greenmail) — deliberately
|
|
11
|
+
// an operator env var (KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS, read via
|
|
12
|
+
// readAllowedPrivateMailHostsFromEnv), never a tenant-config key, so a
|
|
13
|
+
// tenant can never grant themselves the bypass. Shared by both features
|
|
14
|
+
// since a deploy commonly mounts both; declared once in mail-transport-
|
|
15
|
+
// smtp's envSchema (see its feature.ts) to avoid the framework's env-var-
|
|
16
|
+
// conflict check tripping when both features are mounted together.
|
|
17
|
+
|
|
18
|
+
import type { lookup } from "node:dns/promises";
|
|
19
|
+
import { isIP } from "node:net";
|
|
20
|
+
import { resolvePublicHostname } from "@cosmicdrift/kumiko-framework/http";
|
|
21
|
+
|
|
22
|
+
export const MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR = "KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS";
|
|
23
|
+
|
|
24
|
+
/** Parses the comma-separated operator allowlist env var. Never throws —
|
|
25
|
+
* an unset or empty value just means no bypass. */
|
|
26
|
+
export function readAllowedPrivateMailHostsFromEnv(
|
|
27
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
28
|
+
): readonly string[] {
|
|
29
|
+
const raw = env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
|
|
30
|
+
if (!raw) return [];
|
|
31
|
+
return raw
|
|
32
|
+
.split(",")
|
|
33
|
+
.map((host) => host.trim())
|
|
34
|
+
.filter((host) => host.length > 0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type MailHostGuardOptions = {
|
|
38
|
+
readonly allowedPrivateMailHosts?: readonly string[];
|
|
39
|
+
readonly lookupFn?: typeof lookup;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type MailConnectTarget = {
|
|
43
|
+
readonly host: string;
|
|
44
|
+
readonly servername?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function isAllowedPrivateMailHost(host: string, allowed: readonly string[]): boolean {
|
|
48
|
+
return allowed.some((candidate) => candidate.toLowerCase() === host.toLowerCase());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function resolveMailConnectTarget(
|
|
52
|
+
host: string,
|
|
53
|
+
options: MailHostGuardOptions = {},
|
|
54
|
+
): Promise<MailConnectTarget> {
|
|
55
|
+
if (isAllowedPrivateMailHost(host, options.allowedPrivateMailHosts ?? [])) {
|
|
56
|
+
return { host };
|
|
57
|
+
}
|
|
58
|
+
const resolved = await resolvePublicHostname(host, options.lookupFn);
|
|
59
|
+
// An IP-literal host pins to itself — no hostname was ever there for TLS
|
|
60
|
+
// SNI/cert validation to check, so `servername` stays unset (the mail
|
|
61
|
+
// libraries themselves skip SNI for an IP host).
|
|
62
|
+
if (isIP(host) !== 0) {
|
|
63
|
+
return { host: resolved.address };
|
|
64
|
+
}
|
|
65
|
+
return { host: resolved.address, servername: host };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Re-exported so callers can classify a rejection (policy verdict, no
|
|
69
|
+
// retry) without importing kumiko-http directly.
|
|
70
|
+
export { BlockedHostError, HostResolutionError } from "@cosmicdrift/kumiko-framework/http";
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
tenantComplianceProfileEntity,
|
|
41
41
|
} from "../../compliance-profiles";
|
|
42
42
|
import { createConfigFeature } from "../../config";
|
|
43
|
+
import { MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR } from "../../foundation-shared";
|
|
43
44
|
import {
|
|
44
45
|
createInboundMailSupervisor,
|
|
45
46
|
InboundMailAccountStatuses,
|
|
@@ -63,6 +64,11 @@ const USER = "testuser";
|
|
|
63
64
|
const PASSWORD = "testpass";
|
|
64
65
|
const ADDRESS = "testuser@example.com";
|
|
65
66
|
|
|
67
|
+
// greenmail is a local test server, not tenant-supplied — needs the
|
|
68
|
+
// operator escape hatch since HOST is a private/loopback address.
|
|
69
|
+
const originalAllowedPrivateHostsEnv = process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
|
|
70
|
+
process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = HOST;
|
|
71
|
+
|
|
66
72
|
function probe(host: string, port: number): Promise<boolean> {
|
|
67
73
|
return new Promise((resolve) => {
|
|
68
74
|
const socket = connect({ host, port, timeout: 1500 });
|
|
@@ -127,6 +133,11 @@ beforeAll(async () => {
|
|
|
127
133
|
});
|
|
128
134
|
|
|
129
135
|
afterAll(async () => {
|
|
136
|
+
if (originalAllowedPrivateHostsEnv === undefined) {
|
|
137
|
+
delete process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
|
|
138
|
+
} else {
|
|
139
|
+
process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = originalAllowedPrivateHostsEnv;
|
|
140
|
+
}
|
|
130
141
|
if (!available) return;
|
|
131
142
|
await stack.cleanup();
|
|
132
143
|
resetPiiSubjectKmsForTests();
|
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
// — der volle Dispatcher-Pfad ist in
|
|
16
16
|
// inbound-mail-foundation.integration.test.ts abgedeckt.
|
|
17
17
|
|
|
18
|
-
import { describe, expect, test } from "bun:test";
|
|
18
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
19
19
|
import { connect } from "node:net";
|
|
20
20
|
import { createSecret } from "@cosmicdrift/kumiko-framework/secrets";
|
|
21
21
|
import { createTransport } from "nodemailer";
|
|
22
|
+
import { MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR } from "../../foundation-shared";
|
|
22
23
|
import {
|
|
23
24
|
type InboundMailContext,
|
|
24
25
|
isInboundAuthError,
|
|
@@ -35,6 +36,18 @@ const USER = "testuser";
|
|
|
35
36
|
const PASSWORD = "testpass";
|
|
36
37
|
const ADDRESS = "testuser@example.com";
|
|
37
38
|
|
|
39
|
+
// greenmail is a local test server, not tenant-supplied — needs the
|
|
40
|
+
// operator escape hatch since HOST is a private/loopback address.
|
|
41
|
+
const originalAllowedPrivateHostsEnv = process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
|
|
42
|
+
process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = HOST;
|
|
43
|
+
afterAll(() => {
|
|
44
|
+
if (originalAllowedPrivateHostsEnv === undefined) {
|
|
45
|
+
delete process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
|
|
46
|
+
} else {
|
|
47
|
+
process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = originalAllowedPrivateHostsEnv;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
38
51
|
function probe(host: string, port: number): Promise<boolean> {
|
|
39
52
|
return new Promise((resolve) => {
|
|
40
53
|
const socket = connect({ host, port, timeout: 1500 });
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// Greenmail suites skip in CI when the container is down — this file mocks
|
|
3
3
|
// imapflow so the plugin body stays on the coverage badge without Docker.
|
|
4
4
|
|
|
5
|
-
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
5
|
+
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
6
|
+
import type { lookup } from "node:dns/promises";
|
|
6
7
|
import { EventEmitter } from "node:events";
|
|
7
8
|
import { createSecret } from "@cosmicdrift/kumiko-framework/secrets";
|
|
8
9
|
import { sleep, waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
10
|
+
import { MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR } from "../../foundation-shared";
|
|
9
11
|
import {
|
|
10
12
|
type InboundMailContext,
|
|
11
13
|
isInboundAuthError,
|
|
@@ -36,10 +38,12 @@ let lastIdleClient: FakeImapFlow | undefined;
|
|
|
36
38
|
|
|
37
39
|
class FakeImapFlow extends EventEmitter {
|
|
38
40
|
mailbox: { uidValidity: bigint; uidNext: number } | false = false;
|
|
41
|
+
readonly opts: Record<string, unknown>;
|
|
39
42
|
private idleReject: ((err: Error) => void) | undefined;
|
|
40
43
|
|
|
41
|
-
constructor(
|
|
44
|
+
constructor(opts: Record<string, unknown>) {
|
|
42
45
|
super();
|
|
46
|
+
this.opts = opts;
|
|
43
47
|
lastIdleClient = this;
|
|
44
48
|
}
|
|
45
49
|
|
|
@@ -119,7 +123,28 @@ class FakeImapFlow extends EventEmitter {
|
|
|
119
123
|
const realImapflow = await import("imapflow");
|
|
120
124
|
mock.module("imapflow", () => ({ ImapFlow: FakeImapFlow }));
|
|
121
125
|
|
|
122
|
-
const { imapInboundMailPlugin } = await import("../feature");
|
|
126
|
+
const { imapInboundMailPlugin, setImapMailHostLookup } = await import("../feature");
|
|
127
|
+
|
|
128
|
+
/** Host-aware fake resolver — an unmapped hostname behaves like a real ENOTFOUND. */
|
|
129
|
+
function fakeLookupFor(addressesByHost: Readonly<Record<string, string>>): typeof lookup {
|
|
130
|
+
return (async (hostname: string) => {
|
|
131
|
+
const address = addressesByHost[hostname];
|
|
132
|
+
if (!address) throw new Error(`ENOTFOUND ${hostname}`);
|
|
133
|
+
return [{ address, family: 4 }];
|
|
134
|
+
}) as unknown as typeof lookup;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// goodDoc's host is a placeholder that must still clear the mail-host guard —
|
|
138
|
+
// resolved to a public address so the pinned-connect path (host+servername)
|
|
139
|
+
// is exercised the same way a real tenant hostname would be.
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
setImapMailHostLookup(fakeLookupFor({ "imap.example.com": "203.0.113.5" }));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
afterEach(() => {
|
|
145
|
+
setImapMailHostLookup(undefined);
|
|
146
|
+
delete process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
|
|
147
|
+
});
|
|
123
148
|
|
|
124
149
|
afterAll(() => {
|
|
125
150
|
mock.module("imapflow", () => realImapflow);
|
|
@@ -198,6 +223,10 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
|
|
|
198
223
|
await expect(
|
|
199
224
|
imapInboundMailPlugin.verify(ctxWithDoc(goodDoc), account),
|
|
200
225
|
).resolves.toBeUndefined();
|
|
226
|
+
// Proves the connect path actually pins to the resolved IP with SNI set
|
|
227
|
+
// to the original hostname, not just that some connect happened.
|
|
228
|
+
expect(lastIdleClient?.opts["host"]).toBe("203.0.113.5");
|
|
229
|
+
expect(lastIdleClient?.opts["servername"]).toBe("imap.example.com");
|
|
201
230
|
});
|
|
202
231
|
|
|
203
232
|
test("verify: auth failure → InboundAuthError", async () => {
|
|
@@ -354,3 +383,59 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
|
|
|
354
383
|
await stop().catch(() => {});
|
|
355
384
|
});
|
|
356
385
|
});
|
|
386
|
+
|
|
387
|
+
function docWithHost(host: string): string {
|
|
388
|
+
return JSON.stringify({ host, port: 993, secure: true, user: "u@example.com", password: "pw" });
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
describe("imapInboundMailPlugin — mail-host guard", () => {
|
|
392
|
+
test("a private IP-literal host is rejected before any connect attempt", async () => {
|
|
393
|
+
lastIdleClient = undefined;
|
|
394
|
+
try {
|
|
395
|
+
await imapInboundMailPlugin.verify(ctxWithDoc(docWithHost("10.0.0.5")), account);
|
|
396
|
+
expect.unreachable("expected verify to throw");
|
|
397
|
+
} catch (e) {
|
|
398
|
+
expect(isInboundAuthError(e)).toBe(true);
|
|
399
|
+
expect((e as Error).message).toBe("IMAP host is not reachable or not allowed");
|
|
400
|
+
expect((e as Error).message).not.toContain("10.0.0.5");
|
|
401
|
+
}
|
|
402
|
+
expect(lastIdleClient).toBeUndefined();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("a hostname resolving to a private address is rejected before any connect attempt", async () => {
|
|
406
|
+
setImapMailHostLookup(fakeLookupFor({ "internal.example": "127.0.0.1" }));
|
|
407
|
+
lastIdleClient = undefined;
|
|
408
|
+
try {
|
|
409
|
+
await imapInboundMailPlugin.verify(ctxWithDoc(docWithHost("internal.example")), account);
|
|
410
|
+
expect.unreachable("expected verify to throw");
|
|
411
|
+
} catch (e) {
|
|
412
|
+
expect(isInboundAuthError(e)).toBe(true);
|
|
413
|
+
expect((e as Error).message).toBe("IMAP host is not reachable or not allowed");
|
|
414
|
+
expect((e as Error).message).not.toContain("internal.example");
|
|
415
|
+
expect((e as Error).message).not.toContain("127.0.0.1");
|
|
416
|
+
}
|
|
417
|
+
expect(lastIdleClient).toBeUndefined();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("a DNS resolution failure surfaces as InboundTransientError before any connect attempt, with the same tenant-visible message as a blocked host", async () => {
|
|
421
|
+
setImapMailHostLookup(fakeLookupFor({}));
|
|
422
|
+
lastIdleClient = undefined;
|
|
423
|
+
try {
|
|
424
|
+
await imapInboundMailPlugin.verify(ctxWithDoc(docWithHost("nowhere.example")), account);
|
|
425
|
+
expect.unreachable("expected verify to throw");
|
|
426
|
+
} catch (e) {
|
|
427
|
+
expect(isInboundTransientError(e)).toBe(true);
|
|
428
|
+
expect(isInboundAuthError(e)).toBe(false);
|
|
429
|
+
expect((e as Error).message).toBe("IMAP host is not reachable or not allowed");
|
|
430
|
+
expect((e as Error).message).not.toContain("nowhere.example");
|
|
431
|
+
}
|
|
432
|
+
expect(lastIdleClient).toBeUndefined();
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("an operator-allowlisted private host bypasses resolution and keeps its raw host, without SNI", async () => {
|
|
436
|
+
process.env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = "mailpit.internal";
|
|
437
|
+
await imapInboundMailPlugin.verify(ctxWithDoc(docWithHost("mailpit.internal")), account);
|
|
438
|
+
expect(lastIdleClient?.opts["host"]).toBe("mailpit.internal");
|
|
439
|
+
expect(lastIdleClient?.opts["servername"]).toBeUndefined();
|
|
440
|
+
});
|
|
441
|
+
});
|