History

feat: add HTTP caching for unauthenticated OpenAPI spec inspection

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.

Rhys Sullivan committed 8 months ago

Commit ff126951 parent 8adfbf21 file · +27 −16Browse files

Unified · Split

executor/apps/web/src/app/api/openapi/inspect/route.ts+27−16
@@ -24,11 +24,18 @@ const blockedForwardedHeaderNames = new Set([ · 23 unmodified lines
24 "referer",24 "referer",
25]);25]);
26 26
27function noStoreJson(payload: unknown, status: number): Response {27function 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
142export async function POST(request: Request): Promise<Response> {149export 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 }