Skip to content

Commit 986f0fc

Browse files
committed
fix: Subdomains
1 parent 6eef660 commit 986f0fc

1 file changed

Lines changed: 66 additions & 43 deletions

File tree

api/subdomains.js

Lines changed: 66 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,40 @@ import psl from 'psl';
22
import middleware from './_common/middleware.js';
33
import { httpGet } from './_common/http.js';
44
import { parseTarget } from './_common/parse-target.js';
5-
import { upstreamError } from './_common/upstream.js';
65

76
const MAX_SUBDOMAINS = 500;
7+
const SOURCE_TIMEOUT = 8000;
88
const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
99

10-
// Reduce a hostname to its registrable domain so we search the whole zone
1110
const baseDomain = (host) => psl.parse(host)?.domain || host;
12-
13-
// Skip raw IPs, since CT logs are indexed by hostname not address
1411
const isIpAddress = (host) => /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(':');
1512

16-
// Flatten crt.sh rows into a clean, deduped list of valid subdomains under the base
17-
const collectSubdomains = (rows, base) => {
18-
const suffix = `.${base}`;
19-
const out = new Set();
20-
for (const row of rows) {
21-
const raw = row?.name_value;
22-
if (typeof raw !== 'string') continue;
23-
for (const part of raw.split('\n')) {
24-
const name = part.trim().toLowerCase().replace(/^\*\./, '');
25-
if (!name || name === base) continue;
26-
if (!name.endsWith(suffix)) continue;
27-
if (!HOSTNAME_RE.test(name)) continue;
28-
out.add(name);
29-
}
30-
}
31-
return [...out].sort();
13+
const certSpotter = async (domain) => {
14+
const token = process.env.CERTSPOTTER_TOKEN;
15+
const res = await httpGet('https://api.certspotter.com/v1/issuances', {
16+
params: { domain, include_subdomains: 'true', expand: 'dns_names' },
17+
headers: { Accept: 'application/json', ...(token && { Authorization: `Bearer ${token}` }) },
18+
timeout: SOURCE_TIMEOUT,
19+
});
20+
if (!Array.isArray(res.data)) throw new Error('certSpotter returned an unexpected response');
21+
return res.data.flatMap((row) => (Array.isArray(row?.dns_names) ? row.dns_names : []));
3222
};
3323

24+
const crtSh = async (domain) => {
25+
const res = await httpGet('https://crt.sh/', {
26+
params: { q: `%.${domain}`, output: 'json' },
27+
headers: { Accept: 'application/json' },
28+
timeout: SOURCE_TIMEOUT,
29+
});
30+
if (!Array.isArray(res.data)) throw new Error('crt.sh returned an unexpected response');
31+
return res.data.flatMap((row) => String(row?.name_value ?? '').split('\n'));
32+
};
33+
34+
const SOURCES = [
35+
{ name: 'certSpotter', lookup: certSpotter },
36+
{ name: 'crt.sh', lookup: crtSh },
37+
];
38+
3439
const subdomainsHandler = async (url) => {
3540
const { hostname } = parseTarget(url);
3641
if (isIpAddress(hostname)) {
@@ -40,31 +45,49 @@ const subdomainsHandler = async (url) => {
4045
if (!domain || !domain.includes('.')) {
4146
return { skipped: 'Could not resolve a registrable domain' };
4247
}
43-
try {
44-
const res = await httpGet('https://crt.sh/', {
45-
params: { q: `%.${domain}`, output: 'json' },
46-
headers: { Accept: 'application/json' },
47-
});
48-
if (!Array.isArray(res.data)) {
49-
return { error: 'Certificate Transparency lookup returned unexpected data, please retry' };
50-
}
51-
const all = collectSubdomains(res.data, domain);
52-
if (!all.length) {
53-
return {
54-
skipped: `No subdomains found for ${domain} in Certificate Transparency logs`,
55-
retryable: true,
56-
};
48+
49+
const suffix = `.${domain}`;
50+
const sieve = (names) => [
51+
...new Set(
52+
names
53+
.filter((n) => typeof n === 'string')
54+
.map((n) => n.trim().toLowerCase().replace(/^\*\./, ''))
55+
.filter((n) => n && n !== domain && n.endsWith(suffix) && HOSTNAME_RE.test(n)),
56+
),
57+
].sort();
58+
59+
let tried = 0;
60+
let succeeded = 0;
61+
for (const source of SOURCES) {
62+
if (source.requires && !process.env[source.requires]) continue;
63+
tried += 1;
64+
try {
65+
const subdomains = sieve(await source.lookup(domain));
66+
succeeded += 1;
67+
if (subdomains.length) {
68+
return {
69+
domain,
70+
count: subdomains.length,
71+
truncated: subdomains.length > MAX_SUBDOMAINS,
72+
subdomains: subdomains.slice(0, MAX_SUBDOMAINS),
73+
source: source.name,
74+
};
75+
}
76+
} catch {
77+
continue;
5778
}
58-
return {
59-
domain,
60-
count: all.length,
61-
truncated: all.length > MAX_SUBDOMAINS,
62-
subdomains: all.slice(0, MAX_SUBDOMAINS),
63-
source: 'crt.sh',
64-
};
65-
} catch (error) {
66-
return upstreamError(error, 'Subdomain lookup');
6779
}
80+
81+
if (!tried) {
82+
return { skipped: 'No subdomain lookup source is configured' };
83+
}
84+
if (!succeeded) {
85+
return { error: 'Subdomain lookup failed across all sources, please retry', retryable: true };
86+
}
87+
return {
88+
skipped: `No subdomains found for ${domain} in Certificate Transparency logs`,
89+
retryable: true,
90+
};
6891
};
6992

7093
export const handler = middleware(subdomainsHandler);

0 commit comments

Comments
 (0)