diff --git a/lib/services/route53-record-validation.test.ts b/lib/services/route53-record-validation.test.ts new file mode 100644 index 0000000..1008bfb --- /dev/null +++ b/lib/services/route53-record-validation.test.ts @@ -0,0 +1,163 @@ +/** + * lib/services/route53-record-validation.ts unit tests — D-01 allowlist + * enforcement and AWS error sanitization (T-24-03). Pure logic, no AWS SDK + * or Postgres dependency to mock. + */ + +import { describe, it, expect } from 'vitest'; +import { + WRITABLE_RECORD_TYPES, + validateRecordWrite, + sanitizeAwsError, +} from './route53-record-validation'; + +describe('WRITABLE_RECORD_TYPES', () => { + it('is exactly the six D-01 writable types, never NS or SOA', () => { + expect([...WRITABLE_RECORD_TYPES].sort()).toEqual( + ['A', 'AAAA', 'CNAME', 'MX', 'SRV', 'TXT'].sort() + ); + expect(WRITABLE_RECORD_TYPES).not.toContain('NS'); + expect(WRITABLE_RECORD_TYPES).not.toContain('SOA'); + }); +}); + +describe('validateRecordWrite', () => { + const basePayload = { + name: 'www.example.com', + resourceRecords: [{ value: '1.2.3.4' }], + }; + + it('rejects NS with a reason naming it as a zone-delegation record', () => { + const result = validateRecordWrite({ ...basePayload, type: 'NS' }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(400); + expect(result.reason).toMatch(/NS/); + expect(result.reason).toMatch(/delegation/i); + } + }); + + it('rejects SOA', () => { + const result = validateRecordWrite({ ...basePayload, type: 'SOA' }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(400); + }); + + it('rejects lowercase "ns" case-insensitively', () => { + const result = validateRecordWrite({ ...basePayload, type: 'ns' }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(400); + expect(result.reason).toMatch(/delegation/i); + } + }); + + it.each(['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'SRV'])( + 'accepts well-formed %s payload', + (type) => { + const result = validateRecordWrite({ ...basePayload, type }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.type).toBe(type); + expect(result.value.name).toBe('www.example.com.'); + expect(result.value.ttl).toBe(300); + } + } + ); + + it('rejects an unknown type such as CAA (closed allowlist, not a blocklist)', () => { + const result = validateRecordWrite({ ...basePayload, type: 'CAA' }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(400); + }); + + it('rejects an unknown type such as DS', () => { + const result = validateRecordWrite({ ...basePayload, type: 'DS' }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(400); + }); + + it('rejects a missing name', () => { + const result = validateRecordWrite({ type: 'A', resourceRecords: [{ value: '1.2.3.4' }] }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(400); + }); + + it('rejects an empty-string name', () => { + const result = validateRecordWrite({ ...basePayload, name: ' ', type: 'A' }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(400); + }); + + it('rejects a non-integer ttl', () => { + const result = validateRecordWrite({ ...basePayload, type: 'A', ttl: 3.5 }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(400); + }); + + it('rejects a ttl outside 0..2147483647', () => { + const tooHigh = validateRecordWrite({ ...basePayload, type: 'A', ttl: 2147483648 }); + expect(tooHigh.ok).toBe(false); + const tooLow = validateRecordWrite({ ...basePayload, type: 'A', ttl: -1 }); + expect(tooLow.ok).toBe(false); + }); + + it('accepts ttl of exactly 0 and exactly 2147483647', () => { + const min = validateRecordWrite({ ...basePayload, type: 'A', ttl: 0 }); + expect(min.ok).toBe(true); + const max = validateRecordWrite({ ...basePayload, type: 'A', ttl: 2147483647 }); + expect(max.ok).toBe(true); + }); + + it('rejects an empty resourceRecords array (Route 53 rejects an empty value set)', () => { + const result = validateRecordWrite({ name: 'www.example.com', type: 'A', resourceRecords: [] }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(400); + }); + + it('rejects a resourceRecords entry with an empty-string value', () => { + const result = validateRecordWrite({ + name: 'www.example.com', + type: 'A', + resourceRecords: [{ value: '' }], + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(400); + }); + + it('caps resourceRecords at 100 entries', () => { + const tooMany = Array.from({ length: 101 }, (_, i) => ({ value: `10.0.0.${i % 256}` })); + const result = validateRecordWrite({ name: 'www.example.com', type: 'A', resourceRecords: tooMany }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(400); + }); +}); + +describe('sanitizeAwsError', () => { + it('strips AWS access key ids, ARNs, and account ids, then truncates to 500 chars', () => { + const err = new Error( + 'AccessDenied for AKIAIOSFODNN7EXAMPLE on arn:aws:route53:::hostedzone/Z123 account 123456789012' + ); + const sanitized = sanitizeAwsError(err); + expect(sanitized).not.toContain('AKIAIOSFODNN7EXAMPLE'); + expect(sanitized).not.toContain('arn:aws:route53:::hostedzone/Z123'); + expect(sanitized).not.toContain('123456789012'); + expect(sanitized.length).toBeLessThanOrEqual(504); + }); + + it('truncates messages longer than 500 characters', () => { + const longMessage = 'x'.repeat(1000); + const sanitized = sanitizeAwsError(new Error(longMessage)); + expect(sanitized.length).toBeLessThanOrEqual(504); + expect(sanitized.endsWith('...')).toBe(true); + }); + + it('never throws on a non-Error input and returns a string', () => { + expect(() => sanitizeAwsError('a plain string error')).not.toThrow(); + expect(typeof sanitizeAwsError('a plain string error')).toBe('string'); + expect(() => sanitizeAwsError(undefined)).not.toThrow(); + expect(typeof sanitizeAwsError(undefined)).toBe('string'); + expect(() => sanitizeAwsError({ weird: 'object' })).not.toThrow(); + expect(typeof sanitizeAwsError(null)).toBe('string'); + }); +}); diff --git a/lib/services/route53-record-validation.ts b/lib/services/route53-record-validation.ts new file mode 100644 index 0000000..30fb74e --- /dev/null +++ b/lib/services/route53-record-validation.ts @@ -0,0 +1,171 @@ +/** + * 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; +}