/** * AWS Route 53 DNS sync — server-side write validation + AWS error sanitizer. * * D-01: A closed allowlist of writable record types. NS and SOA are * zone-delegation records and must never be writable from Pulse — this check * runs in library code the CRUD routes call BEFORE any AWS command is * constructed, mirroring the credential-field blocklist pattern in * app/api/analyzer/itglue/applications/[id]/apply/route.ts (belt over the * UI's braces: never rely on the UI simply not offering the option). * * T-24-03: `sanitizeAwsError` is the only permitted source of a persisted or * client-returned AWS error string — it strips access key ids, ARNs, and AWS * account ids before the message is written to `route53_audit_log.error_message` * or returned in an API response body. * * No AWS SDK or Postgres dependency here by design — this module is plain, * synchronous, unit-testable validation logic. */ import type { Route53RecordValue, Route53WritableType } from '@/lib/types/route53'; /** * D-01: closed allowlist. NS and SOA are deliberately absent — an * unrecognized type is rejected, never passed through. */ export const WRITABLE_RECORD_TYPES: readonly Route53WritableType[] = Object.freeze([ 'A', 'AAAA', 'CNAME', 'MX', 'TXT', 'SRV', ]); const MIN_TTL = 0; const MAX_TTL = 2147483647; const MAX_RESOURCE_RECORDS = 100; const ZONE_DELEGATION_TYPES = new Set(['NS', 'SOA']); export interface ValidatedRecordWrite { name: string; type: Route53WritableType; ttl: number; resourceRecords: Route53RecordValue[]; } export type ValidateRecordWriteResult = | { ok: true; value: ValidatedRecordWrite } | { ok: false; status: 400; reason: string }; interface ValidateRecordWriteInput { name?: unknown; type?: unknown; ttl?: unknown; resourceRecords?: unknown; } function fail(reason: string): ValidateRecordWriteResult { return { ok: false, status: 400, reason }; } /** * Validate a record write payload against D-01's closed allowlist and basic * shape rules. Does not construct any AWS command and has no DB dependency — * callers invoke this first and only proceed to build a * ChangeResourceRecordSetsCommand when `ok` is true. */ export function validateRecordWrite(input: ValidateRecordWriteInput): ValidateRecordWriteResult { // 1. name: non-empty string after trimming, normalized to Route 53's // canonical lowercase + trailing-dot form. if (typeof input.name !== 'string' || input.name.trim().length === 0) { return fail('Record name is required and must be a non-empty string'); } let name = input.name.trim().toLowerCase(); if (!name.endsWith('.')) { name = `${name}.`; } // 2. type: string, uppercased, must be a member of the closed allowlist. if (typeof input.type !== 'string' || input.type.trim().length === 0) { return fail('Record type is required and must be a string'); } const type = input.type.trim().toUpperCase(); if (ZONE_DELEGATION_TYPES.has(type)) { return fail( `Record type ${type} is not writable from Pulse — NS and SOA are zone-delegation records (D-01)` ); } if (!WRITABLE_RECORD_TYPES.includes(type as Route53WritableType)) { return fail( `Record type ${type} is not a supported writable type — must be one of ${WRITABLE_RECORD_TYPES.join(', ')}` ); } // 3. ttl: integer 0..2147483647 inclusive, default 300 when omitted. let ttl: number; if (input.ttl === undefined || input.ttl === null) { ttl = 300; } else { if (typeof input.ttl !== 'number' || !Number.isInteger(input.ttl)) { return fail('ttl must be an integer'); } if (input.ttl < MIN_TTL || input.ttl > MAX_TTL) { return fail(`ttl must be between ${MIN_TTL} and ${MAX_TTL}`); } ttl = input.ttl; } // 4. resourceRecords: non-empty array, each entry a non-empty string value, // capped at 100 entries (Route 53 rejects an empty value set; unbounded // arrays are a DoS surface — T-24-12). if (!Array.isArray(input.resourceRecords) || input.resourceRecords.length === 0) { return fail('resourceRecords must be a non-empty array'); } if (input.resourceRecords.length > MAX_RESOURCE_RECORDS) { return fail(`resourceRecords cannot exceed ${MAX_RESOURCE_RECORDS} entries`); } const resourceRecords: Route53RecordValue[] = []; for (const entry of input.resourceRecords) { if ( typeof entry !== 'object' || entry === null || typeof (entry as { value?: unknown }).value !== 'string' || (entry as { value: string }).value.trim().length === 0 ) { return fail('Each resourceRecords entry must have a non-empty string value'); } resourceRecords.push({ value: (entry as { value: string }).value }); } return { ok: true, value: { name, type: type as Route53WritableType, ttl, resourceRecords, }, }; } const AWS_ACCESS_KEY_ID_PATTERN = /AKIA[0-9A-Z]{16}/g; const AWS_ARN_PATTERN = /arn:aws:[^\s"']+/g; const AWS_ACCOUNT_ID_PATTERN = /\b[0-9]{12}\b/g; const MAX_SANITIZED_LENGTH = 500; /** * Redact anything that looks like an AWS access key id, ARN, or 12-digit * account id from an AWS SDK error, then truncate. This is the only string * that may be written to `route53_audit_log.error_message` or returned in an * API response body (T-24-03). Never throws. */ export function sanitizeAwsError(err: unknown): string { let message: string; try { message = err instanceof Error ? err.message : String(err); } catch { message = 'Unknown error'; } let sanitized = message .replace(AWS_ACCESS_KEY_ID_PATTERN, '[redacted-key-id]') .replace(AWS_ARN_PATTERN, '[redacted-arn]') .replace(AWS_ACCOUNT_ID_PATTERN, '[redacted-account-id]'); if (sanitized.length > MAX_SANITIZED_LENGTH) { sanitized = `${sanitized.slice(0, MAX_SANITIZED_LENGTH)}...`; } return sanitized; }