fix(24-05): quote TXT record values per RFC 1035 character-string format

AWS Route 53 rejects an unquoted TXT Value with:
'InvalidCharacterString (Value should be enclosed in quotation marks)'
— discovered during plan 24-07's live checkpoint (step 2, create) against
a real hosted zone. buildChangeBatch now wraps TXT values in escaped
double quotes, splitting into 255-character segments per RFC 1035's
character-string limit. A/AAAA/CNAME/MX/SRV values pass through
unchanged (only TXT uses the quoted-string wire format).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
lorentz 2026-08-05 21:08:51 -04:00
parent b9d5a4823f
commit 4da5664184
2 changed files with 68 additions and 1 deletions

View file

@ -79,6 +79,46 @@ describe('buildChangeBatch', () => {
it('throws when given record type SOA (case-insensitive)', () => { it('throws when given record type SOA (case-insensitive)', () => {
expect(() => buildChangeBatch('UPSERT', { ...baseRecordSet, type: 'soa' })).toThrow(/SOA/i); expect(() => buildChangeBatch('UPSERT', { ...baseRecordSet, type: 'soa' })).toThrow(/SOA/i);
}); });
describe('TXT record quoting (RFC 1035 character-string)', () => {
it('wraps a plain TXT value in double quotes — AWS rejects an unquoted Value with InvalidCharacterString', () => {
const batch = buildChangeBatch('UPSERT', {
...baseRecordSet,
type: 'TXT',
resourceRecords: [{ value: 'phase24-verification' }],
});
expect(batch.Changes?.[0].ResourceRecordSet?.ResourceRecords).toEqual([
{ Value: '"phase24-verification"' },
]);
});
it('escapes embedded double quotes and backslashes before quoting', () => {
const batch = buildChangeBatch('UPSERT', {
...baseRecordSet,
type: 'TXT',
resourceRecords: [{ value: 'v=spf1 include:"weird\\path" ~all' }],
});
expect(batch.Changes?.[0].ResourceRecordSet?.ResourceRecords?.[0].Value).toBe(
'"v=spf1 include:\\"weird\\\\path\\" ~all"'
);
});
it('splits a value over 255 characters into multiple quoted segments', () => {
const long = 'a'.repeat(300);
const batch = buildChangeBatch('UPSERT', {
...baseRecordSet,
type: 'TXT',
resourceRecords: [{ value: long }],
});
const value = batch.Changes?.[0].ResourceRecordSet?.ResourceRecords?.[0].Value ?? '';
expect(value).toBe(`"${'a'.repeat(255)}" "${'a'.repeat(45)}"`);
});
it('does not quote non-TXT record values', () => {
const batch = buildChangeBatch('UPSERT', baseRecordSet);
expect(batch.Changes?.[0].ResourceRecordSet?.ResourceRecords).toEqual([{ Value: '1.2.3.4' }]);
});
});
}); });
describe('isRetryableAwsError', () => { describe('isRetryableAwsError', () => {

View file

@ -28,6 +28,31 @@ import {
import { getRoute53Client } from './route53-factory'; import { getRoute53Client } from './route53-factory';
const ZONE_DELEGATION_TYPES = new Set(['NS', 'SOA']); const ZONE_DELEGATION_TYPES = new Set(['NS', 'SOA']);
const TXT_MAX_SEGMENT_LENGTH = 255;
/**
* TXT (and SPF) records use RFC 1035 character-string RDATA AWS rejects a
* bare, unquoted value with `InvalidCharacterString`. A Value must be one or
* more double-quoted segments, each up to 255 bytes; segments longer than
* that are split and re-concatenated (segments render back as one string).
* Internal backslashes and double quotes must be backslash-escaped before
* quoting. A/AAAA/CNAME/MX/SRV values are plain (unquoted) and pass through
* `formatResourceRecordValue` unchanged.
*/
function formatTxtValue(value: string): string {
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const segments: string[] = [];
for (let i = 0; i < escaped.length; i += TXT_MAX_SEGMENT_LENGTH) {
segments.push(escaped.slice(i, i + TXT_MAX_SEGMENT_LENGTH));
}
if (segments.length === 0) segments.push('');
return segments.map((segment) => `"${segment}"`).join(' ');
}
function formatResourceRecordValue(type: string, value: string): string {
return type === 'TXT' ? formatTxtValue(value) : value;
}
const RETRYABLE_ERROR_NAMES = new Set([ const RETRYABLE_ERROR_NAMES = new Set([
'ThrottlingException', 'ThrottlingException',
'Throttling', 'Throttling',
@ -69,7 +94,9 @@ export function buildChangeBatch(
Name: recordSet.name, Name: recordSet.name,
Type: type as ResourceRecordSet['Type'], Type: type as ResourceRecordSet['Type'],
TTL: recordSet.ttl, TTL: recordSet.ttl,
ResourceRecords: recordSet.resourceRecords.map((r) => ({ Value: r.value })), ResourceRecords: recordSet.resourceRecords.map((r) => ({
Value: formatResourceRecordValue(type, r.value),
})),
}; };
if (recordSet.setIdentifier !== null && recordSet.setIdentifier !== undefined) { if (recordSet.setIdentifier !== null && recordSet.setIdentifier !== undefined) {