diff --git a/tools/v2/individual/deadline-detector/docs/SECURITY_AND_PERFORMANCE.md b/tools/v2/individual/deadline-detector/docs/SECURITY_AND_PERFORMANCE.md new file mode 100644 index 00000000..a60bf096 --- /dev/null +++ b/tools/v2/individual/deadline-detector/docs/SECURITY_AND_PERFORMANCE.md @@ -0,0 +1,115 @@ +# Deadline Detector - Security and Performance Notes + +This document records the local security assumptions, unsafe input handling, +and performance limits for the isolated Deadline Detector tool. + +## Scope + +All guidance applies only to: + +```text +tools/v2/individual/deadline-detector/ +``` + +The tool does not read a live inbox, write reminders, write calendars, send +notifications, mutate messages, call providers, call AI services, persist data, +or connect to the app shell. Future integration must add an explicit adapter +issue before using real mailbox or calendar data. + +## Threat Assumptions + +- Message subjects, bodies, sender values, timestamps, and timezones are + untrusted input. +- Attachment content is out of scope for this folder; only bounded metadata may + be passed to guard helpers. +- Large inbox windows and long histories can create avoidable CPU and memory + work even when individual messages are valid. +- Calendar, reminder, and notification writes are unsafe side effects unless a + later issue defines user confirmation, permissions, audit behavior, and + rollback handling. +- Fixture data must remain synthetic and must not include production email + content, secrets, API keys, access tokens, wallet data, or provider + credentials. + +## Guard Helpers + +`guards/deadline-guards.mjs` owns folder-local validation and sanitization: + +- `sanitizeDeadlineText()` removes HTML-like tags, control characters, + zero-width characters, leading/trailing whitespace, and oversized text. +- `validateMessageId()` rejects empty ids, path traversal, whitespace, and + unsupported characters. +- `validateSourceType()` allowlists local source types. +- `validateSender()` rejects malformed email-like sender values and CRLF/null + injection. +- `validateIsoTimestamp()` rejects missing, malformed, or unparseable + timestamps. +- `validateTimezone()` rejects empty, oversized, or control-character timezone + values. +- `sanitizeDeadlineMessage()` returns a sanitized copy without mutating caller + input. +- `guardDeadlineMessageBatch()` caps the number of messages processed in one + pass and sanitizes each message. +- `guardAttachmentMetadata()` accepts metadata only and rejects embedded file + contents. +- `guardHistoryWindow()` caps history scans. +- `guardDetectionRequest()` validates a future adapter-shaped request with + messages and options. + +These guards are pure and synchronous. They do not perform network calls, +storage writes, mailbox reads, calendar writes, reminder writes, or background +jobs. + +## Unsafe Inputs + +| Input | Risk | Handling | +| ---------------------------------- | -------------------------------------- | ----------------------------------- | +| Path-like message ids | Path traversal or confusing audit ids | Rejected by `validateMessageId()` | +| CRLF or null bytes in sender | Header/log injection | Rejected by `validateSender()` | +| Control characters in subject/body | Hidden content or rendering surprises | Removed by `sanitizeDeadlineText()` | +| HTML-like text in subject/body | Accidental markup or script display | Stripped before detection/review | +| Invalid timestamps | Broken urgency calculations | Rejected before detection | +| Oversized body text | Excess CPU/memory work | Truncated to the local body limit | +| Oversized message batches | Slow full-inbox scans | Rejected with a pagination hint | +| Attachment contents | File parsing, malware, memory pressure | Rejected; metadata only | +| Oversized histories | Avoidable scan cost | Rejected with a smaller-window hint | + +## Performance Limits + +The current guard constants are intentionally conservative: + +| Limit | Value | +| ------------------------ | ------------------------------- | +| Message batch size | 100 messages | +| Subject length | 998 characters | +| Body length | 20,000 characters | +| Attachment count | 25 metadata rows | +| Attachment size metadata | 10,000,000 bytes per attachment | +| History events | 500 rows | + +Future integrations should paginate large inboxes and histories before calling +the detector. The detector should run on a bounded, user-confirmed slice rather +than scanning full accounts, teams, or long-running sync histories. + +## Review Commands + +Run from the repository root: + +```bash +node --test tools/v2/individual/deadline-detector/tests/deadline-guards.test.mjs +node --test tools/v2/individual/deadline-detector/tests/deadline-fixtures.test.mjs +``` + +The guard test uses `fixtures/hostile-deadline-inputs.json` to verify malformed +input rejection, text sanitization, attachment metadata boundaries, and +oversized collection guards. + +## Future Adapter Checklist + +- Normalize real mailbox data into folder-local `DeadlineMessage` objects. +- Run `guardDetectionRequest()` before calling `detectDeadlines()`. +- Paginate inbox, team, attachment, and history data before scanning. +- Keep reminder/calendar writes behind explicit user confirmation. +- Avoid logging raw message bodies or private sender details. +- Keep provider, AI, database, notification, wallet, and Stellar integrations + outside this folder unless a future issue explicitly allows them. diff --git a/tools/v2/individual/deadline-detector/fixtures/hostile-deadline-inputs.json b/tools/v2/individual/deadline-detector/fixtures/hostile-deadline-inputs.json new file mode 100644 index 00000000..46ebee84 --- /dev/null +++ b/tools/v2/individual/deadline-detector/fixtures/hostile-deadline-inputs.json @@ -0,0 +1,83 @@ +{ + "tool": "deadline-detector", + "version": 1, + "validMessages": [ + { + "id": "msg-safe-001", + "type": "email", + "sender": "sender@example.test", + "subject": "Submit report by 2026-06-20", + "body": "The report is due by 2026-06-20 at 15:00.", + "receivedAt": "2026-06-18T09:00:00Z", + "containsPersonalData": false, + "userTimezone": "UTC" + } + ], + "hostileMessages": [ + { + "id": "bad-id-path", + "field": "id", + "reason": "path traversal in message id", + "patch": { + "id": "../../mailbox" + } + }, + { + "id": "bad-type", + "field": "type", + "reason": "unsupported source type", + "patch": { + "type": "live-inbox" + } + }, + { + "id": "bad-sender-header", + "field": "sender", + "reason": "CRLF sender injection", + "patch": { + "sender": "attacker@example.test\r\nBcc: victim@example.test" + } + }, + { + "id": "bad-received-at", + "field": "receivedAt", + "reason": "invalid timestamp", + "patch": { + "receivedAt": "not-a-date" + } + }, + { + "id": "bad-timezone", + "field": "userTimezone", + "reason": "timezone control characters", + "patch": { + "userTimezone": "UTC\r\nX-Timezone: injected" + } + }, + { + "id": "bad-personal-flag", + "field": "containsPersonalData", + "reason": "personal data marker must be boolean", + "patch": { + "containsPersonalData": "false" + } + } + ], + "sanitizationCases": [ + { + "field": "subject", + "input": " Renewal\u0000 due\u200b tomorrow ", + "expected": "alert(1) Renewal due tomorrow" + }, + { + "field": "body", + "input": "Submit\u0007 by 2026-06-20", + "expected": "Submit by 2026-06-20" + }, + { + "field": "attachmentName", + "input": "proposal.pdf\u0000", + "expected": "proposal.pdf" + } + ] +} diff --git a/tools/v2/individual/deadline-detector/guards/deadline-guards.mjs b/tools/v2/individual/deadline-detector/guards/deadline-guards.mjs new file mode 100644 index 00000000..785e9fc8 --- /dev/null +++ b/tools/v2/individual/deadline-detector/guards/deadline-guards.mjs @@ -0,0 +1,249 @@ +/** + * Security and performance guards for the Deadline Detector. + * + * These helpers are pure, synchronous, and folder-local. Future integration + * code should call them before running detection over mailbox, attachment, or + * history data. They do not read inbox state, write reminders, call providers, + * or persist anything. + */ + +const ALLOWED_SOURCE_TYPES = new Set(["email", "calendar-forward", "invoice", "project-update"]); +const MESSAGE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; +const TIMEZONE_PATTERN = /^[A-Za-z0-9_+\-/.]+$/; + +export const DEADLINE_GUARD_LIMITS = { + MAX_MESSAGE_BATCH_SIZE: 100, + MAX_MESSAGE_ID_LENGTH: 96, + MAX_SENDER_LENGTH: 254, + MAX_SUBJECT_LENGTH: 998, + MAX_BODY_LENGTH: 20_000, + MAX_TIMEZONE_LENGTH: 80, + MAX_ATTACHMENT_COUNT: 25, + MAX_ATTACHMENT_NAME_LENGTH: 180, + MAX_ATTACHMENT_BYTES: 10_000_000, + MAX_HISTORY_EVENTS: 500, +}; + +const CONTROL_CHARACTERS = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g; +const INVISIBLE_CHARACTERS = /[\u200b-\u200d\u2060\ufeff]/g; +const HTML_TAGS = /<[^>]*>/g; + +export class DeadlineGuardError extends Error { + constructor(message, field) { + super(message); + this.name = "DeadlineGuardError"; + this.field = field; + } +} + +export function sanitizeDeadlineText(value, maxLength = DEADLINE_GUARD_LIMITS.MAX_BODY_LENGTH) { + if (typeof value !== "string") { + return ""; + } + return value + .normalize("NFC") + .replace(HTML_TAGS, "") + .replace(CONTROL_CHARACTERS, "") + .replace(INVISIBLE_CHARACTERS, "") + .trim() + .slice(0, maxLength); +} + +export function validateMessageId(id) { + if (typeof id !== "string" || id.length === 0) { + throw new DeadlineGuardError("message id must be a non-empty string", "id"); + } + if (id.length > DEADLINE_GUARD_LIMITS.MAX_MESSAGE_ID_LENGTH) { + throw new DeadlineGuardError( + `message id exceeds ${DEADLINE_GUARD_LIMITS.MAX_MESSAGE_ID_LENGTH} characters`, + "id", + ); + } + if (!MESSAGE_ID_PATTERN.test(id)) { + throw new DeadlineGuardError( + "message id may contain only letters, numbers, underscore, and dash", + "id", + ); + } + return id; +} + +export function validateSourceType(type) { + if (typeof type !== "string" || type.length === 0) { + throw new DeadlineGuardError("message type must be a non-empty string", "type"); + } + if (!ALLOWED_SOURCE_TYPES.has(type)) { + throw new DeadlineGuardError(`unsupported message type: ${type}`, "type"); + } + return type; +} + +export function validateSender(sender) { + if (typeof sender !== "string" || sender.length === 0) { + throw new DeadlineGuardError("sender must be a non-empty string", "sender"); + } + if (sender.length > DEADLINE_GUARD_LIMITS.MAX_SENDER_LENGTH) { + throw new DeadlineGuardError( + `sender exceeds ${DEADLINE_GUARD_LIMITS.MAX_SENDER_LENGTH} characters`, + "sender", + ); + } + if (/[\r\n\0]/.test(sender)) { + throw new DeadlineGuardError("sender contains header injection characters", "sender"); + } + const at = sender.lastIndexOf("@"); + if (at < 1 || at === sender.length - 1) { + throw new DeadlineGuardError("sender must include local part and domain", "sender"); + } + return sender; +} + +export function validateIsoTimestamp(value, field = "timestamp") { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T/.test(value)) { + throw new DeadlineGuardError(`${field} must be an ISO timestamp string`, field); + } + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) { + throw new DeadlineGuardError(`${field} must be parseable as a date`, field); + } + return value; +} + +export function validateTimezone(timezone) { + if (typeof timezone !== "string" || timezone.length === 0) { + throw new DeadlineGuardError("timezone must be a non-empty string", "userTimezone"); + } + if (timezone.length > DEADLINE_GUARD_LIMITS.MAX_TIMEZONE_LENGTH) { + throw new DeadlineGuardError( + `timezone exceeds ${DEADLINE_GUARD_LIMITS.MAX_TIMEZONE_LENGTH} characters`, + "userTimezone", + ); + } + if (!TIMEZONE_PATTERN.test(timezone)) { + throw new DeadlineGuardError("timezone contains unsupported characters", "userTimezone"); + } + return timezone; +} + +export function sanitizeDeadlineMessage(message) { + if (message === null || typeof message !== "object" || Array.isArray(message)) { + throw new DeadlineGuardError("deadline message must be a plain object", "message"); + } + + const subject = sanitizeDeadlineText(message.subject, DEADLINE_GUARD_LIMITS.MAX_SUBJECT_LENGTH); + const body = sanitizeDeadlineText(message.body, DEADLINE_GUARD_LIMITS.MAX_BODY_LENGTH); + + if (subject.length === 0) { + throw new DeadlineGuardError("subject must contain visible text", "subject"); + } + if (body.length === 0) { + throw new DeadlineGuardError("body must contain visible text", "body"); + } + if (typeof message.containsPersonalData !== "boolean") { + throw new DeadlineGuardError("containsPersonalData must be boolean", "containsPersonalData"); + } + + return { + id: validateMessageId(message.id), + type: validateSourceType(message.type), + sender: validateSender(message.sender), + subject, + body, + receivedAt: validateIsoTimestamp(message.receivedAt, "receivedAt"), + containsPersonalData: message.containsPersonalData, + userTimezone: validateTimezone(message.userTimezone), + }; +} + +export function guardDeadlineMessageBatch(messages) { + if (!Array.isArray(messages)) { + throw new DeadlineGuardError("messages must be an array", "messages"); + } + if (messages.length > DEADLINE_GUARD_LIMITS.MAX_MESSAGE_BATCH_SIZE) { + throw new DeadlineGuardError( + `message batch size ${messages.length} exceeds ${DEADLINE_GUARD_LIMITS.MAX_MESSAGE_BATCH_SIZE}; paginate before detecting`, + "messages", + ); + } + return messages.map((message) => sanitizeDeadlineMessage(message)); +} + +export function guardAttachmentMetadata(attachments = []) { + if (!Array.isArray(attachments)) { + throw new DeadlineGuardError("attachments must be an array", "attachments"); + } + if (attachments.length > DEADLINE_GUARD_LIMITS.MAX_ATTACHMENT_COUNT) { + throw new DeadlineGuardError( + `attachment count ${attachments.length} exceeds ${DEADLINE_GUARD_LIMITS.MAX_ATTACHMENT_COUNT}`, + "attachments", + ); + } + + return attachments.map((attachment, index) => { + if (attachment === null || typeof attachment !== "object" || Array.isArray(attachment)) { + throw new DeadlineGuardError(`attachment ${index} must be a plain object`, "attachments"); + } + if ("content" in attachment || "bytes" in attachment || "buffer" in attachment) { + throw new DeadlineGuardError( + "attachment content is out of scope; pass metadata only", + "attachments", + ); + } + const name = sanitizeDeadlineText( + attachment.name, + DEADLINE_GUARD_LIMITS.MAX_ATTACHMENT_NAME_LENGTH, + ); + if (name.length === 0) { + throw new DeadlineGuardError("attachment name must contain visible text", "attachments"); + } + if (!Number.isInteger(attachment.sizeBytes) || attachment.sizeBytes < 0) { + throw new DeadlineGuardError( + "attachment sizeBytes must be a non-negative integer", + "attachments", + ); + } + if (attachment.sizeBytes > DEADLINE_GUARD_LIMITS.MAX_ATTACHMENT_BYTES) { + throw new DeadlineGuardError( + `attachment exceeds ${DEADLINE_GUARD_LIMITS.MAX_ATTACHMENT_BYTES} bytes`, + "attachments", + ); + } + return { + name, + sizeBytes: attachment.sizeBytes, + }; + }); +} + +export function guardHistoryWindow(events = []) { + if (!Array.isArray(events)) { + throw new DeadlineGuardError("history events must be an array", "history"); + } + if (events.length > DEADLINE_GUARD_LIMITS.MAX_HISTORY_EVENTS) { + throw new DeadlineGuardError( + `history size ${events.length} exceeds ${DEADLINE_GUARD_LIMITS.MAX_HISTORY_EVENTS}; request a smaller window`, + "history", + ); + } + return true; +} + +export function guardDetectionRequest(request) { + if (request === null || typeof request !== "object" || Array.isArray(request)) { + throw new DeadlineGuardError("detection request must be a plain object", "request"); + } + + const messages = guardDeadlineMessageBatch(request.messages); + const options = {}; + + if (request.options?.now !== undefined) { + options.now = validateIsoTimestamp(request.options.now, "now"); + } + if (request.options?.defaultTimezone !== undefined) { + options.defaultTimezone = validateTimezone(request.options.defaultTimezone); + } + + return { messages, options }; +} + +export { ALLOWED_SOURCE_TYPES }; diff --git a/tools/v2/individual/deadline-detector/tests/deadline-guards.test.mjs b/tools/v2/individual/deadline-detector/tests/deadline-guards.test.mjs new file mode 100644 index 00000000..78442d36 --- /dev/null +++ b/tools/v2/individual/deadline-detector/tests/deadline-guards.test.mjs @@ -0,0 +1,181 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + DEADLINE_GUARD_LIMITS, + DeadlineGuardError, + guardAttachmentMetadata, + guardDeadlineMessageBatch, + guardDetectionRequest, + guardHistoryWindow, + sanitizeDeadlineMessage, + sanitizeDeadlineText, + validateIsoTimestamp, + validateMessageId, + validateSender, + validateSourceType, + validateTimezone, +} from "../guards/deadline-guards.mjs"; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const fixture = JSON.parse( + readFileSync(join(currentDir, "..", "fixtures", "hostile-deadline-inputs.json"), "utf8"), +); + +const baseMessage = fixture.validMessages[0]; + +function assertGuardError(fn, field) { + assert.throws( + fn, + (error) => error instanceof DeadlineGuardError && error.field === field, + `expected DeadlineGuardError for ${field}`, + ); +} + +describe("Deadline Detector guards - text sanitization", () => { + for (const entry of fixture.sanitizationCases) { + it(`sanitizes ${entry.field}`, () => { + assert.equal(sanitizeDeadlineText(entry.input), entry.expected); + }); + } + + it("returns an empty string for non-string text", () => { + assert.equal(sanitizeDeadlineText(null), ""); + assert.equal(sanitizeDeadlineText(42), ""); + }); + + it("truncates text to the provided limit", () => { + assert.equal(sanitizeDeadlineText("abcdef", 3), "abc"); + }); +}); + +describe("Deadline Detector guards - field validators", () => { + it("accepts safe message ids", () => { + assert.equal(validateMessageId("msg-safe_001"), "msg-safe_001"); + }); + + it("rejects path-like ids", () => { + assertGuardError(() => validateMessageId("../../mailbox"), "id"); + }); + + it("accepts allowlisted source types", () => { + assert.equal(validateSourceType("email"), "email"); + assert.equal(validateSourceType("project-update"), "project-update"); + }); + + it("rejects unknown source types", () => { + assertGuardError(() => validateSourceType("live-inbox"), "type"); + }); + + it("rejects sender CRLF injection", () => { + assertGuardError( + () => validateSender("sender@example.test\r\nBcc: victim@example.test"), + "sender", + ); + }); + + it("rejects malformed timestamps", () => { + assertGuardError(() => validateIsoTimestamp("not-a-date", "receivedAt"), "receivedAt"); + }); + + it("rejects unsafe timezone characters", () => { + assertGuardError(() => validateTimezone("UTC\r\nX-Timezone: injected"), "userTimezone"); + }); +}); + +describe("Deadline Detector guards - message validation", () => { + it("returns a sanitized copy without mutating input", () => { + const input = { + ...baseMessage, + subject: " Due\u0000 tomorrow ", + body: "Submit\u200b by tomorrow.", + }; + const sanitized = sanitizeDeadlineMessage(input); + + assert.equal(sanitized.subject, "Due tomorrow"); + assert.equal(sanitized.body, "Submit by tomorrow."); + assert.equal(input.subject, " Due\u0000 tomorrow "); + }); + + for (const entry of fixture.hostileMessages) { + it(`rejects ${entry.id}: ${entry.reason}`, () => { + assertGuardError( + () => sanitizeDeadlineMessage({ ...baseMessage, ...entry.patch }), + entry.field, + ); + }); + } + + it("rejects non-object messages", () => { + assertGuardError(() => sanitizeDeadlineMessage(null), "message"); + assertGuardError(() => sanitizeDeadlineMessage([]), "message"); + }); + + it("guards and sanitizes a bounded message batch", () => { + const result = guardDeadlineMessageBatch([baseMessage]); + assert.equal(result.length, 1); + assert.equal(result[0].id, baseMessage.id); + }); + + it("rejects oversized message batches before detection", () => { + const messages = Array.from( + { length: DEADLINE_GUARD_LIMITS.MAX_MESSAGE_BATCH_SIZE + 1 }, + (_, index) => ({ ...baseMessage, id: `msg-${index}` }), + ); + + assertGuardError(() => guardDeadlineMessageBatch(messages), "messages"); + }); +}); + +describe("Deadline Detector guards - request and collection limits", () => { + it("validates a future adapter request shape", () => { + const request = { + messages: [baseMessage], + options: { + now: "2026-06-18T09:00:00Z", + defaultTimezone: "UTC", + }, + }; + + const guarded = guardDetectionRequest(request); + assert.equal(guarded.messages.length, 1); + assert.equal(guarded.options.now, "2026-06-18T09:00:00Z"); + assert.equal(guarded.options.defaultTimezone, "UTC"); + }); + + it("rejects attachment content and accepts metadata", () => { + const metadata = guardAttachmentMetadata([ + { + name: fixture.sanitizationCases[2].input, + sizeBytes: 1024, + }, + ]); + + assert.equal(metadata[0].name, fixture.sanitizationCases[2].expected); + assertGuardError( + () => guardAttachmentMetadata([{ name: "invoice.pdf", sizeBytes: 10, content: "raw" }]), + "attachments", + ); + }); + + it("rejects oversized attachment lists", () => { + const attachments = Array.from( + { length: DEADLINE_GUARD_LIMITS.MAX_ATTACHMENT_COUNT + 1 }, + (_, index) => ({ name: `file-${index}.txt`, sizeBytes: 100 }), + ); + + assertGuardError(() => guardAttachmentMetadata(attachments), "attachments"); + }); + + it("rejects oversized history windows", () => { + const history = Array.from( + { length: DEADLINE_GUARD_LIMITS.MAX_HISTORY_EVENTS + 1 }, + (_, index) => ({ id: `event-${index}` }), + ); + + assertGuardError(() => guardHistoryWindow(history), "history"); + }); +});