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
32 changes: 20 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
40 changes: 40 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -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<number, string> {
* 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<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };

/**
* Creates a successful Result
*/
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}

/**
* Creates a failed Result
*/
export function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
188 changes: 188 additions & 0 deletions src/validation/metadata-validation.ts
Original file line number Diff line number Diff line change
@@ -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<void, MetadataValidationError> {
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<void, MetadataValidationError> {
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<void, MetadataValidationError> {
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<void, MetadataValidationError> {
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<void, MetadataValidationError> {
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<string, string>,
): Result<void, MetadataValidationError> {
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<void, MetadataValidationError> {
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<void, MetadataValidationError> {
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or an array of validation errors

yeah, good call.

the key is set by our user, but the value can come from the user’s visitor, so abuse might not be the user’s fault. we need to tell them exactly which metadata entry failed and why, so they can surface a clear error to the visitor and handle it cleanly, instead of things breaking with no explanation.

on the mdk-checkout side, we should add strongly typed errors to createCheckout so our users can reliably tell what went wrong and respond accordingly (unrelated to this issue, but right now it may fail because they didn't set the apiKey / mnemonic OR because the metadata validation failed OR because VSS is down. we need to tell them what's going on. right now it just fails silently)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I'm a big fan of modelling the error domain.

*/
export function validateMetadata(
metadata: Record<string, string> | undefined,
): Result<void, MetadataValidationError[]> {
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);
}
Loading