feat: centralize mail credential and sandbox policy
This commit is contained in:
@@ -487,8 +487,9 @@
|
|||||||
},
|
},
|
||||||
"packages/mail": {
|
"packages/mail": {
|
||||||
"name": "@wrnexus/mail",
|
"name": "@wrnexus/mail",
|
||||||
"version": "0.8.2",
|
"version": "0.8.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@wrnexus/encryption": "workspace:*",
|
||||||
"@wrnexus/queue": "workspace:*",
|
"@wrnexus/queue": "workspace:*",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -530,7 +531,7 @@
|
|||||||
},
|
},
|
||||||
"packages/payment": {
|
"packages/payment": {
|
||||||
"name": "@wrnexus/payment",
|
"name": "@wrnexus/payment",
|
||||||
"version": "0.8.0",
|
"version": "0.8.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/db": "workspace:*",
|
"@wrnexus/db": "workspace:*",
|
||||||
"@wrnexus/plugin": "workspace:*",
|
"@wrnexus/plugin": "workspace:*",
|
||||||
@@ -657,7 +658,7 @@
|
|||||||
},
|
},
|
||||||
"packages/styles": {
|
"packages/styles": {
|
||||||
"name": "@wrnexus/styles",
|
"name": "@wrnexus/styles",
|
||||||
"version": "0.8.19",
|
"version": "0.8.20",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
"@wrnexus/plugin": "workspace:*",
|
"@wrnexus/plugin": "workspace:*",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/mail",
|
"name": "@wrnexus/mail",
|
||||||
"version": "0.8.2",
|
"version": "0.8.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@wrnexus/encryption": "workspace:*",
|
||||||
"@wrnexus/queue": "workspace:*"
|
"@wrnexus/queue": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { DefinedJob } from "@wrnexus/queue";
|
import type { DefinedJob } from "@wrnexus/queue";
|
||||||
|
import { createKeyring, open, seal, type EncryptionKeyring } from "@wrnexus/encryption";
|
||||||
|
|
||||||
export interface MailMessage {
|
export interface MailMessage {
|
||||||
from?: string;
|
from?: string;
|
||||||
@@ -32,10 +33,12 @@ export function sealedMailDriver(options: SealedMailDriverOptions): MailDriver {
|
|||||||
let cached: Promise<MailDriver> | undefined;
|
let cached: Promise<MailDriver> | undefined;
|
||||||
const create = async () => options.create(await options.unseal(options.sealedCredential));
|
const create = async () => options.create(await options.unseal(options.sealedCredential));
|
||||||
const driver = () =>
|
const driver = () =>
|
||||||
options.cache === false ? create() : (cached ??= create().catch((error) => {
|
options.cache === false
|
||||||
cached = undefined;
|
? create()
|
||||||
throw error;
|
: (cached ??= create().catch((error) => {
|
||||||
}));
|
cached = undefined;
|
||||||
|
throw error;
|
||||||
|
}));
|
||||||
return {
|
return {
|
||||||
async send(message) {
|
async send(message) {
|
||||||
return (await driver()).send(message);
|
return (await driver()).send(message);
|
||||||
@@ -59,14 +62,87 @@ export interface MailOptions {
|
|||||||
sandbox?: { enabled?: boolean; allowlist?: readonly string[] };
|
sandbox?: { enabled?: boolean; allowlist?: readonly string[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defineMail(options: MailOptions) {
|
export interface MailSandboxDecision {
|
||||||
|
allowed: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fail-closed, case-insensitive sandbox policy shared by mail workers and defineMail. */
|
||||||
|
export function mailSandboxDecision(
|
||||||
|
sandbox: { enabled?: boolean | number; allowlist?: readonly string[] | string },
|
||||||
|
recipient: unknown,
|
||||||
|
): MailSandboxDecision {
|
||||||
|
if (!sandbox.enabled) return { allowed: true };
|
||||||
|
if (typeof recipient !== "string" || !recipient.trim()) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: "sandbox mode is on and no valid recipient address was given",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let source: unknown = sandbox.allowlist ?? [];
|
||||||
|
if (typeof source === "string") {
|
||||||
|
try {
|
||||||
|
source = JSON.parse(source || "[]");
|
||||||
|
} catch {
|
||||||
|
source = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
const allowed = new Set(
|
const allowed = new Set(
|
||||||
(options.sandbox?.allowlist ?? []).map((value) => value.trim().toLowerCase()),
|
(Array.isArray(source) ? source : [])
|
||||||
|
.filter((value): value is string => typeof value === "string")
|
||||||
|
.map((value) => value.trim().toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
);
|
);
|
||||||
|
const normalized = recipient.trim().toLowerCase();
|
||||||
|
return allowed.has(normalized)
|
||||||
|
? { allowed: true }
|
||||||
|
: {
|
||||||
|
allowed: false,
|
||||||
|
reason: `sandbox mode is on and ${recipient} is not in the sandbox allow-list`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SealedMailCredentialsOptions {
|
||||||
|
/** Environment variable holding a base64 AES key. */
|
||||||
|
env?: string;
|
||||||
|
keyId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owns validated, lazy encryption-key setup for credentials stored at rest. */
|
||||||
|
export function defineSealedMailCredentials(options: SealedMailCredentialsOptions = {}) {
|
||||||
|
const env = options.env ?? "APP_ENCRYPTION_KEY";
|
||||||
|
let cached: EncryptionKeyring | undefined;
|
||||||
|
const keyring = () => {
|
||||||
|
if (cached) return cached;
|
||||||
|
const secret = process.env[env];
|
||||||
|
if (!secret)
|
||||||
|
throw new Error(`${env} is not set; it is required to seal mail credentials at rest.`);
|
||||||
|
let length = 0;
|
||||||
|
try {
|
||||||
|
length = atob(secret).length;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${env} must be a valid base64-encoded 16, 24 or 32 byte key.`);
|
||||||
|
}
|
||||||
|
if (![16, 24, 32].includes(length)) {
|
||||||
|
throw new Error(
|
||||||
|
`${env} must decode to 16, 24 or 32 bytes; the current value decodes to ${length}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (cached = createKeyring([{ id: options.keyId ?? "primary", secret, active: true }]));
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
keyring,
|
||||||
|
seal: (plain: string) => seal(plain, keyring()),
|
||||||
|
open: (sealed: string) => open(sealed, keyring()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defineMail(options: MailOptions) {
|
||||||
const assertAllowed = (recipients: readonly string[]) => {
|
const assertAllowed = (recipients: readonly string[]) => {
|
||||||
if (!options.sandbox?.enabled) return;
|
for (const recipient of recipients) {
|
||||||
const denied = recipients.find((recipient) => !allowed.has(recipient.trim().toLowerCase()));
|
const decision = mailSandboxDecision(options.sandbox ?? {}, recipient);
|
||||||
if (denied) throw new Error(`WRN-MAIL-SANDBOX: recipient '${denied}' is not allow-listed`);
|
if (!decision.allowed) throw new Error(`WRN-MAIL-SANDBOX: ${decision.reason}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
async send(message: MailMessage) {
|
async send(message: MailMessage) {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { expect, test } from "bun:test";
|
import { expect, test } from "bun:test";
|
||||||
import { defineMail, defineMailTemplate, sealedMailDriver } from "../src/index.ts";
|
import {
|
||||||
|
defineMail,
|
||||||
|
defineMailTemplate,
|
||||||
|
defineSealedMailCredentials,
|
||||||
|
mailSandboxDecision,
|
||||||
|
sealedMailDriver,
|
||||||
|
} from "../src/index.ts";
|
||||||
|
|
||||||
test("mail templates render and sandbox blocks accidental recipients", async () => {
|
test("mail templates render and sandbox blocks accidental recipients", async () => {
|
||||||
const sent: unknown[] = [];
|
const sent: unknown[] = [];
|
||||||
@@ -17,6 +23,26 @@ test("mail templates render and sandbox blocks accidental recipients", async ()
|
|||||||
await expect(mail.send({ to: "real@example.com", subject: "no" })).rejects.toThrow("SANDBOX");
|
await expect(mail.send({ to: "real@example.com", subject: "no" })).rejects.toThrow("SANDBOX");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("mail owns fail-closed sandbox decisions and sealed credential setup", async () => {
|
||||||
|
expect(
|
||||||
|
mailSandboxDecision({ enabled: 1, allowlist: '["Test@Example.com"]' }, "test@example.com"),
|
||||||
|
).toEqual({ allowed: true });
|
||||||
|
expect(
|
||||||
|
mailSandboxDecision({ enabled: true, allowlist: "not-json" }, "x@example.com").allowed,
|
||||||
|
).toBe(false);
|
||||||
|
const previous = process.env.TEST_MAIL_ENCRYPTION_KEY;
|
||||||
|
process.env.TEST_MAIL_ENCRYPTION_KEY = btoa(String.fromCharCode(...new Uint8Array(32).fill(7)));
|
||||||
|
try {
|
||||||
|
const credentials = defineSealedMailCredentials({ env: "TEST_MAIL_ENCRYPTION_KEY" });
|
||||||
|
const sealed = await credentials.seal("smtp-secret");
|
||||||
|
expect(sealed).not.toContain("smtp-secret");
|
||||||
|
expect(await credentials.open(sealed)).toBe("smtp-secret");
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env.TEST_MAIL_ENCRYPTION_KEY;
|
||||||
|
else process.env.TEST_MAIL_ENCRYPTION_KEY = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("sealed drivers decrypt lazily once and delegate send/test", async () => {
|
test("sealed drivers decrypt lazily once and delegate send/test", async () => {
|
||||||
let opens = 0;
|
let opens = 0;
|
||||||
const sent: string[] = [];
|
const sent: string[] = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user