From e0c8d80cdea15a4ac3fe495a6ad38e043b4b80b5 Mon Sep 17 00:00:00 2001 From: Souvik Kumar Date: Fri, 16 Oct 2026 21:10:45 +0530 Subject: [PATCH] fix: decode Route53 octal escapes in record names (#5553) * fix: decode Route53 octal escapes in record names * fix: keep escaped dots and backslashes in Route53 record names --- apps/dokploy/__test__/dns/route53.test.ts | 66 +++++++++++++++++++++++ packages/server/src/utils/dns/route53.ts | 16 ++++-- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/__test__/dns/route53.test.ts b/apps/dokploy/__test__/dns/route53.test.ts index 6b3e0ab62..ec136ba85 100644 --- a/apps/dokploy/__test__/dns/route53.test.ts +++ b/apps/dokploy/__test__/dns/route53.test.ts @@ -111,6 +111,54 @@ describe("route53Client.listRecords", () => { ]); }); + it("decodes octal escapes such as \\052 for wildcard names", async () => { + send.mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "\\052.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.2.3.4" }], + }, + ], + IsTruncated: false, + }); + + const records = await route53Client.listRecords(config, "Z123"); + + expect(records[0]).toMatchObject({ + id: "A:*.example.com", + name: "*.example.com", + }); + }); + + it("keeps escaped dots so they stay distinct from label separators", async () => { + send.mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "a\\056b.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.2.3.4" }], + }, + { + Name: "a.b.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "5.6.7.8" }], + }, + ], + IsTruncated: false, + }); + + const records = await route53Client.listRecords(config, "Z123"); + + expect(records.map((r) => r.id)).toEqual([ + "A:a\\056b.example.com", + "A:a.b.example.com", + ]); + }); + it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => { send.mockResolvedValueOnce({ ResourceRecordSets: [ @@ -355,6 +403,24 @@ describe("route53Client.deleteRecord", () => { ]); }); + it("finds a wildcard record set that Route53 returns escaped", async () => { + const existingSet = { + Name: "\\052.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.2.3.4" }], + }; + send.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] }); + send.mockResolvedValueOnce({}); + + await route53Client.deleteRecord(config, "Z123", "A:*.example.com"); + + const changeCommand = send.mock.calls[1]?.[0] as HasInput; + expect(changeCommand.input.ChangeBatch.Changes).toEqual([ + { Action: "DELETE", ResourceRecordSet: existingSet }, + ]); + }); + it("throws when the record no longer exists", async () => { send.mockResolvedValueOnce({ ResourceRecordSets: [] }); diff --git a/packages/server/src/utils/dns/route53.ts b/packages/server/src/utils/dns/route53.ts index 8c7e70474..2ece868e4 100644 --- a/packages/server/src/utils/dns/route53.ts +++ b/packages/server/src/utils/dns/route53.ts @@ -27,6 +27,14 @@ const stripTrailingDot = (name: string) => name.replace(/\.$/, ""); const ensureTrailingDot = (name: string) => name.endsWith(".") ? name : `${name}.`; const stripZonePrefix = (id: string) => id.replace(/^\/hostedzone\//, ""); +// Route53 returns special characters in names as \ + three-digit octal code (e.g. * as \052). +// Escaped dots (\056) and backslashes (\134) stay escaped so decoded names remain unique. +const decodeRecordName = (name: string) => + name.replace(/\\([0-7]{3})/g, (match, code) => + code === "056" || code === "134" + ? match + : String.fromCharCode(Number.parseInt(code, 8)), + ); const buildRecordId = (type: string, name: string) => `${type}:${stripTrailingDot(name)}`; @@ -61,7 +69,8 @@ const findExactRecordSet = async ( if ( candidate && candidate.Type === type && - stripTrailingDot(candidate.Name ?? "") === stripTrailingDot(name) + stripTrailingDot(decodeRecordName(candidate.Name ?? "")) === + stripTrailingDot(name) ) { return candidate; } @@ -132,10 +141,11 @@ export const route53Client: DnsClient = { if (!set.Name || !set.Type || !set.ResourceRecords?.length) { continue; } + const name = decodeRecordName(set.Name); records.push({ - id: buildRecordId(set.Type, set.Name), + id: buildRecordId(set.Type, name), type: set.Type, - name: stripTrailingDot(set.Name), + name: stripTrailingDot(name), content: set.ResourceRecords.map((r) => r.Value).join("\n"), ttl: set.TTL ?? 300, });