diff --git a/cli/src/build-template.ts b/cli/src/build-template.ts
index 8ccebdf..980cc73 100644
--- a/cli/src/build-template.ts
+++ b/cli/src/build-template.ts
@@ -21,7 +21,8 @@ const TEMPLATE_CSP = [
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"object-src 'none'",
"base-uri 'self'",
- "frame-ancestors 'none'",
+ // Clickjacking: frame-ancestors and X-Frame-Options both require HTTP headers,
+ // not . Static templates must rely on the hosting server's headers config.
].join("; ")
function templateCspPlugin() {
diff --git a/package.json b/package.json
index 2ef1d9a..014cfae 100644
--- a/package.json
+++ b/package.json
@@ -97,6 +97,7 @@
"i18next-browser-languagedetector": "^8.2.1",
"idb": "^8.0.3",
"input-otp": "^1.4.2",
+ "ipaddr.js": "^2.4.0",
"libphonenumber-js": "^1.13.3",
"lucide-react": "^1.17.0",
"marked": "^18.0.4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 26eee95..1e6e93d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -203,6 +203,9 @@ importers:
input-otp:
specifier: ^1.4.2
version: 1.4.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ ipaddr.js:
+ specifier: ^2.4.0
+ version: 2.4.0
libphonenumber-js:
specifier: ^1.13.3
version: 1.13.3
@@ -3929,6 +3932,10 @@ packages:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
+ ipaddr.js@2.4.0:
+ resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==}
+ engines: {node: '>= 10'}
+
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -9896,6 +9903,8 @@ snapshots:
ipaddr.js@1.9.1: {}
+ ipaddr.js@2.4.0: {}
+
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.9
diff --git a/src/components/console/ConsoleFormDialog.tsx b/src/components/console/ConsoleFormDialog.tsx
index 442bb1b..0ec1746 100644
--- a/src/components/console/ConsoleFormDialog.tsx
+++ b/src/components/console/ConsoleFormDialog.tsx
@@ -16,10 +16,13 @@ interface Props {
resource: ConsoleResource
mode: "create" | "edit"
initialData?: Record | undefined
+ /** Path params (e.g. the id the detail page was loaded with) — fallback when the
+ * response body doesn't echo the id field. */
+ pathParams?: Record | undefined
onSuccess: () => void
}
-export function ConsoleFormDialog({ resource, mode, initialData, onSuccess }: Props) {
+export function ConsoleFormDialog({ resource, mode, initialData, pathParams, onSuccess }: Props) {
const { t } = useTranslation()
const { dispatch, activeLayout } = useConsoleContext()
const auth = useAuthContext()
@@ -41,10 +44,12 @@ export function ConsoleFormDialog({ resource, mode, initialData, onSuccess }: Pr
const handleSubmit = async () => {
if (!operation) return
const body = JSON.stringify(formData)
- const params: Record = {}
+ // Seed with the path params the page was loaded with; the response's own id
+ // (if present) takes precedence as it's the authoritative record id.
+ const params: Record = { ...(pathParams ?? {}) }
- if (mode === "edit" && resource.idParam && initialData) {
- const id = String(initialData[resource.idParam] ?? initialData["id"] ?? "")
+ if (mode === "edit" && resource.idParam) {
+ const id = String(initialData?.[resource.idParam] ?? initialData?.["id"] ?? pathParams?.[resource.idParam] ?? "")
if (id) params[resource.idParam] = id
}
diff --git a/src/components/console/templates/DetailCardTemplate.tsx b/src/components/console/templates/DetailCardTemplate.tsx
index 96c4cc5..a79bd45 100644
--- a/src/components/console/templates/DetailCardTemplate.tsx
+++ b/src/components/console/templates/DetailCardTemplate.tsx
@@ -115,7 +115,7 @@ export function DetailCardTemplate({ resource, layoutOverride }: TemplateProps)
{state.subView === "edit" && data && (
-
+
)}
)
diff --git a/src/hooks/use-settings.ts b/src/hooks/use-settings.ts
index 86efda3..bb9101f 100644
--- a/src/hooks/use-settings.ts
+++ b/src/hooks/use-settings.ts
@@ -1,6 +1,7 @@
import { useEffect, useRef } from "react"
import { useOpenAPIContext } from "@/contexts/OpenAPIContext"
import { authTypeValue, readLegacySettingsFromLocalStorage } from "@/lib/db"
+import { isPrivateOrLocalHost } from "@/lib/openapi/url-guard"
import type { AuthType } from "@/lib/openapi/types"
interface AuthSetters {
@@ -62,9 +63,21 @@ export function useSettings(
if (title) document.title = title
if (specUrl && autoLoad) {
- setTimeout(() => {
- autoLoad(specUrl, baseUrl ? { baseUrlOverride: baseUrl } : undefined)
- }, 0)
+ // A spec URL taken from the query string is attacker-controllable (share
+ // link) and auto-fetched without interaction — refuse internal/loopback
+ // hosts (SSRF). URLs the user typed or previously loaded (legacy) are exempt.
+ const fromQuery = !!paramSpecUrl && specUrl === paramSpecUrl
+ let blockedHost = false
+ if (fromQuery) {
+ try { blockedHost = isPrivateOrLocalHost(new URL(specUrl).hostname) } catch { blockedHost = true }
+ }
+ if (blockedHost) {
+ console.warn(`[apilot] refused to auto-load spec from an internal/loopback host: ${specUrl}`)
+ } else {
+ setTimeout(() => {
+ autoLoad(specUrl, baseUrl ? { baseUrlOverride: baseUrl } : undefined)
+ }, 0)
+ }
}
})()
diff --git a/src/lib/db.ts b/src/lib/db.ts
index bca6cf9..8bb7172 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -733,18 +733,19 @@ export async function addHistoryEntry(entry: Omit): Promise<
try {
const db = await getDB()
const originalHeaders = entry.response.requestHeaders
- const sanitized = { ...truncateBody(entry.response) }
+ // Redact BEFORE truncating — truncateBody turns valid JSON into broken text,
+ // which makes redactBody's JSON.parse fail and skip credential masking.
+ const sanitized = { ...entry.response }
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)
+ const truncated = truncateBody(sanitized)
await db.add("history", {
...entry,
requestBody: redactBody(entry.requestBody),
requestParams: redactParams(entry.requestParams),
- response: sanitized,
+ response: truncated,
})
} catch (err) {
if ((err as DOMException)?.name === "QuotaExceededError") {
@@ -872,9 +873,11 @@ export async function getEnvironmentCredential(envId: string): Promise {
- const db = await getDB()
- const profiles = await db.getAllFromIndex("environments", "specId", specId) as EnvironmentProfile[]
+ // Reuse getEnvironments so the createdAt ordering is preserved (the consumer uses
+ // [0] as the default environment when none is saved — index order ≠ creation order).
+ const profiles = await getEnvironments(specId)
if (profiles.length === 0) return []
+ const db = await getDB()
// Read all credentials within a single transaction instead of opening one DB
// connection per profile.
const tx = db.transaction("environmentCredentials", "readonly")
diff --git a/src/lib/openapi/generate-example.test.ts b/src/lib/openapi/generate-example.test.ts
index c64f07c..5f355bb 100644
--- a/src/lib/openapi/generate-example.test.ts
+++ b/src/lib/openapi/generate-example.test.ts
@@ -26,6 +26,16 @@ describe("generateExample circular handling", () => {
}
expect(generateExample(schema)).not.toBeNull()
})
+
+ it("does not prune a schema shared between sibling fields (non-circular reuse)", () => {
+ const shared: SchemaObject = { type: "object", properties: { value: { type: "string" } } }
+ const schema: SchemaObject = { type: "object", properties: { a: shared, b: shared } }
+ const ex = generateExample(schema) as Record | null
+ expect(ex).not.toBeNull()
+ // Both siblings must retain content; the second must not be emptied as a "cycle".
+ expect((ex?.a as Record | undefined)?.value).toBeDefined()
+ expect((ex?.b as Record | undefined)?.value).toBeDefined()
+ })
})
describe("generateWithVariant boundaries", () => {
diff --git a/src/lib/openapi/generate-example.ts b/src/lib/openapi/generate-example.ts
index c263ec0..78a7e65 100644
--- a/src/lib/openapi/generate-example.ts
+++ b/src/lib/openapi/generate-example.ts
@@ -277,15 +277,18 @@ export function generateWithVariant(rawSchema: SchemaObject, variantId: string):
// 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