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)
This commit is contained in:
lorentz 2026-08-05 20:20:46 -04:00
parent ecd4dabd67
commit 4be4a191a5
2 changed files with 334 additions and 0 deletions

View file

@ -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');
});
});

View file

@ -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;
}