Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b10cd71
[Bug] Enforce private-skill visibility on playground skill-load (BOLA)
chronoai-shining Jun 4, 2026
24adcea
[Bug] Reject path-traversal skill names on the skip_validation import…
chronoai-shining Jun 4, 2026
2111788
[Bug] Atomically reserve quota to prevent concurrent over-cap (TOCTOU)
chronoai-shining Jun 4, 2026
4ecbb0c
[Bug] Add a per-user rate limit to POST /playground/chat
chronoai-shining Jun 4, 2026
7056a91
[Bug] Bound pagination page to stop unbounded MongoDB skip()
chronoai-shining Jun 4, 2026
94bdd35
[Bug] Re-resolve outbound hosts at fetch time for SSRF (DNS-rebind) p…
chronoai-shining Jun 4, 2026
75916db
[Bug] Skip idempotency capture for SSE/streaming responses
chronoai-shining Jun 4, 2026
2f71b81
[Bug] Key anonymous rate limits on a trusted client IP, not raw XFF
chronoai-shining Jun 4, 2026
a893541
[Misc] Back the rate limiter with a shared store (per-pod/restart-res…
chronoai-shining Jun 4, 2026
5f3ce51
[Bug] Enforce caller org membership when sharing a skill into an org
chronoai-shining Jun 4, 2026
866d809
[Feature] Full-bleed autoplay video background as the landing hero
chronoai-shining Jun 4, 2026
d5dc629
[Misc] Redact token/clientSecret/privateKey/accessToken in Pino logs
chronoai-shining Jun 4, 2026
da8d209
[Bug] Reject traversal/dot segments in GitHub owner/repo identifiers …
chronoai-shining Jun 4, 2026
dfe3bd9
[Bug] Clamp playground sandbox timeout_secs to the advertised 1-600 r…
chronoai-shining Jun 4, 2026
dc10df1
[Misc] Remove the unused requireOwnerOrAdmin authz helper
chronoai-shining Jun 4, 2026
91aec31
[Docs] Correct the stale ENCRYPTION_KEY dev-sentinel claim in config doc
chronoai-shining Jun 4, 2026
137df13
[Misc] Harden playground actor plumbing (follow-up to #806)
chronoai-shining Jun 4, 2026
0cf8f26
[Misc] Quota reservation follow-ups (follow-up to #808)
chronoai-shining Jun 4, 2026
fec8aed
[Bug] Re-validate redirect hops in safeFetch to close the SSRF redire…
chronoai-shining Jun 4, 2026
595b883
[Bug] setSkillPermissions returns spurious 403 when org memberships a…
chronoai-shining Jun 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/auto-806-playground-skill-bola.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": patch
---

Fix an authorization gap where the playground could load a private skill's full contents without checking the caller's read access. `getSkillJson` now requires a caller actor and enforces `canReadSkill` for both the `skillId` and `load_skill` paths, so a private skill is only readable by its owner, users/orgs it is shared with, or a platform admin.
5 changes: 5 additions & 0 deletions .changeset/auto-807-skill-name-traversal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": patch
---

Reject path-traversal skill names on the `skip_validation` import path. The lenient frontmatter extractor now enforces the same kebab-case name rule as the strict path, and the GitHub-mirror folder builder refuses unsafe names, preventing a crafted skill name from writing outside its own folder in the public mirror.
5 changes: 5 additions & 0 deletions .changeset/auto-808-quota-toctou.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": patch
---

Close a quota time-of-check/time-of-use race: the per-user/surface quota is now reserved atomically at check time (a conditional increment guarded by the cap) instead of being read first and charged after the LLM call, so concurrent requests can no longer exceed the cap. Failed or aborted calls release the reservation.
5 changes: 5 additions & 0 deletions .changeset/auto-809-playground-chat-rate-limit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": patch
---

Add per-user rate limit (20/min) to POST /playground/chat (#809).
5 changes: 5 additions & 0 deletions .changeset/auto-810-bound-skip-dos.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": patch
---

Bound unbounded MongoDB skip() pagination (CWE-770): page ceiling + maxTimeMS on public skill queries (#810).
5 changes: 5 additions & 0 deletions .changeset/auto-811-ssrf-dns-rebind-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": patch
---

Close DNS-rebind SSRF gap: route chrono-storage, chrono-sandbox, and all NyxID/LLM-gateway outbound requests through a shared fetch-time assertPublicResolvedAddress preflight (#811).
5 changes: 5 additions & 0 deletions .changeset/auto-812-idempotency-sse-passthrough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": patch
---

Idempotency middleware skips capture for streaming (text/event-stream) responses so SSE streams are delivered unbuffered and never persisted (#812).
5 changes: 5 additions & 0 deletions .changeset/auto-813-rate-limit-xff-trusted-hop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": patch
---

Rate-limit keys anonymous traffic on a trusted-position X-Forwarded-For hop (configurable via ORNN_TRUSTED_PROXY_HOPS), not the spoofable leftmost token (#813, CWE-348).
5 changes: 5 additions & 0 deletions .changeset/auto-814-rate-limit-single-replica-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": patch
---

Harden the rate limiter's single-replica by-design contract (code + deployment guard) and pin per-pod isolation under test; shared-store backing for multi-replica is tracked in #837 (#814).
5 changes: 5 additions & 0 deletions .changeset/auto-815-share-non-member-org.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": patch
---

Fix CWE-862 (#815): PUT /skills/:id/permissions rejects sharing a skill into an org the caller is not a member of (403 not_org_member); platform admins exempt.
5 changes: 5 additions & 0 deletions .changeset/auto-817-pino-redact-secrets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": patch
---

Harden log redaction: token, accessToken, userAccessToken, clientSecret and privateKey are now censored in all Pino logger roots (shared logger, bootstrap, entrypoint), sourced from a single exported REDACT_PATHS constant.
5 changes: 5 additions & 0 deletions .changeset/auto-818-github-identifier-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": patch
---

Harden GitHub identifier validation: mirror settings owner/repo now enforce the same naming rules as the mirror routes (shared constants), and repo pull identifiers reject "." / ".." path-traversal segments.
5 changes: 5 additions & 0 deletions .changeset/auto-819-clamp-sandbox-timeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": patch
---

Clamp the playground sandbox timeout_secs to the advertised 1-600 range and default non-numeric values to 60s.
5 changes: 5 additions & 0 deletions .changeset/auto-820-remove-dead-authz-helper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": patch
---

Remove the unused requireOwnerOrAdmin authz middleware (dead code; the live skill-ownership policy is canManageSkill).
5 changes: 5 additions & 0 deletions .changeset/auto-821-encryption-key-jsdoc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": patch
---

Correct the SkillConfig.encryptionKey JSDoc to match the enforced contract (mandatory ≥32 chars, no dev fallback, fail-fast at boot) and add tests pinning loadConfig() ConfigError behavior.
5 changes: 5 additions & 0 deletions .changeset/auto-826-actor-plumbing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": patch
---

Require an explicit authorization actor on the playground chat path (drop the SYSTEM_ACTOR fallback) and de-duplicate the route-level actor builds behind a single buildActorContext helper so they cannot drift.
5 changes: 5 additions & 0 deletions .changeset/auto-827-quota-reservation-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": patch
---

Thread the quota reservation timestamp through to charge-on-completion so per-model analytics reconcile against the reserved month bucket (fixes a benign month-boundary straddle). Add route-level integration tests covering model-resolution failure (used unchanged) and aborted/errored streams (slot released). Note for consumers: /me/quota remaining reflects in-flight reservations — used is bumped at reserve time and refunded on system-error/abort.
5 changes: 5 additions & 0 deletions .changeset/auto-832-safefetch-redirect-ssrf.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": patch
---

Close an SSRF redirect-hop bypass: safeFetch now follows redirects via a bounded manual loop that re-validates each hop's host against the public-address guard and strips cross-host credentials, instead of blindly following 3xx to unvalidated targets.
5 changes: 5 additions & 0 deletions .changeset/auto-842-org-membership-unresolved.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": patch
---

setSkillPermissions now distinguishes an unresolved org-membership read (forwarded token absent or NyxID unreachable) from a confirmed non-membership: sharing a skill into an org while memberships are unresolved returns a retryable 503 org_membership_unavailable instead of a misleading 403 not_org_member. Confirmed non-members still get 403. Read-path visibility is unchanged (still fail-soft).
5 changes: 5 additions & 0 deletions .changeset/landing-video-hero.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-web": minor
---

Replace the scroll-scrub landing hero with a full-bleed, autoplaying, muted, looping background intro video (static poster under reduced-motion).
1 change: 1 addition & 0 deletions deployment/.env.sample.ornn
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ MAX_PACKAGE_SIZE_BYTES=<max-package-size>
ALLOWED_ORIGINS=<comma-separated-origins, e.g. https://app.ornn.xyz,http://localhost:5173>
ORNN_PUBLIC_ORIGIN=<e.g. https://ornn.chrono-ai.fun>
ORNN_URL_ALLOWLIST_CIDR=<comma-separated, e.g. nyxid-backend,mongodb,10.0.0.0/8,127.0.0.0/8>
ORNN_TRUSTED_PROXY_HOPS=<trusted appending proxies in front of ornn-api beyond the immediate peer; default 0 = key the connecting peer's appended IP>
AGENTSEAL_PYTHON=/opt/agentseal/bin/python
AGENTSEAL_SCRIPT=/opt/agentseal/scan_skill.py
ENCRYPTION_KEY=<32+ char passphrase, e.g. openssl rand -hex 32>
Expand Down
4 changes: 4 additions & 0 deletions deployment/ornn-api/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ metadata:
labels:
app: ornn-api
spec:
# Pinned to 1: the in-process rate limiter (ornn-api/src/middleware/rateLimit.ts) keeps per-pod buckets.
# Do NOT raise >1 or add an HPA without a shared-store backend first (#837) — per-pod buckets multiply the effective limit by replica count.
replicas: 1
selector:
matchLabels:
Expand Down Expand Up @@ -54,6 +56,8 @@ spec:
value: "${ORNN_PUBLIC_ORIGIN}"
- name: ORNN_URL_ALLOWLIST_CIDR
value: "${ORNN_URL_ALLOWLIST_CIDR}"
- name: ORNN_TRUSTED_PROXY_HOPS
value: "${ORNN_TRUSTED_PROXY_HOPS}"
- name: AGENTSEAL_PYTHON
value: "${AGENTSEAL_PYTHON}"
- name: AGENTSEAL_SCRIPT
Expand Down
1 change: 1 addition & 0 deletions docs/CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ Optional: `detail`, `errors[]`.
| `resource_conflict` | 409 | State conflict (duplicate, concurrent modification, etc.) |
| `rate_limited` | 429 | Caller exceeded rate limit |
| `upstream_unavailable` | 502 / 503 | Dependency (NyxID, LLM, sandbox, ...) failed |
| `org_membership_unavailable` | 503 | NyxID org-membership lookup unresolved — forwarded token absent or lookup failed. Retryable |
| `internal_error` | 500 | Unhandled server error |

New codes require convention-doc update. Handlers MUST NOT invent ad-hoc codes.
Expand Down
42 changes: 28 additions & 14 deletions ornn-api/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,24 +147,36 @@ import { buildSpec } from "./openapi/specBuilder";
// Error handler
import { AppError, buildProblemJsonBody } from "./shared/types/index";

// Shared redaction list — single source of truth for sensitive log
// fields, shared with index.ts and the createLogger factory.
import { REDACT_PATHS } from "./shared/logger";

export interface BootstrapResult {
app: Hono;
shutdown: () => Promise<void>;
}

export async function bootstrap(config: SkillConfig): Promise<BootstrapResult> {
/**
* Test-only dependency overrides. Production callers pass nothing — the
* single optional second argument lets integration tests substitute the
* `NyxLlmClient` with an in-process fake (see `tests/mocks/llmGateway.ts`)
* so quota-charge / per-model accounting flows run without real network
* IO. The override, when present, replaces the single `nyxLlmClient` that
* the playground, skill-gen, search, and audit domains all share.
*/
export interface BootstrapOverrides {
/** Substitute the shared LLM gateway client (integration tests only). */
llmClient?: NyxLlmClient;
}

export async function bootstrap(
config: SkillConfig,
overrides?: BootstrapOverrides,
): Promise<BootstrapResult> {
const logger = pino({
level: config.logLevel,
...(config.logPretty ? { transport: { target: "pino-pretty" } } : {}),
redact: {
paths: [
"req.headers.authorization",
"req.headers[\"x-api-key\"]",
"*.password",
"*.secret",
"*.apiKey",
],
},
redact: { paths: REDACT_PATHS },
}).child({ service: "ornn-api" });

logger.info("Bootstrapping ornn-api service...");
Expand Down Expand Up @@ -436,10 +448,12 @@ export async function bootstrap(config: SkillConfig): Promise<BootstrapResult> {
// selection still resolves through this single client. Backend-eng-2
// will swap this for a per-surface provider lookup once the
// `llm_providers` collection ships.
const nyxLlmClient = new NyxLlmClient({
resolver: async () => resolveLlmProviderForSurface("playground"),
saTokenProvider,
});
const nyxLlmClient =
overrides?.llmClient ??
new NyxLlmClient({
resolver: async () => resolveLlmProviderForSurface("playground"),
saTokenProvider,
});

// ---- Repositories ----
const skillRepo = new SkillRepository(db);
Expand Down
101 changes: 101 additions & 0 deletions ornn-api/src/clients/llmModelListClient.ssrf.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Tests for #832: `LlmModelListClient` routes BOTH of its outbound
* sites through `safeFetch`, so each is refused when its host rebinds to
* 169.254.169.254 at fetch time:
*
* 1. The model-list URL fetch (`fetch({ modelListUrl, auth })`).
* 2. The OAuth2 `tokenUrl` client_credentials exchange, taken when
* `auth.kind === "tokenUrl"` — an explicitly named site because it
* POSTs `client_secret` to an operator-supplied host. The refusal
* MUST fire before the secret leaves the process.
*
* The client catches `SsrfRefusalError` and rethrows it as-is, so the
* caller still sees the refusal type. We assert the fetch spy recorded
* zero calls for the refused site.
*
* dns is stubbed before the client imports so the shared preflight
* (bound in `url.ts` at module load) sees the rebind — same host-aware
* idiom as the nyxid ssrf tests.
*/

import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
import type {
ApiFormat,
LlmProviderAuth,
} from "../domains/settings/llmProviders/types";

const REBIND_HOST = "rebind.test";
mock.module("node:dns/promises", () => ({
lookup: async (host: string) =>
host === REBIND_HOST
? [{ address: "169.254.169.254", family: 4 }]
: [{ address: "93.184.216.34", family: 4 }],
}));

const { LlmModelListClient } = await import("./llmModelListClient");
const { SsrfRefusalError } = await import("../infra/url");

const ALLOWLIST_ENV = "ORNN_URL_ALLOWLIST_CIDR";
const originalFetch = globalThis.fetch;
const originalAllowlist = process.env[ALLOWLIST_ENV];

let fetchCalls: string[];

beforeEach(() => {
fetchCalls = [];
delete process.env[ALLOWLIST_ENV];
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
fetchCalls.push(url);
return new Response(JSON.stringify({ data: [], access_token: "tok" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
});

afterEach(() => {
globalThis.fetch = originalFetch;
if (originalAllowlist === undefined) delete process.env[ALLOWLIST_ENV];
else process.env[ALLOWLIST_ENV] = originalAllowlist;
});

const apiFormat: ApiFormat = "responses";

describe("LlmModelListClient SSRF preflight (#832)", () => {
it("refuses a rebound modelListUrl before reading the catalog", async () => {
const auth: LlmProviderAuth = { kind: "apiKey", apiKey: "secret-key" };
await expect(
new LlmModelListClient().fetch({
modelListUrl: "http://rebind.test/v1/models",
apiFormat,
auth,
}),
).rejects.toBeInstanceOf(SsrfRefusalError);
expect(fetchCalls).toHaveLength(0);
});

it("refuses a rebound OAuth2 tokenUrl before POSTing client_secret", async () => {
// Named site: the auth.kind === "tokenUrl" client_credentials
// exchange. tokenUrl rebinds to the metadata host — the preflight
// must refuse before `client_secret` is posted. The model-list URL
// is a benign public host that is never reached because auth header
// construction fails first.
const auth: LlmProviderAuth = {
kind: "tokenUrl",
tokenUrl: "http://rebind.test/oauth/token",
clientId: "cid",
clientSecret: "super-secret",
};
await expect(
new LlmModelListClient().fetch({
modelListUrl: "http://public-models.example.com/v1/models",
apiFormat,
auth,
}),
).rejects.toBeInstanceOf(SsrfRefusalError);
// Neither the token endpoint nor the model-list endpoint was hit.
expect(fetchCalls).toHaveLength(0);
});
});
33 changes: 7 additions & 26 deletions ornn-api/src/clients/llmModelListClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,8 @@ import type {
LlmProviderAuth,
} from "../domains/settings/llmProviders/types";
import type { ModelListFetcher } from "../domains/settings/llmProviders/service";
import {
assertPublicResolvedAddress,
SsrfRefusalError,
} from "../infra/url";
import { safeFetch } from "../infra/safeFetch";
import { SsrfRefusalError } from "../infra/url";

const logger = createLogger("llmModelListClient");

Expand Down Expand Up @@ -76,27 +74,6 @@ function redactCredentials(body: string): string {
.replace(/"(access_token|api_key|apiKey|token)"\s*:\s*"[^"]*"/g, '"$1":"[REDACTED]"');
}

/**
* Pre-flight SSRF check + AbortSignal wrapper around `fetch`. Centralised
* so the model-list and OAuth2 token-exchange paths share one
* implementation.
*/
async function safeFetch(
url: string,
init: RequestInit,
): Promise<Response> {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`Invalid URL: '${url}'`);
}
await assertPublicResolvedAddress(parsed.hostname);

const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
return fetch(url, { ...init, signal });
}

export class LlmModelListClient implements ModelListFetcher {
/**
* Fetch the upstream catalog. Returns `[{ id, displayName }]` —
Expand All @@ -119,7 +96,10 @@ export class LlmModelListClient implements ModelListFetcher {

let resp: Response;
try {
resp = await safeFetch(args.modelListUrl, { headers });
resp = await safeFetch(args.modelListUrl, {
headers,
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
} catch (err) {
// Distinguish SSRF refusal (operator-actionable) from generic
// transport failures so the admin "Refresh" toast can render
Expand Down Expand Up @@ -219,6 +199,7 @@ export class LlmModelListClient implements ModelListFetcher {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
} catch (err) {
if (err instanceof SsrfRefusalError) {
Expand Down
3 changes: 2 additions & 1 deletion ornn-api/src/clients/nyxid/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
*/

import { createLogger } from "../../shared/logger";
import { safeFetch } from "../../infra/safeFetch";
const logger = createLogger("nyxidSaTokenProvider");

/**
Expand Down Expand Up @@ -82,7 +83,7 @@ export class NyxidSaTokenProvider {
client_id: clientId,
client_secret: clientSecret,
});
const resp = await fetch(tokenUrl, {
const resp = await safeFetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
Expand Down
Loading
Loading