Cache spec fetch responses when no custom headers are forwarded, using public s-maxage=3600 with stale-while-revalidate. Requests with auth headers bypass the cache to avoid leaking credentials.
Unified · Split
| @@ -24,11 +24,18 @@ const blockedForwardedHeaderNames = new Set([ · 23 unmodified lines | |||
| 24 | "referer", | 24 | "referer", |
| 25 | ]); | 25 | ]); |
| 26 | 26 | ||
| 27 | function noStoreJson(payload: unknown, status: number): Response { | 27 | function jsonResponse( |
| 28 | payload: unknown, | ||
| 29 | status: number, | ||
| 30 | options?: { cacheable?: boolean }, | ||
| 31 | ): Response { | ||
| 32 | const cacheable = options?.cacheable === true; | ||
| 28 | return Response.json(payload, { | 33 | return Response.json(payload, { |
| 29 | status, | 34 | status, |
| 30 | headers: { | 35 | headers: { |
| 31 | "cache-control": "no-store", | 36 | "cache-control": cacheable |
| 37 | ? "public, s-maxage=3600, stale-while-revalidate=86400" | ||
| 38 | : "no-store", | ||
| 32 | }, | 39 | }, |
| 33 | }); | 40 | }); |
| 34 | } | 41 | } |
| @@ -142,14 +149,14 @@ function extractErrorDetail(text: string): string | undefined { · 107 unmodified lines | |||
| 142 | export async function POST(request: Request): Promise<Response> { | 149 | export async function POST(request: Request): Promise<Response> { |
| 143 | const parsedRequest = requestSchema.safeParse(await request.json().catch(() => null)); | 150 | const parsedRequest = requestSchema.safeParse(await request.json().catch(() => null)); |
| 144 | if (!parsedRequest.success) { | 151 | if (!parsedRequest.success) { |
| 145 | return noStoreJson({ detail: "Invalid OpenAPI inspection request" }, 400); | 152 | return jsonResponse({ detail: "Invalid OpenAPI inspection request" }, 400); |
| 146 | } | 153 | } |
| 147 | 154 | ||
| 148 | let parsedUrl: URL; | 155 | let parsedUrl: URL; |
| 149 | try { | 156 | try { |
| 150 | parsedUrl = new URL(parsedRequest.data.specUrl); | 157 | parsedUrl = new URL(parsedRequest.data.specUrl); |
| 151 | } catch { | 158 | } catch { |
| 152 | return noStoreJson({ detail: "Spec URL is invalid" }, 400); | 159 | return jsonResponse({ detail: "Spec URL is invalid" }, 400); |
| 153 | } | 160 | } |
| 154 | 161 | ||
| 155 | const protocol = parsedUrl.protocol.toLowerCase(); | 162 | const protocol = parsedUrl.protocol.toLowerCase(); |
| @@ -157,25 +164,28 @@ export async function POST(request: Request): Promise<Response> { · 1 unmodified line | |||
| 157 | const localAllowed = process.env.NODE_ENV !== "production"; | 164 | const localAllowed = process.env.NODE_ENV !== "production"; |
| 158 | 165 | ||
| 159 | if (parsedUrl.username || parsedUrl.password) { | 166 | if (parsedUrl.username || parsedUrl.password) { |
| 160 | return noStoreJson({ detail: "Credentials in spec URL are not allowed" }, 400); | 167 | return jsonResponse({ detail: "Credentials in spec URL are not allowed" }, 400); |
| 161 | } | 168 | } |
| 162 | 169 | ||
| 163 | if (protocol !== "https:" && protocol !== "http:") { | 170 | if (protocol !== "https:" && protocol !== "http:") { |
| 164 | return noStoreJson({ detail: "Spec URL must use https:// (or http:// for localhost in dev)" }, 400); | 171 | return jsonResponse({ detail: "Spec URL must use https:// (or http:// for localhost in dev)" }, 400); |
| 165 | } | 172 | } |
| 166 | 173 | ||
| 167 | if (protocol === "http:" && !(localAllowed && isLocalHost(hostname))) { | 174 | if (protocol === "http:" && !(localAllowed && isLocalHost(hostname))) { |
| 168 | return noStoreJson({ detail: "Spec URL must use https://" }, 400); | 175 | return jsonResponse({ detail: "Spec URL must use https://" }, 400); |
| 169 | } | 176 | } |
| 170 | 177 | ||
| 171 | if (isPrivateIp(hostname) && !(localAllowed && isLocalHost(hostname))) { | 178 | if (isPrivateIp(hostname) && !(localAllowed && isLocalHost(hostname))) { |
| 172 | return noStoreJson({ detail: "Private or local hosts are not allowed" }, 400); | 179 | return jsonResponse({ detail: "Private or local hosts are not allowed" }, 400); |
| 173 | } | 180 | } |
| 174 | 181 | ||
| 175 | if (!isIP(normalizeIpHost(hostname)) && !isPublicDnsHostname(hostname)) { | 182 | if (!isIP(normalizeIpHost(hostname)) && !isPublicDnsHostname(hostname)) { |
| 176 | return noStoreJson({ detail: "Spec host must be a public DNS host" }, 400); | 183 | return jsonResponse({ detail: "Spec host must be a public DNS host" }, 400); |
| 177 | } | 184 | } |
| 178 | 185 | ||
| 186 | const forwardedHeaders = sanitizeForwardHeaders(parsedRequest.data.headers); | ||
| 187 | const cacheableRequest = Object.keys(forwardedHeaders).length === 0; | ||
| 188 | |||
| 179 | const controller = new AbortController(); | 189 | const controller = new AbortController(); |
| 180 | const timeoutId = setTimeout(() => controller.abort(), INSPECTION_TIMEOUT_MS); | 190 | const timeoutId = setTimeout(() => controller.abort(), INSPECTION_TIMEOUT_MS); |
| 181 | 191 | ||
| @@ -184,15 +194,16 @@ export async function POST(request: Request): Promise<Response> { · 2 unmodified lines | |||
| 184 | method: "GET", | 194 | method: "GET", |
| 185 | headers: { | 195 | headers: { |
| 186 | Accept: "application/json, application/yaml, text/yaml, text/plain;q=0.9, */*;q=0.8", | 196 | Accept: "application/json, application/yaml, text/yaml, text/plain;q=0.9, */*;q=0.8", |
| 187 | ...sanitizeForwardHeaders(parsedRequest.data.headers), | 197 | ...forwardedHeaders, |
| 188 | }, | 198 | }, |
| 189 | signal: controller.signal, | 199 | signal: controller.signal, |
| 190 | cache: "no-store", | 200 | cache: cacheableRequest ? "force-cache" : "no-store", |
| 201 | ...(cacheableRequest ? { next: { revalidate: 3600 } } : {}), | ||
| 191 | }); | 202 | }); |
| 192 | 203 | ||
| 193 | if (!response.ok) { | 204 | if (!response.ok) { |
| 194 | const detail = extractErrorDetail(await response.text().catch(() => "")); | 205 | const detail = extractErrorDetail(await response.text().catch(() => "")); |
| 195 | return noStoreJson({ | 206 | return jsonResponse({ |
| 196 | status: response.status, | 207 | status: response.status, |
| 197 | statusText: response.statusText, | 208 | statusText: response.statusText, |
| 198 | detail, | 209 | detail, |
| @@ -207,19 +218,19 @@ export async function POST(request: Request): Promise<Response> { · 8 unmodified lines | |||
| 207 | contentType, | 218 | contentType, |
| 208 | }); | 219 | }); |
| 209 | 220 | ||
| 210 | return noStoreJson({ | 221 | return jsonResponse({ |
| 211 | status: response.status, | 222 | status: response.status, |
| 212 | statusText: response.statusText, | 223 | statusText: response.statusText, |
| 213 | spec: inspected.spec, | 224 | spec: inspected.spec, |
| 214 | inferredAuth: inspected.inferredAuth, | 225 | inferredAuth: inspected.inferredAuth, |
| 215 | }, 200); | 226 | }, 200, { cacheable: cacheableRequest }); |
| 216 | } catch (error) { | 227 | } catch (error) { |
| 217 | if (isAbortError(error)) { | 228 | if (isAbortError(error)) { |
| 218 | return noStoreJson({ detail: "Request timed out while fetching spec" }, 504); | 229 | return jsonResponse({ detail: "Request timed out while fetching spec" }, 504); |
| 219 | } | 230 | } |
| 220 | 231 | ||
| 221 | const detail = error instanceof Error ? error.message : "Failed to fetch spec"; | 232 | const detail = error instanceof Error ? error.message : "Failed to fetch spec"; |
| 222 | return noStoreJson({ detail }, 502); | 233 | return jsonResponse({ detail }, 502); |
| 223 | } finally { | 234 | } finally { |
| 224 | clearTimeout(timeoutId); | 235 | clearTimeout(timeoutId); |
| 225 | } | 236 | } |