diff --git a/src/index.ts b/src/index.ts index d7c6f0a..7c81dae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,22 +1,30 @@ 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 }; + +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/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 }; +} diff --git a/src/validation/metadata-validation.ts b/src/validation/metadata-validation.ts new file mode 100644 index 0000000..450c912 --- /dev/null +++ b/src/validation/metadata-validation.ts @@ -0,0 +1,188 @@ +import { type Result, err, ok } 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'); + } + }); + }); +}); +