-
Notifications
You must be signed in to change notification settings - Fork 0
Metadata Validation #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| */ | ||
| 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); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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)
There was a problem hiding this comment.
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.