@@ -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+
92189export 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 ( / ^ h t t p s : / , " 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