wulf-pulse/lib/services/route53-record-validation.ts
lorentz 4be4a191a5 feat(24-03): add D-01 record-write validator and AWS error sanitizer
- validateRecordWrite enforces closed allowlist (A/AAAA/CNAME/MX/TXT/SRV), rejects NS/SOA case-insensitively with a delegation-specific reason
- sanitizeAwsError redacts AWS access key ids, ARNs, and 12-digit account ids, truncates to 500 chars (T-24-03)
- no AWS SDK or Postgres dependency; fully unit-tested (23 assertions)
2026-08-05 20:20:46 -04:00

171 lines
5.8 KiB
TypeScript

/**
* 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;
}