Skip to content

Commit 413c408

Browse files
committed
Harden AIA proxy and expand FPKI domain allow list
Security: block query strings, credentials, non-standard ports, and fragments in proxied URLs. Validate redirect targets against the domain allow list to prevent open redirect abuse. Reconstruct URLs from validated components instead of forwarding raw input. Add 29 domains covering US Government PKI (.gov/.mil), FPKI Shared Service Providers (Entrust, WidePoint, DigiCert Federal, IdenTrust), FPKI Bridge participants (CertiPath, defense contractors, DirectTrust), and Bavarian State PKI.
1 parent 5ef6646 commit 413c408

2 files changed

Lines changed: 136 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.7.2] - 2026-02-15
11+
12+
### Security
13+
14+
- Harden AIA proxy: block query strings, URL credentials, non-standard ports, and fragments in proxied URLs
15+
- Harden AIA proxy: validate redirect targets against domain allow list to prevent open redirect abuse
16+
- Harden AIA proxy: reconstruct URLs from validated components instead of forwarding raw input
17+
18+
### Added
19+
20+
- Add US Government PKI domains to AIA proxy allow list: DoD/DISA, Treasury SSP, State Department, USPTO, Veterans Affairs
21+
- Add FPKI Shared Service Provider domains to AIA proxy allow list: Entrust Federal, WidePoint/ORC, DigiCert Federal SSP, DigiCert/Symantec legacy, IdenTrust
22+
- Add FPKI Bridge participant domains to AIA proxy allow list: CertiPath, Boeing, Lockheed Martin, Northrop Grumman, Raytheon/RTX, Exostar, Carillon, STRAC/FTI, DirectTrust SAFE, Verizon SSP, DocuSign Federal
23+
- Add Bavarian State PKI (`www.pki.bayern.de`) to AIA proxy allow list
24+
1025
## [0.7.1] - 2026-02-15
1126

1227
### Changed
@@ -306,7 +321,8 @@ Initial release.
306321
- PKCS#12, PKCS#7, and JKS encode/decode support
307322
- Homebrew distribution via GoReleaser
308323

309-
[Unreleased]: https://github.com/sensiblebit/certkit/compare/v0.7.1...HEAD
324+
[Unreleased]: https://github.com/sensiblebit/certkit/compare/v0.7.2...HEAD
325+
[0.7.2]: https://github.com/sensiblebit/certkit/compare/v0.7.1...v0.7.2
310326
[0.7.1]: https://github.com/sensiblebit/certkit/compare/v0.7.0...v0.7.1
311327
[0.7.0]: https://github.com/sensiblebit/certkit/compare/v0.6.7...v0.7.0
312328
[0.6.7]: https://github.com/sensiblebit/certkit/compare/v0.6.0...v0.6.7

web/functions/api/fetch.ts

Lines changed: 119 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,69 @@ const ALLOWED_DOMAINS: string[] = [
7373
"rapidssl-aia.geotrust.com",
7474
// E-Tugra (Turkish CA)
7575
"www.e-tugra.com",
76-
// US Federal PKI
76+
// ── US Federal PKI (.gov / .mil) ──
77+
// FPKI repository (GSA)
7778
"repo.fpki.gov",
79+
"http.fpki.gov",
80+
// US DoD PKI (DISA) — suffix covers crl.disa.mil, crl.nit.disa.mil,
81+
// crl.gds.disa.mil, crl.gds.nit.disa.mil, and future subdomains.
82+
"disa.mil",
83+
// US Treasury PKI SSP (serves DHS, VA, NASA, SSA, Treasury OCIO)
84+
"pki.treas.gov",
85+
"pki.treasury.gov",
86+
// US Department of State
87+
"crls.pki.state.gov",
88+
// US Patent and Trademark Office
89+
"ipki.uspto.gov",
90+
// US Department of Veterans Affairs
91+
"crl.pki.va.gov",
92+
// ── FPKI Shared Service Providers ──
93+
// Entrust Federal SSP — suffix covers sspweb, rootweb, nfimediumsspweb,
94+
// fedrootg2crl, feddcsweb, hhspkicrl subdomains.
95+
"managed.entrust.com",
96+
// WidePoint / ORC PKI
97+
"crl-server.orc.com",
98+
"eva.orc.com",
99+
"eca.orc.com",
100+
"crl.xca.xpki.com",
101+
// DigiCert Federal SSP
102+
"ssp-aia.digicert.com",
103+
"ssp-crl.digicert.com",
104+
"ssp-sia.digicert.com",
105+
"onsite-crl.pki.digicert.com",
106+
// DigiCert / Symantec legacy — suffix covers pki-crl, tscp-crl,
107+
// tscp-aia, tscp-sia subdomains.
108+
"symauth.com",
109+
// IdenTrust (FPKI bridge participant)
110+
"apps.identrust.com",
111+
"validation.identrust.com",
112+
// ── FPKI Bridge Participants ──
113+
// CertiPath Bridge — suffix covers crl. and aia. subdomains.
114+
"certipath.com",
115+
// Defense contractors
116+
"crl.boeing.com",
117+
"crl.external.lmco.com",
118+
"certdata.northropgrumman.com",
119+
"pki.rtx.com",
120+
// Exostar
121+
"www.fis.evincible.com",
122+
// Carillon (Canadian FPKI bridge partner)
123+
"pub.carillon.ca",
124+
"pub.carillonfedserv.com",
125+
// STRAC / Foundation for Trusted Identity
126+
"pki.strac.org",
127+
"pki.fti.org",
128+
// DirectTrust SAFE Identity Bridge — suffix covers crl. and aia.
129+
"makeidentitysafe.com",
130+
// Verizon SSP
131+
"sia1.ssp-strong-id.net",
132+
// DocuSign Federal
133+
"crl.dsf.docusign.net",
134+
// ── Non-US Government PKI ──
78135
// Swiss Federal PKI
79136
"www.pki.admin.ch",
137+
// Bavarian State PKI
138+
"www.pki.bayern.de",
80139
// TBS Internet
81140
"crt.tbs-internet.com",
82141
"crt.tbs-x509.com",
@@ -89,6 +148,44 @@ function isAllowedDomain(hostname: string): boolean {
89148
);
90149
}
91150

151+
// Follows redirects manually, re-validating each target against the domain
152+
// allow list. Prevents open redirects on allowed domains from bouncing to
153+
// arbitrary URLs.
154+
const MAX_REDIRECTS = 5;
155+
156+
async function safeFetch(url: string): Promise<Response> {
157+
let currentURL = url;
158+
for (let i = 0; i <= MAX_REDIRECTS; i++) {
159+
const resp = await fetch(currentURL, {
160+
headers: { "User-Agent": "certkit AIA proxy/1.0" },
161+
redirect: "manual",
162+
});
163+
164+
// Not a redirect — return as-is.
165+
if (resp.status < 300 || resp.status >= 400) {
166+
return resp;
167+
}
168+
169+
const location = resp.headers.get("Location");
170+
if (!location) {
171+
return resp;
172+
}
173+
174+
const target = new URL(location, currentURL);
175+
if (target.protocol !== "https:" && target.protocol !== "http:") {
176+
throw new Error("Redirect to non-HTTP protocol");
177+
}
178+
if (!isAllowedDomain(target.hostname)) {
179+
throw new Error(`Redirect to disallowed domain '${target.hostname}'`);
180+
}
181+
182+
// Sanitize redirect URL — only keep protocol, host, and path.
183+
currentURL = `${target.protocol}//${target.hostname}${target.pathname}`;
184+
}
185+
186+
throw new Error("Too many redirects");
187+
}
188+
92189
export const onRequestOptions: PagesFunction = async ({ request }) => {
93190
const origin = request.headers.get("Origin");
94191
return new Response(null, { status: 204, headers: corsHeaders(origin) });
@@ -125,10 +222,25 @@ export const onRequestGet: PagesFunction = async ({ request }) => {
125222
return errorResponse(400, "Invalid URL", origin);
126223
}
127224

225+
// Block credentials in URL
226+
if (parsed.username || parsed.password) {
227+
return errorResponse(400, "URLs with credentials are not allowed", origin);
228+
}
229+
230+
// Block non-standard ports — CA AIA endpoints use default ports only
231+
if (parsed.port) {
232+
return errorResponse(400, "Non-standard ports are not allowed", origin);
233+
}
234+
128235
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
129236
return errorResponse(400, "Only HTTP/HTTPS URLs are allowed", origin);
130237
}
131238

239+
// Block query strings and fragments — AIA URLs are static file paths
240+
if (parsed.search || parsed.hash) {
241+
return errorResponse(400, "Query strings and fragments are not allowed", origin);
242+
}
243+
132244
if (!isAllowedDomain(parsed.hostname)) {
133245
return errorResponse(
134246
403,
@@ -153,22 +265,22 @@ export const onRequestGet: PagesFunction = async ({ request }) => {
153265
return errorResponse(403, "URL path does not look like a certificate file", origin);
154266
}
155267

268+
// Reconstruct from validated components — never forward the raw input URL.
269+
const sanitizedURL = `${parsed.protocol}//${parsed.hostname}${parsed.pathname}`;
270+
156271
// Try the URL as given. If HTTPS fails with an SSL error, fall back to HTTP
157272
// (many CA AIA endpoints only serve plain HTTP).
158-
const urlsToTry = [targetURL];
273+
const urlsToTry = [sanitizedURL];
159274
if (parsed.protocol === "https:") {
160-
urlsToTry.push(targetURL.replace(/^https:/, "http:"));
275+
urlsToTry.push(`http://${parsed.hostname}${parsed.pathname}`);
161276
}
162277

163278
let lastStatus = 502;
164279
let lastMessage = "All fetch attempts failed";
165280

166281
for (const tryURL of urlsToTry) {
167282
try {
168-
const upstream = await fetch(tryURL, {
169-
headers: { "User-Agent": "certkit AIA proxy/1.0" },
170-
redirect: "follow",
171-
});
283+
const upstream = await safeFetch(tryURL);
172284

173285
if (!upstream.ok) {
174286
lastStatus = upstream.status;

0 commit comments

Comments
 (0)