Skip to content

Commit 055ddec

Browse files
authored
Merge pull request #724 from aniokedianne/feat/close-653-671-672-675
feat(routes-f): add domain validator with idn+tld checks
2 parents c1d7466 + dbe3c80 commit 055ddec

4 files changed

Lines changed: 195 additions & 0 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
jest.mock("next/server", () => {
2+
const actual = jest.requireActual("next/server");
3+
return {
4+
...actual,
5+
NextResponse: {
6+
...actual.NextResponse,
7+
json: (body: unknown, init?: ResponseInit) =>
8+
new Response(JSON.stringify(body), {
9+
status: init?.status ?? 200,
10+
headers: { "Content-Type": "application/json" },
11+
}),
12+
},
13+
};
14+
});
15+
16+
import { POST } from "../route";
17+
import { validateDomain } from "../_lib/validate";
18+
19+
function makePost(body: object): Request {
20+
return new Request("http://localhost/api/routes-f/domain-validate", {
21+
method: "POST",
22+
headers: { "Content-Type": "application/json" },
23+
body: JSON.stringify(body),
24+
});
25+
}
26+
27+
describe("validateDomain()", () => {
28+
it("validates a standard domain and parses parts", () => {
29+
const result = validateDomain("blog.example.com");
30+
expect(result.valid).toBe(true);
31+
expect(result.normalized).toBe("blog.example.com");
32+
expect(result.parts).toEqual({
33+
subdomain: "blog",
34+
sld: "example",
35+
tld: "com",
36+
});
37+
expect(result.is_known_tld).toBe(true);
38+
expect(result.is_idn).toBe(false);
39+
});
40+
41+
it("normalizes IDN and detects punycode usage", () => {
42+
const result = validateDomain("bücher.de");
43+
expect(result.valid).toBe(true);
44+
expect(result.normalized).toBe("xn--bcher-kva.de");
45+
expect(result.is_idn).toBe(true);
46+
expect(result.tld).toBe("de");
47+
});
48+
49+
it("returns valid true with unknown tld", () => {
50+
const result = validateDomain("example.unknownxyz");
51+
expect(result.valid).toBe(true);
52+
expect(result.is_known_tld).toBe(false);
53+
expect(result.tld).toBe("unknownxyz");
54+
});
55+
56+
it("rejects invalid syntax", () => {
57+
expect(validateDomain("-bad.com").valid).toBe(false);
58+
expect(validateDomain("bad..com").valid).toBe(false);
59+
expect(validateDomain("bad-.com").valid).toBe(false);
60+
expect(validateDomain("localhost").valid).toBe(false);
61+
});
62+
});
63+
64+
describe("POST /api/routes-f/domain-validate", () => {
65+
it("returns parsed domain details", async () => {
66+
const res = await POST(makePost({ domain: "Shop.Example.IO" }) as never);
67+
expect(res.status).toBe(200);
68+
const data = await res.json();
69+
expect(data).toMatchObject({
70+
valid: true,
71+
normalized: "shop.example.io",
72+
tld: "io",
73+
is_known_tld: true,
74+
is_idn: false,
75+
});
76+
});
77+
78+
it("rejects protocol-prefixed input", async () => {
79+
const res = await POST(makePost({ domain: "https://example.com" }) as never);
80+
expect(res.status).toBe(400);
81+
});
82+
83+
it("rejects ip input", async () => {
84+
const res = await POST(makePost({ domain: "127.0.0.1" }) as never);
85+
expect(res.status).toBe(400);
86+
});
87+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const KNOWN_TLDS = new Set<string>([
2+
"academy","accountant","accountants","actor","adult","ae","agency","ai","airforce","am","app","art","asia","at","au","auction","autos","band","bar","bargains","beauty","beer","berlin","best","bet","bid","bike","bio","biz","blog","blue","boo","boutique","build","builders","business","buzz","bz","ca","cab","cafe","camera","camp","capital","cards","care","careers","cars","cash","casino","cat","cc","center","ceo","chat","cheap","church","city","claims","cleaning","click","clinic","clothing","cloud","club","co","coach","codes","coffee","college","com","community","company","computer","condos","consulting","contact","contractors","cool","country","coupons","courses","credit","creditcard","cricket","cruises","cx","cyou","cz","dance","date","dating","de","deals","delivery","democrat","dental","design","dev","digital","direct","directory","discount","doctor","dog","domains","download","earth","edu","education","email","energy","engineer","engineering","enterprises","equipment","es","estate","eu","events","exchange","expert","exposed","express","fail","faith","family","fans","farm","fashion","finance","financial","fish","fishing","fit","fitness","flights","florist","fm","foo","football","forsale","foundation","fr","fun","fund","furniture","futbol","fyi","gallery","game","games","garden","gay","gifts","gives","glass","global","gold","golf","graphics","gratis","green","group","guide","guru","haus","health","healthcare","help","hiphop","hockey","holdings","holiday","homes","host","hosting","house","how","icu","id","ie","im","in","inc","industries","info","ink","institute","insure","international","investments","io","irish","it","jetzt","jewelry","jobs","jp","ke","kim","kitchen","land","law","lawyer","lease","legal","life","lighting","limited","limo","link","live","llc","loan","loans","lol","london","love","ltd","maison","management","market","marketing","mba","media","memorial","meme","me","mobi","moda","moe","money","monster","mortgage","motorcycles","mov","movie","mx","name","navy","net","network","news","nexus","ninja","no","now","nyc","observer","one","online","ooo","org","page","partners","parts","party","pe","pet","pharmacy","photos","pics","pictures","pink","pizza","place","plumbing","plus","pm","poker","porn","press","pro","productions","promo","properties","property","protection","pub","pw","qa","quest","racing","radio","realty","recipes","red","rehab","reise","reisen","rent","rentals","repair","report","republican","rest","restaurant","review","reviews","rip","rocks","rodeo","run","sale","salon","school","science","security","services","shop","shopping","show","singles","site","soccer","social","software","solar","solutions","space","store","stream","studio","style","supply","support","surf","surgery","systems","tax","taxi","team","tech","technology","tel","tennis","theater","theatre","tires","today","tools","top","tours","town","toys","trade","training","travel","tube","tv","uk","university","uno","us","vacations","vegas","ventures","vet","viajes","video","villas","vin","vip","vision","vlog","vodka","vote","voyage","watch","webcam","website","wiki","win","wine","work","works","world","ws","wtf","xyz","yoga","zone",
3+
]);
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { toASCII } from "punycode/";
2+
import { KNOWN_TLDS } from "./tlds";
3+
4+
export type DomainParts = {
5+
subdomain?: string;
6+
sld: string;
7+
tld: string;
8+
};
9+
10+
export type DomainValidationResult = {
11+
valid: boolean;
12+
normalized: string;
13+
tld: string | null;
14+
is_known_tld: boolean;
15+
is_idn: boolean;
16+
parts: DomainParts | null;
17+
};
18+
19+
const IP_V4_RE = /^(?:\d{1,3}\.){3}\d{1,3}$/;
20+
const PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
21+
const LABEL_RE = /^[a-z0-9-]+$/i;
22+
23+
export function validateDomain(input: string): DomainValidationResult {
24+
const trimmed = input.trim();
25+
if (!trimmed || PROTOCOL_RE.test(trimmed)) {
26+
return invalid("");
27+
}
28+
29+
if (trimmed.endsWith(".")) {
30+
return invalid("");
31+
}
32+
33+
let ascii: string;
34+
try {
35+
ascii = toASCII(trimmed).toLowerCase();
36+
} catch {
37+
return invalid("");
38+
}
39+
40+
if (!ascii || ascii.length > 253 || IP_V4_RE.test(ascii) || ascii.includes(":")) {
41+
return invalid(ascii);
42+
}
43+
44+
const labels = ascii.split(".");
45+
if (labels.length < 2) {
46+
return invalid(ascii);
47+
}
48+
49+
for (const label of labels) {
50+
if (!label || label.length > 63 || !LABEL_RE.test(label)) {
51+
return invalid(ascii);
52+
}
53+
if (label.startsWith("-") || label.endsWith("-")) {
54+
return invalid(ascii);
55+
}
56+
}
57+
58+
const tld = labels[labels.length - 1];
59+
const sld = labels[labels.length - 2];
60+
const subdomain = labels.length > 2 ? labels.slice(0, -2).join(".") : undefined;
61+
const isIdn = /[^\x00-\x7f]/.test(trimmed) || ascii.includes("xn--");
62+
const isKnown = KNOWN_TLDS.has(tld);
63+
64+
return {
65+
valid: true,
66+
normalized: ascii,
67+
tld,
68+
is_known_tld: isKnown,
69+
is_idn: isIdn,
70+
parts: { subdomain, sld, tld },
71+
};
72+
}
73+
74+
function invalid(normalized: string): DomainValidationResult {
75+
return {
76+
valid: false,
77+
normalized,
78+
tld: null,
79+
is_known_tld: false,
80+
is_idn: false,
81+
parts: null,
82+
};
83+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { validateDomain } from "./_lib/validate";
3+
4+
export async function POST(req: NextRequest) {
5+
let body: { domain?: unknown };
6+
try {
7+
body = await req.json();
8+
} catch {
9+
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
10+
}
11+
12+
if (typeof body?.domain !== "string") {
13+
return NextResponse.json({ error: "'domain' is required and must be a string" }, { status: 400 });
14+
}
15+
16+
const raw = body.domain.trim();
17+
if (!raw || /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || /^(?:\d{1,3}\.){3}\d{1,3}$/.test(raw) || raw.includes(":")) {
18+
return NextResponse.json({ error: "Invalid domain input" }, { status: 400 });
19+
}
20+
21+
return NextResponse.json(validateDomain(raw));
22+
}

0 commit comments

Comments
 (0)