From ce847427e5f144c1d5e5012bf1e849780956833b Mon Sep 17 00:00:00 2001 From: amackillop Date: Mon, 22 Dec 2025 08:32:02 -0800 Subject: [PATCH 1/3] Add Result utility type for error handling Introduces a generic Result discriminated union type for type-safe error handling without exceptions. This pattern allows functions to return either success (ok: true, value: T) or failure (ok: false, error: E) in a way that TypeScript can narrow. Helper functions ok() and err() simplify creating Result instances. --- src/lib/utils.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/lib/utils.ts diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..4254799 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,40 @@ +/** + * Generic Result type for operations that can succeed or fail. + * + * This is a discriminated union that provides type-safe error handling + * without throwing exceptions. The `ok` property acts as the discriminator. + * + * @example + * ```typescript + * function divide(a: number, b: number): Result { + * if (b === 0) { + * return { ok: false, error: 'Division by zero' } + * } + * return { ok: true, value: a / b } + * } + * + * const result = divide(10, 2) + * if (result.ok) { + * console.log(result.value) // TypeScript knows result.value exists + * } else { + * console.error(result.error) // TypeScript knows result.error exists + * } + * ``` + */ +export type Result = + | { ok: true; value: T } + | { ok: false; error: E }; + +/** + * Creates a successful Result + */ +export function ok(value: T): Result { + return { ok: true, value }; +} + +/** + * Creates a failed Result + */ +export function err(error: E): Result { + return { ok: false, error }; +} From 5ef48ee7bce9169f7f1d67828a8155d13b4dfa5e Mon Sep 17 00:00:00 2001 From: amackillop Date: Mon, 22 Dec 2025 08:46:58 -0800 Subject: [PATCH 2/3] Add security validation for checkout metadata Implements comprehensive validation for checkout metadata to prevent security issues before database persistence. Validates key count, key format/length, value encoding, control characters, and total serialized size. Validation collects all errors and reports them together, making it easier for users to fix multiple issues. Error messages include the specific key name for value-related errors. --- src/index.ts | 8 + src/validation/metadata-validation.ts | 188 ++++++++++++ tests/validation/metadata-validation.test.ts | 285 +++++++++++++++++++ 3 files changed, 481 insertions(+) create mode 100644 src/validation/metadata-validation.ts create mode 100644 tests/validation/metadata-validation.test.ts diff --git a/src/index.ts b/src/index.ts index d7c6f0a..42215fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,3 +20,11 @@ export type { export type { Checkout } from "./schemas/checkout"; export const contract = { checkout, onboarding }; + +export type { MetadataValidationError } from "./validation/metadata-validation"; +export { + MAX_KEY_COUNT, + MAX_KEY_LENGTH, + MAX_METADATA_SIZE_BYTES, + validateMetadata, +} from "./validation/metadata-validation"; diff --git a/src/validation/metadata-validation.ts b/src/validation/metadata-validation.ts new file mode 100644 index 0000000..88cfe55 --- /dev/null +++ b/src/validation/metadata-validation.ts @@ -0,0 +1,188 @@ +import { err, ok, type Result } from "../lib/utils.js"; + +export const MAX_METADATA_SIZE_BYTES = 1024; // 1KB +export const MAX_KEY_LENGTH = 100; +export const MAX_KEY_COUNT = 50; + +/** + * Pattern matching control characters (0x00-0x1F) except: + * - 0x09 (tab) - allowed for formatting + * - 0x0A (LF/newline) - allowed for multi-line text + * - 0x0D (CR/carriage return) - allowed for line endings + * + * Security concerns with control characters: + * - Null bytes (0x00) can cause string truncation and injection attacks + * - ESC (0x1B) can execute terminal escape sequences if displayed in terminals + * - Control characters can obfuscate malicious content in logs + * - Many databases and systems have issues storing/processing control characters + * - Can cause JSON parsing issues in some edge cases + * - May break string operations in various programming languages + * + * Matches: null (0x00), SOH-STX (0x01-0x02), EOT-ACK (0x04-0x06), + * BEL (0x07), BS (0x08), VT (0x0B), FF (0x0C), SO-SI (0x0E-0x0F), + * DLE-DC4 (0x10-0x14), NAK-SYN (0x15-0x16), ETB-CAN (0x17-0x18), + * EM-SUB (0x19-0x1A), ESC-FS (0x1B-0x1C), GS-US (0x1D-0x1F) + */ +// biome-ignore lint/suspicious/noControlCharactersInRegex: This regex intentionally matches control characters for security validation +const CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B-\x0C\x0E-\x1F]/; + +/** + * Pattern matching valid key format (alphanumeric, underscore, hyphen only) + */ +const VALID_KEY_PATTERN = /^[a-zA-Z0-9_-]+$/; + +export type MetadataValidationError = { + type: string; + message: string; +}; + +function validateKeyFormat(key: string): Result { + if (!VALID_KEY_PATTERN.test(key)) { + const message = + key === "" + ? "Metadata keys cannot be empty" + : `Metadata key "${key}" contains invalid characters. Keys must contain only letters, numbers, underscores, and hyphens.`; + return err({ type: "invalid_key_format", message }); + } + return ok(undefined); +} + +function validateKeyLength(key: string): Result { + if (key.length > MAX_KEY_LENGTH) { + return err({ + type: "key_too_long", + message: `Metadata key "${key}" exceeds maximum length of ${MAX_KEY_LENGTH} characters`, + }); + } + return ok(undefined); +} + +function validateNullBytes( + key: string, + value: string, +): Result { + if (value.includes("\0")) { + return err({ + type: "control_character", + message: `Metadata value for key "${key}" cannot contain null bytes`, + }); + } + return ok(undefined); +} + +function validateControlCharacters( + key: string, + value: string, +): Result { + if (CONTROL_CHAR_PATTERN.test(value)) { + return err({ + type: "control_character", + message: `Metadata value for key "${key}" cannot contain control characters`, + }); + } + return ok(undefined); +} + +function validateUtf8Encoding( + key: string, + value: string, +): Result { + try { + const encoded = new TextEncoder().encode(value); + new TextDecoder("utf-8", { fatal: true }).decode(encoded); + } catch { + return err({ + type: "invalid_encoding", + message: `Metadata value for key "${key}" contains invalid UTF-8 encoding`, + }); + } + return ok(undefined); +} + +function validateMetadataSize( + metadata: Record, +): Result { + const serialized = JSON.stringify(metadata); + const sizeBytes = new TextEncoder().encode(serialized).length; + if (sizeBytes > MAX_METADATA_SIZE_BYTES) { + return err({ + type: "size_exceeded", + message: `Metadata size (${sizeBytes} bytes) exceeds maximum allowed size (${MAX_METADATA_SIZE_BYTES} bytes). To fix this, reduce the size of your metadata values or remove unnecessary fields.`, + }); + } + return ok(undefined); +} + +function validateKey(key: string): Result { + const formatCheck = validateKeyFormat(key); + if (!formatCheck.ok) return formatCheck; + + const lengthCheck = validateKeyLength(key); + if (!lengthCheck.ok) return lengthCheck; + + return ok(undefined); +} + +function validateValue( + key: string, + value: string, +): Result { + const nullByteCheck = validateNullBytes(key, value); + if (!nullByteCheck.ok) return nullByteCheck; + + const controlCharCheck = validateControlCharacters(key, value); + if (!controlCharCheck.ok) return controlCharCheck; + + const encodingCheck = validateUtf8Encoding(key, value); + if (!encodingCheck.ok) return encodingCheck; + + return ok(undefined); +} + +/** + * Validates checkout metadata against all security constraints. + * Returns all validation errors found, allowing users to fix multiple issues at once. + * + * @param metadata - The metadata object to validate, or undefined + * @returns A Result containing either success (ok: true) or an array of validation errors (ok: false) + */ +export function validateMetadata( + metadata: Record | undefined, +): Result { + if (!metadata) { + return ok(undefined); + } + + const errors: MetadataValidationError[] = []; + + const keyCount = Object.keys(metadata).length; + if (keyCount > MAX_KEY_COUNT) { + errors.push({ + type: "key_count_exceeded", + message: `Metadata contains ${keyCount} keys, which exceeds the maximum of ${MAX_KEY_COUNT} keys`, + }); + } + + for (const [key, value] of Object.entries(metadata)) { + const keyCheck = validateKey(key); + if (!keyCheck.ok) { + errors.push(keyCheck.error); + } + + const valueCheck = validateValue(key, value); + if (!valueCheck.ok) { + errors.push(valueCheck.error); + } + } + + const sizeCheck = validateMetadataSize(metadata); + if (!sizeCheck.ok) { + errors.push(sizeCheck.error); + } + + if (errors.length > 0) { + return err(errors); + } + + return ok(undefined); +} diff --git a/tests/validation/metadata-validation.test.ts b/tests/validation/metadata-validation.test.ts new file mode 100644 index 0000000..c412e23 --- /dev/null +++ b/tests/validation/metadata-validation.test.ts @@ -0,0 +1,285 @@ +import { describe, test, expect } from 'vitest'; +import { + validateMetadata, + MAX_METADATA_SIZE_BYTES, + MAX_KEY_LENGTH, + MAX_KEY_COUNT, +} from '../../src/validation/metadata-validation'; + +describe('validateMetadata', () => { + test('returns ok for undefined metadata', () => { + const result = validateMetadata(undefined); + expect(result.ok).toBe(true); + }); + + test('returns ok for empty metadata object', () => { + const result = validateMetadata({}); + expect(result.ok).toBe(true); + }); + + test('returns ok for valid metadata', () => { + const metadata = { + customerName: 'John Doe', + product: 'Lightning download', + note: 'Fast checkout', + }; + const result = validateMetadata(metadata); + expect(result.ok).toBe(true); + }); + + // Size validation tests + describe('size validation', () => { + test('returns error for metadata exceeding 1KB limit', () => { + const largeValue = 'x'.repeat(MAX_METADATA_SIZE_BYTES); + const metadata = { + data: largeValue, + }; + const result = validateMetadata(metadata); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.length).toBe(1); + expect(result.error[0].type).toBe('size_exceeded'); + expect(result.error[0].message).toContain('exceeds maximum allowed size'); + } + }); + + test('handles multiple fields that together exceed limit', () => { + const metadata = { + field1: 'x'.repeat(600), + field2: 'y'.repeat(600), + }; + const result = validateMetadata(metadata); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.length).toBe(1); + expect(result.error[0].type).toBe('size_exceeded'); + } + }); + + test('includes actual size in error details', () => { + const largeValue = 'x'.repeat(MAX_METADATA_SIZE_BYTES + 1); + const metadata = { + data: largeValue, + }; + + const result = validateMetadata(metadata); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.length).toBe(1); + expect(result.error[0].type).toBe('size_exceeded'); + expect(result.error[0].message).toContain('bytes'); + } + }); + }); + + // Key format tests + describe('key format validation', () => { + test('rejects key with special characters', () => { + const metadata = { + 'invalid@key': 'value', + }; + const result = validateMetadata(metadata); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.length).toBe(1); + expect(result.error[0].type).toBe('invalid_key_format'); + expect(result.error[0].message).toContain('invalid characters'); + expect(result.error[0].message).toContain('invalid@key'); + } + }); + + test('rejects key with spaces', () => { + const metadata = { + 'invalid key': 'value', + }; + const result = validateMetadata(metadata); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.length).toBe(1); + expect(result.error[0].type).toBe('invalid_key_format'); + } + }); + + test('rejects key with dots', () => { + const metadata = { + 'invalid.key': 'value', + }; + const result = validateMetadata(metadata); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.length).toBe(1); + expect(result.error[0].type).toBe('invalid_key_format'); + } + }); + + test('accepts valid key formats', () => { + const metadata = { + valid_key: 'value', + 'valid-key': 'value', + validKey123: 'value', + VALID_KEY: 'value', + }; + const result = validateMetadata(metadata); + expect(result.ok).toBe(true); + }); + + test('rejects empty key', () => { + const metadata = { + '': 'value', + }; + const result = validateMetadata(metadata); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.length).toBe(1); + expect(result.error[0].type).toBe('invalid_key_format'); + } + }); + }); + + // Key length tests + describe('key length validation', () => { + test('rejects key exceeding maximum length', () => { + const longKey = 'x'.repeat(MAX_KEY_LENGTH + 1); + const metadata = { + [longKey]: 'value', + }; + const result = validateMetadata(metadata); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.length).toBe(1); + expect(result.error[0].type).toBe('key_too_long'); + expect(result.error[0].message).toContain('exceeds maximum length'); + } + }); + + test('accepts key at maximum length', () => { + const maxKey = 'x'.repeat(MAX_KEY_LENGTH); + const metadata = { + [maxKey]: 'value', + }; + const result = validateMetadata(metadata); + expect(result.ok).toBe(true); + }); + }); + + // Key count tests + describe('key count validation', () => { + test('rejects metadata with too many keys', () => { + const metadata: Record = {}; + for (let i = 0; i < MAX_KEY_COUNT + 1; i++) { + metadata[`key${i}`] = 'value'; + } + const result = validateMetadata(metadata); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.length).toBe(1); + expect(result.error[0].type).toBe('key_count_exceeded'); + expect(result.error[0].message).toContain('exceeds the maximum'); + } + }); + + test('accepts metadata at maximum key count', () => { + const metadata: Record = {}; + for (let i = 0; i < MAX_KEY_COUNT; i++) { + metadata[`key${i}`] = 'value'; + } + const result = validateMetadata(metadata); + expect(result.ok).toBe(true); + }); + }); + + // Value encoding tests + describe('value encoding validation', () => { + test('rejects value with null bytes', () => { + const metadata = { + key: 'value\0with\0null', + }; + const result = validateMetadata(metadata); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.length).toBe(1); + expect(result.error[0].type).toBe('control_character'); + expect(result.error[0].message).toContain('null bytes'); + expect(result.error[0].message).toContain('key'); + } + }); + + test('rejects value with control characters', () => { + const metadata = { + key: 'value\x01control', + }; + const result = validateMetadata(metadata); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.length).toBe(1); + expect(result.error[0].type).toBe('control_character'); + } + }); + + test('accepts value with newline', () => { + const metadata = { + note: 'Line 1\nLine 2', + }; + const result = validateMetadata(metadata); + expect(result.ok).toBe(true); + }); + + test('accepts value with tab', () => { + const metadata = { + note: 'Column1\tColumn2', + }; + const result = validateMetadata(metadata); + expect(result.ok).toBe(true); + }); + + test('accepts value with carriage return', () => { + const metadata = { + note: 'Line 1\rLine 2', + }; + const result = validateMetadata(metadata); + expect(result.ok).toBe(true); + }); + + test('accepts unicode characters', () => { + const metadata = { + name: 'José', + emoji: '⚡', + chinese: '中文', + }; + const result = validateMetadata(metadata); + expect(result.ok).toBe(true); + }); + }); + + // Combined validation tests + describe('combined validation', () => { + test('validates all constraints and returns multiple errors', () => { + const metadata = { + 'invalid@key': 'value\x01control', // Invalid key format and control character + 'another-key': 'x'.repeat(MAX_METADATA_SIZE_BYTES), // Would exceed size + }; + const result = validateMetadata(metadata); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.length).toBeGreaterThanOrEqual(2); + const errorTypes = result.error.map((e) => e.type); + expect(errorTypes).toContain('invalid_key_format'); + expect(errorTypes).toContain('control_character'); + expect(errorTypes).toContain('size_exceeded'); + } + }); + }); +}); + From 01d1159f0fce6544d3a332cc96656072055641cc Mon Sep 17 00:00:00 2001 From: amackillop Date: Mon, 22 Dec 2025 08:50:26 -0800 Subject: [PATCH 3/3] Format --- src/index.ts | 24 ++++++++++++------------ src/validation/metadata-validation.ts | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/index.ts b/src/index.ts index 42215fb..7c81dae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,23 +1,23 @@ import { checkout } from "./contracts/checkout"; import { onboarding } from "./contracts/onboarding"; -export type { - StartDeviceAuth, - StartDeviceAuthResponse, - PollDeviceAuth, - PollDeviceAuthResponse, - BootstrapOnboarding, - BootstrapOnboardingResponse, -} from "./contracts/onboarding"; -export type { StartDeviceAuth as StartDeviceAuthInput } from "./contracts/onboarding"; -export { CheckoutSchema } from "./schemas/checkout"; export type { - CreateCheckout, ConfirmCheckout, - RegisterInvoice, + CreateCheckout, PaymentReceived, + RegisterInvoice, } from "./contracts/checkout"; +export type { + BootstrapOnboarding, + BootstrapOnboardingResponse, + PollDeviceAuth, + PollDeviceAuthResponse, + StartDeviceAuth, + StartDeviceAuth as StartDeviceAuthInput, + StartDeviceAuthResponse, +} from "./contracts/onboarding"; export type { Checkout } from "./schemas/checkout"; +export { CheckoutSchema } from "./schemas/checkout"; export const contract = { checkout, onboarding }; diff --git a/src/validation/metadata-validation.ts b/src/validation/metadata-validation.ts index 88cfe55..450c912 100644 --- a/src/validation/metadata-validation.ts +++ b/src/validation/metadata-validation.ts @@ -1,4 +1,4 @@ -import { err, ok, type Result } from "../lib/utils.js"; +import { type Result, err, ok } from "../lib/utils.js"; export const MAX_METADATA_SIZE_BYTES = 1024; // 1KB export const MAX_KEY_LENGTH = 100;