Skip to content

Commit d5a1163

Browse files
committed
fix: enforce full SAN set, reject punycode labels, normalize IPv6 classification, rate-limit name updates
1 parent 5e832de commit d5a1163

7 files changed

Lines changed: 166 additions & 12 deletions

File tree

src/ip.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,31 @@ test("rejects public IPv6 and malformed addresses", () => {
3030
assert.equal(isPrivateAddress("not-an-ip"), false);
3131
assert.equal(isPrivateAddress(""), false);
3232
});
33+
34+
test("accepts expanded IPv6 spellings of private addresses", () => {
35+
assert.equal(isPrivateAddress("0:0:0:0:0:0:0:1"), true); // expanded loopback
36+
assert.equal(isPrivateAddress("fe80:0:0:0:0:0:0:1"), true);
37+
assert.equal(isPrivateAddress("fd00:0000:0000:0000:0000:0000:0000:0001"), true);
38+
});
39+
40+
test("classifies IPv4-mapped IPv6 by the embedded IPv4", () => {
41+
assert.equal(isPrivateAddress("::ffff:192.168.1.1"), true);
42+
assert.equal(isPrivateAddress("::ffff:10.0.0.5"), true);
43+
assert.equal(isPrivateAddress("::ffff:8.8.8.8"), false);
44+
});
45+
46+
test("classifies NAT64-embedded IPv6 by the embedded IPv4", () => {
47+
assert.equal(isPrivateAddress("64:ff9b::192.168.1.1"), true);
48+
assert.equal(isPrivateAddress("64:ff9b::c0a8:101"), true); // 192.168.1.1 in hex groups
49+
assert.equal(isPrivateAddress("64:ff9b::8.8.8.8"), false);
50+
});
51+
52+
test("classifies 6to4 IPv6 by the embedded IPv4", () => {
53+
assert.equal(isPrivateAddress("2002:c0a8:101::1"), true); // embeds 192.168.1.1
54+
assert.equal(isPrivateAddress("2002:808:808::1"), false); // embeds 8.8.8.8
55+
});
56+
57+
test("fails closed on ambiguous IPv6 forms", () => {
58+
assert.equal(isPrivateAddress("::"), false); // unspecified
59+
assert.equal(isPrivateAddress("::ffff:0:8.8.8.8"), false); // not the mapped /96
60+
});

src/ip.ts

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,64 @@ function isPrivateIPv4(address: string): boolean {
2323
}
2424

2525
function isPrivateIPv6(address: string): boolean {
26-
const normalized = address.toLowerCase();
27-
if (normalized === "::1") return true; // loopback
28-
if (normalized.startsWith("fe80:")) return true; // fe80::/10 link-local
29-
if (/^f[cd][0-9a-f]{2}:/.test(normalized)) return true; // fc00::/7 unique local
26+
// Normalize to the full 8x16-bit group form so shorthand ("::"), expanded,
27+
// and IPv4-embedded spellings all classify identically. Fail closed on
28+
// anything that does not normalize cleanly.
29+
const groups = expandIPv6(address);
30+
if (!groups) return false;
31+
32+
if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return true; // ::1 loopback
33+
if (groups.slice(0, 5).every((g) => g === 0) && groups[5] === 0xffff) {
34+
// ::ffff:a.b.c.d IPv4-mapped -- classify by the embedded IPv4
35+
return isPrivateIPv4(embeddedIPv4(groups[6], groups[7]));
36+
}
37+
if (
38+
groups[0] === 0x0064 &&
39+
groups[1] === 0xff9b &&
40+
groups.slice(2, 6).every((g) => g === 0)
41+
) {
42+
// 64:ff9b::/96 NAT64 -- classify by the embedded IPv4
43+
return isPrivateIPv4(embeddedIPv4(groups[6], groups[7]));
44+
}
45+
if (groups[0] === 0x2002) {
46+
// 2002::/16 6to4 -- classify by the embedded IPv4
47+
return isPrivateIPv4(embeddedIPv4(groups[1], groups[2]));
48+
}
49+
if ((groups[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
50+
if ((groups[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local
3051
return false;
3152
}
53+
54+
/** Expands a valid IPv6 address into its 8 16-bit groups, or null if it cannot. */
55+
function expandIPv6(address: string): number[] | null {
56+
let addr = address.toLowerCase();
57+
58+
// Rewrite a trailing dotted-quad (e.g. "::ffff:192.168.1.1") as two hex groups.
59+
const v4Match = addr.match(/^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
60+
if (v4Match) {
61+
const octets = v4Match[2].split(".").map(Number);
62+
if (octets.some((o) => o > 255)) return null;
63+
const hi = ((octets[0] << 8) | octets[1]).toString(16);
64+
const lo = ((octets[2] << 8) | octets[3]).toString(16);
65+
addr = `${v4Match[1]}${hi}:${lo}`;
66+
}
67+
68+
const halves = addr.split("::");
69+
if (halves.length > 2) return null;
70+
const head = halves[0] ? halves[0].split(":") : [];
71+
const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
72+
const missing = 8 - head.length - tail.length;
73+
if (halves.length === 2 ? missing < 0 : head.length !== 8) return null;
74+
const parts = halves.length === 2 ? [...head, ...Array(missing).fill("0"), ...tail] : head;
75+
76+
const groups: number[] = [];
77+
for (const part of parts) {
78+
if (!/^[0-9a-f]{1,4}$/.test(part)) return null;
79+
groups.push(Number.parseInt(part, 16));
80+
}
81+
return groups.length === 8 ? groups : null;
82+
}
83+
84+
function embeddedIPv4(hi: number, lo: number): string {
85+
return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
86+
}

src/names.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
validateCertRequest,
1111
validateNameClaim,
1212
validateNameDelete,
13+
validateNameLabel,
1314
verifyCertRequest,
1415
verifyNameClaim,
1516
verifyNameDelete,
@@ -157,6 +158,23 @@ test("rejects names with invalid dns characters", () => {
157158
);
158159
});
159160

161+
test("rejects punycode (xn--) labels", () => {
162+
assert.throws(() => validateNameLabel("xn--nxasmq6b"), /punycode/);
163+
assert.throws(
164+
() =>
165+
validateNameClaim({
166+
version: 1,
167+
action: "claim",
168+
name: "xn--nxasmq6b",
169+
subject: "did:key:z6MkiFake",
170+
lanIps: ["10.0.0.1"],
171+
sequence: 1,
172+
signature: "sig",
173+
}),
174+
/punycode/
175+
);
176+
});
177+
160178
test("rejects reserved names", () => {
161179
for (const reserved of ["api", "www", "admin", "acme"]) {
162180
assert.throws(
@@ -259,3 +277,9 @@ test("csr domain check rejects a csr with extra SAN entries", () => {
259277
const csr = createTestCsr(domain, [domain, "evil.example.com"]);
260278
assert.throws(() => assertCsrMatchesDomain(csr, domain), NameError);
261279
});
280+
281+
test("csr domain check rejects a csr with a non-dNSName SAN entry", () => {
282+
const domain = fqdnForName("mynode");
283+
const csr = createTestCsr(domain, [domain, { type: 7, ip: "8.8.8.8" }]); // iPAddress
284+
assert.throws(() => assertCsrMatchesDomain(csr, domain), /only a dNSName entry/);
285+
});

src/names.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ export function validateNameLabel(name: unknown): string {
6161
"name must be a dns-safe label (lowercase letters, digits, hyphens; cannot start or end with a hyphen)"
6262
);
6363
}
64+
if (lower.startsWith("xn--")) {
65+
throw new NameError('name must not be a punycode ("xn--") label');
66+
}
6467
if (RESERVED_NAMES.has(lower)) {
6568
throw new NameError(`name "${lower}" is reserved`);
6669
}
@@ -263,8 +266,9 @@ export async function verifyCertRequest(record: CertRequestRecord): Promise<bool
263266
}
264267

265268
/**
266-
* Enforces that the CSR's CN and every SAN dNSName entry is exactly the
267-
* expected <name>.local.tinycloud.link domain -- nothing broader, nothing else.
269+
* Enforces that the CSR's CN and its complete SAN set is exactly the
270+
* expected <name>.local.tinycloud.link domain as a single dNSName entry --
271+
* nothing broader, nothing else, no SAN entries of any other type.
268272
*/
269273
export function assertCsrMatchesDomain(csrPem: string, expectedDomain: string): void {
270274
let csr: forge.pki.CertificateSigningRequest;
@@ -281,9 +285,16 @@ export function assertCsrMatchesDomain(csrPem: string, expectedDomain: string):
281285
const sanExtension = (extensionRequest?.extensions ?? []).find(
282286
(ext: { name?: string }) => ext.name === "subjectAltName"
283287
) as { altNames?: Array<{ type: number; value: string }> } | undefined;
284-
const sanNames = (sanExtension?.altNames ?? [])
285-
.filter((entry) => entry.type === 2) // dNSName
286-
.map((entry) => entry.value);
288+
const altNames = sanExtension?.altNames ?? [];
289+
for (const entry of altNames) {
290+
if (entry.type !== 2) {
291+
// 2 = dNSName
292+
throw new NameError(
293+
`csr subjectAltName must contain only a dNSName entry for ${expectedDomain} (found an entry of type ${entry.type})`
294+
);
295+
}
296+
}
297+
const sanNames = altNames.map((entry) => entry.value);
287298

288299
const names = new Set<string>([...(cn ? [cn] : []), ...sanNames]);
289300

src/server.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { FakeAcmeClient } from "./test-support/fake-acme-client.js";
1515
import { InMemoryCertRateLimiter, InMemoryNameStore } from "./test-support/memory-stores.js";
1616
import { didKeySigner, type Signer } from "./test-support/signing.js";
1717

18-
function buildApp() {
18+
function buildApp(options: { nameUpdateRateLimitPerDay?: number } = {}) {
1919
const nameStore = new InMemoryNameStore();
2020
const dnsProvider = new InMemoryDnsProvider();
2121
const rateLimiter = new InMemoryCertRateLimiter();
@@ -33,6 +33,7 @@ function buildApp() {
3333
acmeIssuer,
3434
rateLimiter,
3535
certRateLimitPerDay: 2,
36+
nameUpdateRateLimitPerDay: options.nameUpdateRateLimitPerDay,
3637
});
3738
return { app, dnsProvider };
3839
}
@@ -109,6 +110,19 @@ test("same subject can update lanIps with a higher sequence", async () => {
109110
assert.deepEqual(dnsProvider.addressRecords.get(fqdnForName("office")), ["192.168.1.77"]);
110111
});
111112

113+
test("enforces the daily name update rate limit per name", async () => {
114+
const { app, dnsProvider } = buildApp({ nameUpdateRateLimitPerDay: 2 });
115+
const signer = didKeySigner(38);
116+
const first = await claim(app, "hammered", signer, ["192.168.1.19"], 1);
117+
const second = await claim(app, "hammered", signer, ["192.168.1.20"], 2);
118+
const third = await claim(app, "hammered", signer, ["192.168.1.21"], 3);
119+
assert.equal(first.status, 201);
120+
assert.equal(second.status, 200);
121+
assert.equal(third.status, 429);
122+
// The DNS provider was not touched by the rate-limited update.
123+
assert.deepEqual(dnsProvider.addressRecords.get(fqdnForName("hammered")), ["192.168.1.20"]);
124+
});
125+
112126
test("rejects a stale sequence update", async () => {
113127
const { app } = buildApp();
114128
const signer = didKeySigner(24);

src/server.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,19 @@ export interface ServerConfig {
2424
rateLimiter: CertRateLimiter;
2525
attestationDocument?: string;
2626
certRateLimitPerDay?: number;
27+
nameUpdateRateLimitPerDay?: number;
28+
}
29+
30+
// Prefixes name-update entries so they share the cert rate-limit store without
31+
// colliding with cert issuances (claimed names can never contain a colon).
32+
function nameUpdateRateLimitKey(name: string): string {
33+
return `name-update:${name}`;
2734
}
2835

2936
export function createServer(config: ServerConfig): Hono {
3037
const app = new Hono();
3138
const rateLimit = config.certRateLimitPerDay ?? 5;
39+
const nameUpdateRateLimit = config.nameUpdateRateLimitPerDay ?? 30;
3240

3341
app.get("/health", (c) => c.json({ ok: true }));
3442

@@ -80,6 +88,14 @@ export function createServer(config: ServerConfig): Hono {
8088
return c.json({ error: "invalid record signature" }, 401);
8189
}
8290

91+
const recentUpdates = await config.rateLimiter.countRecentIssuances(
92+
nameUpdateRateLimitKey(claim.name),
93+
DAY_MS
94+
);
95+
if (recentUpdates >= nameUpdateRateLimit) {
96+
return c.json({ error: "name update rate limit exceeded for this name" }, 429);
97+
}
98+
8399
const status = await config.nameStore.put({
84100
name: claim.name,
85101
subject: claim.subject,
@@ -92,6 +108,7 @@ export function createServer(config: ServerConfig): Hono {
92108
}
93109

94110
await config.dnsProvider.upsertAddressRecords(fqdnForName(claim.name), claim.lanIps);
111+
await config.rateLimiter.recordIssuance(nameUpdateRateLimitKey(claim.name));
95112

96113
return c.json(
97114
{ name: claim.name, subject: claim.subject, lanIps: claim.lanIps, status },

src/test-support/csr.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import forge from "node-forge";
22

3+
/** A SAN entry: a bare string becomes a dNSName; an object passes through to forge as-is. */
4+
export type TestAltName = string | { type: number; value?: string; ip?: string };
5+
36
/** Builds a real PKCS#10 CSR PEM for tests, without shelling out to openssl. */
4-
export function createTestCsr(commonName: string, altNames: string[] = [commonName]): string {
7+
export function createTestCsr(commonName: string, altNames: TestAltName[] = [commonName]): string {
58
const keys = forge.pki.rsa.generateKeyPair(2048);
69
const csr = forge.pki.createCertificationRequest();
710
csr.publicKey = keys.publicKey;
@@ -12,7 +15,9 @@ export function createTestCsr(commonName: string, altNames: string[] = [commonNa
1215
extensions: [
1316
{
1417
name: "subjectAltName",
15-
altNames: altNames.map((value) => ({ type: 2, value })),
18+
altNames: altNames.map((entry) =>
19+
typeof entry === "string" ? { type: 2, value: entry } : entry
20+
),
1621
},
1722
],
1823
},

0 commit comments

Comments
 (0)