Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions src/lib/send-attachment-limits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// The send attachment caps, and the constraint that actually bound them.
//
// The self-hosted caps were 5 files / 512KiB each / 768KiB total, and 768KiB is
// not an arbitrary number: attachments travel base64-encoded inside the JSON
// send body, and base64(768KiB) is 1048576 bytes — EXACTLY the 1 MiB
// `MAX_JSON_BODY_BYTES` the send route reads its body with. The attachment cap
// was the largest raw payload whose encoding fits the body cap.
//
// That coupling is why raising the attachment numbers ALONE would have changed
// nothing: `readJsonBody` runs before the attachment branch, so an oversize
// document is refused 413 by the body cap before any attachment rule is
// consulted. These tests pin the coupling so the two cannot drift apart again,
// in EITHER direction — a body budget too small silently re-blocks the send,
// and attachment caps too large silently overrun the provider.

import { describe, expect, test } from "bun:test";
import {
base64EncodedBytes,
describeSendAttachmentLimits,
LOCAL_SEND_ATTACHMENT_LIMITS,
mimeEncodedUpperBound,
requiredSendJsonBodyBytes,
SELF_HOSTED_SEND_ATTACHMENT_LIMITS,
SES_MAX_MESSAGE_BYTES,
type SendAttachmentLimits,
} from "./send-attachment-limits.js";

/** The live case this cap was raised for: a notarised 6-page scan. */
const NOTARISED_SCAN_BYTES = 3_800_000;

describe("base64EncodedBytes", () => {
test("matches the standard 4/3 expansion with padding", () => {
// Hand-checkable vectors: base64 emits 4 characters per 3 input bytes,
// padding the final group.
expect(base64EncodedBytes(0)).toBe(0);
expect(base64EncodedBytes(1)).toBe(4);
expect(base64EncodedBytes(2)).toBe(4);
expect(base64EncodedBytes(3)).toBe(4);
expect(base64EncodedBytes(4)).toBe(8);
});

test("agrees with a real Buffer encoding", () => {
// The formula is only worth having if it predicts what Buffer actually does.
for (const size of [1, 2, 3, 5, 17, 1024, 100_000]) {
const encoded = Buffer.alloc(size).toString("base64").length;
expect(base64EncodedBytes(size)).toBe(encoded);
}
});
});

describe("self-hosted send attachment caps carry a real document", () => {
test("a notarised scan fits under the per-file cap", () => {
expect(NOTARISED_SCAN_BYTES).toBeLessThanOrEqual(
SELF_HOSTED_SEND_ATTACHMENT_LIMITS.maxBytesPerFile,
);
});

test("two notarised scans fit under the total cap in one message", () => {
expect(NOTARISED_SCAN_BYTES * 2).toBeLessThanOrEqual(
SELF_HOSTED_SEND_ATTACHMENT_LIMITS.maxTotalBytes,
);
});

test("the file-count cap still admits a two-document send", () => {
expect(SELF_HOSTED_SEND_ATTACHMENT_LIMITS.maxFiles).toBeGreaterThanOrEqual(2);
});
});

// The cap must still BE a cap. A change that removed enforcement entirely would
// pass every "a big file now works" assertion above, so each bound is pinned
// from the other side too.
describe("the caps still bound", () => {
test("per-file and total caps are finite and ordered", () => {
const { maxFiles, maxBytesPerFile, maxTotalBytes } = SELF_HOSTED_SEND_ATTACHMENT_LIMITS;
expect(Number.isFinite(maxBytesPerFile)).toBe(true);
expect(Number.isFinite(maxTotalBytes)).toBe(true);
expect(Number.isFinite(maxFiles)).toBe(true);
expect(maxBytesPerFile).toBeGreaterThan(0);
expect(maxFiles).toBeGreaterThan(0);
// A per-file cap above the total would be unreachable and misleading.
expect(maxBytesPerFile).toBeLessThanOrEqual(maxTotalBytes);
});

test("one byte over the per-file cap is over the per-file cap", () => {
expect(SELF_HOSTED_SEND_ATTACHMENT_LIMITS.maxBytesPerFile + 1)
.toBeGreaterThan(SELF_HOSTED_SEND_ATTACHMENT_LIMITS.maxBytesPerFile);
});

test("the self-hosted caps stay at or below the local ones", () => {
// `src/lib/send.local.ts` enforces its own 25MiB ceiling on the local send
// path. If the self-hosted per-file cap ever rose above it, that layer would
// start rejecting sends the hosted route had just accepted.
expect(SELF_HOSTED_SEND_ATTACHMENT_LIMITS.maxBytesPerFile)
.toBeLessThanOrEqual(LOCAL_SEND_ATTACHMENT_LIMITS.maxBytesPerFile);
});
});

// THE TEST THAT WOULD HAVE CAUGHT THE ORIGINAL DEFECT.
describe("the JSON body budget can actually carry a full-size attachment set", () => {
test("the send body budget exceeds the encoded worst-case attachment set", () => {
const encoded = base64EncodedBytes(SELF_HOSTED_SEND_ATTACHMENT_LIMITS.maxTotalBytes);
expect(requiredSendJsonBodyBytes(SELF_HOSTED_SEND_ATTACHMENT_LIMITS))
.toBeGreaterThan(encoded);
});

test("it leaves room for the envelope around the attachments", () => {
// Subject, addresses, filenames, content types, and a text/html body all
// share the same request body.
const encoded = base64EncodedBytes(SELF_HOSTED_SEND_ATTACHMENT_LIMITS.maxTotalBytes);
const slack = requiredSendJsonBodyBytes(SELF_HOSTED_SEND_ATTACHMENT_LIMITS) - encoded;
expect(slack).toBeGreaterThanOrEqual(1024 * 1024);
});

test("the budget tracks the cap rather than being a hand-typed number", () => {
// Doubling the cap must move the budget. A constant that ignored its input
// is exactly how the body cap and the attachment cap drifted apart.
const doubled: SendAttachmentLimits = {
...SELF_HOSTED_SEND_ATTACHMENT_LIMITS,
maxTotalBytes: SELF_HOSTED_SEND_ATTACHMENT_LIMITS.maxTotalBytes * 2,
};
expect(requiredSendJsonBodyBytes(doubled))
.toBeGreaterThan(requiredSendJsonBodyBytes(SELF_HOSTED_SEND_ATTACHMENT_LIMITS));
});
});

describe("the caps stay inside the provider ceiling", () => {
test("SES is pinned at its documented v2 value", () => {
// AWS: SESv2/SMTP maximum message size is 40MB per message, measured AFTER
// base64 encoding, and is NOT adjustable. (The v1 API's limit is 10MB.)
expect(SES_MAX_MESSAGE_BYTES).toBe(40_000_000);
});

test("a worst-case message stays under the SES ceiling", () => {
expect(mimeEncodedUpperBound(SELF_HOSTED_SEND_ATTACHMENT_LIMITS))
.toBeLessThan(SES_MAX_MESSAGE_BYTES);
});

test("it keeps a real margin, not a rounding-error one", () => {
const margin = SES_MAX_MESSAGE_BYTES - mimeEncodedUpperBound(SELF_HOSTED_SEND_ATTACHMENT_LIMITS);
expect(margin).toBeGreaterThan(5_000_000);
});

test("the bound can fail — a cap over the ceiling is reported as over", () => {
// A ceiling check that cannot fail is not a check. This proves the
// instrument fires on a known-bad input.
const oversize: SendAttachmentLimits = {
maxFiles: 5,
maxBytesPerFile: 60 * 1024 * 1024,
maxTotalBytes: 60 * 1024 * 1024,
};
expect(mimeEncodedUpperBound(oversize)).toBeGreaterThan(SES_MAX_MESSAGE_BYTES);
});
});

describe("describeSendAttachmentLimits", () => {
test("renders the raised self-hosted caps", () => {
expect(describeSendAttachmentLimits(SELF_HOSTED_SEND_ATTACHMENT_LIMITS))
.toBe("5 files, 10MB each, 20MB total");
});
});
86 changes: 80 additions & 6 deletions src/lib/send-attachment-limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,22 @@ export interface SendAttachmentLimits {
maxTotalBytes: number;
}

/** Enforced by the self-hosted JSON send route (`POST /v1/messages/send`). */
/**
* Enforced by the self-hosted JSON send route (`POST /v1/messages/send`).
*
* These are DOCUMENT-SIZED on purpose: 512KiB could not carry a scanned page,
* so the route refused ordinary notarised paperwork. The ceiling above them is
* the provider's, not ours — see `SES_MAX_MESSAGE_BYTES` and
* `mimeEncodedUpperBound`.
*
* The per-file cap MUST stay at or below the local path's own 25MiB ceiling
* (`MAX_ATTACHMENT_SIZE_BYTES` in `send.local.ts`), or that layer would reject
* sends this route had just accepted.
*/
export const SELF_HOSTED_SEND_ATTACHMENT_LIMITS: SendAttachmentLimits = {
maxFiles: 5,
maxBytesPerFile: 512 * 1024,
maxTotalBytes: 768 * 1024,
maxBytesPerFile: 10 * 1024 * 1024,
maxTotalBytes: 20 * 1024 * 1024,
};

/**
Expand All @@ -31,12 +42,75 @@ export const LOCAL_SEND_ATTACHMENT_LIMITS: SendAttachmentLimits = {
maxTotalBytes: 25 * 1024 * 1024,
};

function humanBytes(bytes: number): string {
/**
* Bytes needed to base64-encode `rawBytes`: 4 characters per 3 input bytes,
* final group padded.
*
* This is the conversion that made the old caps look arbitrary. Attachments
* travel base64-encoded inside the JSON send body, so every raw cap below is
* spent against the body budget at 4/3 its size.
*/
export function base64EncodedBytes(rawBytes: number): number {
return Math.ceil(rawBytes / 3) * 4;
}

/**
* Room reserved in the send body for everything that is not attachment content:
* addresses, subject, filenames, content types, JSON structure, and a text or
* HTML body.
*/
export const SEND_ENVELOPE_HEADROOM_BYTES = 2 * 1024 * 1024;

/**
* The JSON request-body budget the send route needs in order for `limits` to be
* reachable at all.
*
* DERIVED, never hand-typed. The previous 768KiB total cap was exactly
* `base64EncodedBytes(768KiB) === 1MiB`, the route's body cap — so the two
* numbers were coupled in fact and independent in code, and raising one alone
* silently accomplished nothing. Deriving the body budget from the attachment
* cap makes that drift impossible.
*/
export function requiredSendJsonBodyBytes(limits: SendAttachmentLimits): number {
return base64EncodedBytes(limits.maxTotalBytes) + SEND_ENVELOPE_HEADROOM_BYTES;
}

/**
* Amazon SES maximum message size, measured AFTER base64 encoding. Not
* adjustable.
*
* 40MB is the SESv2/SMTP figure, and `src/providers/ses.ts` sends through
* SESv2 `SendEmailCommand` with raw content. The v1 API's limit is 10MB; do not
* mix them up if that provider path ever changes.
*/
export const SES_MAX_MESSAGE_BYTES = 40_000_000;

/**
* Upper bound on the encoded MIME message a full-size `limits` set produces,
* for comparison against `SES_MAX_MESSAGE_BYTES`.
*
* MIME wraps base64 at 76 characters per line plus CRLF, so the encoded payload
* costs a further 78/76 on top of the 4/3 expansion.
*/
export function mimeEncodedUpperBound(limits: SendAttachmentLimits): number {
const encoded = base64EncodedBytes(limits.maxTotalBytes);
return Math.ceil((encoded * 78) / 76) + SEND_ENVELOPE_HEADROOM_BYTES;
}

/**
* Renders a cap for an operator-facing message.
*
* Exported so the send route's rejection text is DERIVED from the constant it
* enforces. Those strings previously read "512KiB" and "768KiB" as literals, so
* raising the caps would have left the route refusing at one size while telling
* the operator another.
*/
export function humanLimitBytes(bytes: number): string {
if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))}MB`;
return `${Math.round(bytes / 1024)}KiB`;
}

export function describeSendAttachmentLimits(limits: SendAttachmentLimits): string {
return `${limits.maxFiles} files, ${humanBytes(limits.maxBytesPerFile)} each, `
+ `${humanBytes(limits.maxTotalBytes)} total`;
return `${limits.maxFiles} files, ${humanLimitBytes(limits.maxBytesPerFile)} each, `
+ `${humanLimitBytes(limits.maxTotalBytes)} total`;
}
44 changes: 31 additions & 13 deletions src/lib/send-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,41 +91,59 @@ describe("attachment caps are evaluated, not merely printed", () => {
)).toEqual([]);
});

// These fixtures are DERIVED from the caps rather than written as literals.
// They used to be fixed sizes (600KiB, 400KiB) chosen against a 512KiB/768KiB
// cap, so raising the caps turned every "oversize" fixture into a legal one and
// the suite asserted the opposite of what it was named for. A fixture that
// encodes a constant is the same drift this module exists to prevent.
const SH = SELF_HOSTED_SEND_ATTACHMENT_LIMITS;

it("catches a per-file overage that the old preview printed as fine", () => {
// 600KiB is under the 25MB global ceiling readSendAttachments enforces, so
// Still under the much larger local ceiling readSendAttachments enforces, so
// nothing else in the pipeline would have caught it before the server did.
const oversize = SH.maxBytesPerFile + 1;
expect(oversize).toBeLessThanOrEqual(LOCAL_SEND_ATTACHMENT_LIMITS.maxBytesPerFile);
const findings = evaluateAttachmentCaps(
[{ filename: "big.pdf", bytes: 600 * K }],
SELF_HOSTED_SEND_ATTACHMENT_LIMITS,
[{ filename: "big.pdf", bytes: oversize }],
SH,
);
expect(findings.map((f) => f.rule)).toContain("bytes_per_file");
expect(findings[0]!.detail).toContain("big.pdf");
});

it("catches a total overage even when every file is individually legal", () => {
const files = Array.from({ length: 3 }, (_, i) => ({ filename: `f${i}.pdf`, bytes: 400 * K }));
const findings = evaluateAttachmentCaps(files, SELF_HOSTED_SEND_ATTACHMENT_LIMITS);
// Enough max-size files to exceed the total, each one legal on its own.
const count = Math.ceil(SH.maxTotalBytes / SH.maxBytesPerFile) + 1;
expect(count).toBeLessThanOrEqual(SH.maxFiles);
const files = Array.from({ length: count }, (_, i) => ({ filename: `f${i}.pdf`, bytes: SH.maxBytesPerFile }));
const findings = evaluateAttachmentCaps(files, SH);
expect(findings.map((f) => f.rule)).toContain("total_bytes");
expect(findings.map((f) => f.rule)).not.toContain("bytes_per_file");
});

it("catches too many files", () => {
const files = Array.from({ length: 6 }, (_, i) => ({ filename: `f${i}.txt`, bytes: 1 }));
expect(evaluateAttachmentCaps(files, SELF_HOSTED_SEND_ATTACHMENT_LIMITS).map((f) => f.rule))
const files = Array.from({ length: SH.maxFiles + 1 }, (_, i) => ({ filename: `f${i}.txt`, bytes: 1 }));
expect(evaluateAttachmentCaps(files, SH).map((f) => f.rule))
.toContain("file_count");
});

it("applies the LOCAL caps in local mode, not the server's", () => {
// A 600KiB file is refused self-hosted and fine locally. Predicting the wrong
// mode's limits is the same class of defect as not predicting at all.
const files = [{ filename: "big.pdf", bytes: 600 * K }];
// A file over the self-hosted per-file cap but inside the local one is
// refused self-hosted and fine locally. Predicting the wrong mode's limits is
// the same class of defect as not predicting at all.
const bytes = SH.maxBytesPerFile + 1;
expect(bytes).toBeLessThanOrEqual(LOCAL_SEND_ATTACHMENT_LIMITS.maxBytesPerFile);
const files = [{ filename: "big.pdf", bytes }];
expect(evaluateAttachmentCaps(files, LOCAL_SEND_ATTACHMENT_LIMITS)).toEqual([]);
expect(evaluateAttachmentCaps(files, SELF_HOSTED_SEND_ATTACHMENT_LIMITS).length).toBeGreaterThan(0);
expect(evaluateAttachmentCaps(files, SH).length).toBeGreaterThan(0);
});

it("reports every distinct violation rather than stopping at the first", () => {
const files = Array.from({ length: 6 }, (_, i) => ({ filename: `f${i}.pdf`, bytes: 600 * K }));
const rules = new Set(evaluateAttachmentCaps(files, SELF_HOSTED_SEND_ATTACHMENT_LIMITS).map((f) => f.rule));
const files = Array.from(
{ length: SH.maxFiles + 1 },
(_, i) => ({ filename: `f${i}.pdf`, bytes: SH.maxBytesPerFile + 1 }),
);
const rules = new Set(evaluateAttachmentCaps(files, SH).map((f) => f.rule));
expect(rules).toEqual(new Set(["file_count", "bytes_per_file", "total_bytes"]));
});
});
Expand Down
8 changes: 5 additions & 3 deletions src/lib/send-preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,11 @@ export interface AttachmentCapFinding {
* Evaluate the REAL files against the mode's caps.
*
* These caps were printed as prose next to the attachment count and never checked,
* so a set that the self-hosted route refuses (5 files / 512KiB each / 768KiB
* total) previewed as fine. `readSendAttachments` only enforces the much larger
* global ceilings (25MB), so nothing else caught it either.
* so a set that the self-hosted route refuses previewed as fine.
* `readSendAttachments` only enforces the much larger local ceilings, so nothing
* else caught it either. The exact numbers deliberately are not repeated here —
* they live in `SELF_HOSTED_SEND_ATTACHMENT_LIMITS`, and a prose copy of them is
* how the prediction drifted from the enforcement in the first place.
*
* `sizes` are decoded byte lengths, not base64 lengths — the caps are on content.
*/
Expand Down
8 changes: 7 additions & 1 deletion src/server/self-hosted/inbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { mintApiKey, verifyApiKey } from "@hasna/contracts/auth";
import type { TypedQueryClient } from "../../storage-kit/index.js";
import { EmailsSelfHostedStore } from "./store.js";
import { handleSelfHostedRequest, type SelfHostedServiceDeps } from "./service.js";
import { SELF_HOSTED_SEND_ATTACHMENT_LIMITS } from "../../lib/send-attachment-limits.js";
import { testAuthDeps, selfScopedStore } from "./auth/test-support.js";
import { emailsSelfHostedMigrations } from "./migrations.js";

Expand Down Expand Up @@ -743,7 +744,12 @@ describe("Emails self-hosted inbound messages", () => {
}),
}));
expect((await send("not-base64"))?.status).toBe(400);
expect((await send(Buffer.alloc(513 * 1024).toString("base64")))?.status).toBe(400);
// Derived from the cap, not written as a literal. This read `513 * 1024`,
// one KiB over the old 512KiB per-file cap — so raising the cap turned the
// fixture into a legal attachment and the assertion asserted the opposite of
// its own name.
const overCap = Buffer.alloc(SELF_HOSTED_SEND_ATTACHMENT_LIMITS.maxBytesPerFile + 1);
expect((await send(overCap.toString("base64")))?.status).toBe(400);
});

it("rejects outbound header injection before reservation or provider send", async () => {
Expand Down
Loading
Loading