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:
parent
ecd4dabd67
commit
4be4a191a5
2 changed files with 334 additions and 0 deletions
163
lib/services/route53-record-validation.test.ts
Normal file
163
lib/services/route53-record-validation.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
171
lib/services/route53-record-validation.ts
Normal file
171
lib/services/route53-record-validation.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue