From 4da5664184c3e77b22bc526ff333068fb5340a67 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 5 Aug 2026 21:08:51 -0400 Subject: [PATCH] fix(24-05): quote TXT record values per RFC 1035 character-string format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/services/route53-change-submit.test.ts | 40 ++++++++++++++++++++++ lib/services/route53-change-submit.ts | 29 +++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/lib/services/route53-change-submit.test.ts b/lib/services/route53-change-submit.test.ts index e911289..17bdcec 100644 --- a/lib/services/route53-change-submit.test.ts +++ b/lib/services/route53-change-submit.test.ts @@ -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', () => { diff --git a/lib/services/route53-change-submit.ts b/lib/services/route53-change-submit.ts index 828166b..7235d89 100644 --- a/lib/services/route53-change-submit.ts +++ b/lib/services/route53-change-submit.ts @@ -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) {