- 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)
171 lines
5.8 KiB
TypeScript
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;
|
|
}
|