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:
parent
b9d5a4823f
commit
4da5664184
2 changed files with 68 additions and 1 deletions
|
|
@ -79,6 +79,46 @@ describe('buildChangeBatch', () => {
|
|||
it('throws when given record type SOA (case-insensitive)', () => {
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,31 @@ import {
|
|||
import { getRoute53Client } from './route53-factory';
|
||||
|
||||
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([
|
||||
'ThrottlingException',
|
||||
'Throttling',
|
||||
|
|
@ -69,7 +94,9 @@ export function buildChangeBatch(
|
|||
Name: recordSet.name,
|
||||
Type: type as ResourceRecordSet['Type'],
|
||||
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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue