Skip to content

Commit 391310e

Browse files
authored
fix(security): reject ':' in tenant ids, count only POSTs for eve rate limit (#5)
* fix(security): reject ':' in tenant ids; count only POSTs for eve rate limit Harden the per-user key boundary and fix the eve rate-limit double-count, plus stale-doc cleanup. Folded into the existing tenant-isolation changeset. Tenant isolation - AgentMemory/ChatHistory/ToolCache now reject a ':' (the key separator) in userId/sessionId/toolName. The whole isolation model rests on the key shape `<prefix>:<userId>:<...>`, and only emptiness was validated — a ':' let keys collide across users (e.g. user "a"+session "b:c" reached user "a:b"+session "c" via the direct-key getChat/saveChat/deleteChat/forget paths). - Added unit tests for the rejection across all three primitives. eve rate limiting - createRateLimitAuth counts only POST requests. eve drives each turn as two authenticated requests (the message POST + a follow-up GET .../stream) and the auth walk runs on both, so a turn was charged twice. Now one turn = one token; session-read GETs fall through unthrottled. Documented in the README and docstring; updated tests (a bare Request defaults to GET). Docs - Document deriving userId from a verified auth source (Clerk, Auth.js, Supabase Auth, Auth0, ...), never a client-supplied value; added DEMO-ONLY warnings to the ai-sdk-demo routes that trust an x-user-id header. - Fixed stale references: search-tools `{@link withIndex}` -> ReactiveSearchIndex; ai-sdk memory "scope" -> "userId"; corrected CLAUDE.md's superseded namespace key-naming note. * docs(eve): drop @upstash/agentkit-sdk from install (it's already a dependency)
1 parent 7f706d0 commit 391310e

17 files changed

Lines changed: 138 additions & 32 deletions

File tree

.changeset/harden-tenant-isolation.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ Tenant-isolation hardening, a type-safe reactive search index, and a consistent
88

99
**Tenant isolation**
1010

11-
- `ChatHistory` is keyed per user (`<prefix>:<userId>:<sessionId>`), so a chat can never be read or overwritten by a different user. Every method takes a single object; `userId`/`sessionId` are required and validated non-empty.
12-
- `AgentMemory` requires a non-empty `userId` on every call (no silent shared bucket); `add`/`recall` take a single object param.
13-
- `ToolCache` keys are `<prefix>:<userId>:<toolName>:<hash>` — scoped per user, then per tool.
14-
- `createRateLimit`/`createRateLimitAuth` require an explicit `limiter` (removed `limit`/`window`); eve's `createRateLimitAuth` requires `identifier` (no implicit global bucket).
11+
- `ChatHistory` is keyed per user (`<prefix>:<userId>:<sessionId>`), so a chat can never be read or overwritten by a different user. Every method takes a single object; `userId`/`sessionId` are required, validated non-empty, and rejected if they contain the `:` key separator (which would otherwise let keys collide across users).
12+
- `AgentMemory` requires a non-empty `userId` on every call (no silent shared bucket) and rejects a `:` in `userId`; `add`/`recall` take a single object param.
13+
- `ToolCache` keys are `<prefix>:<userId>:<toolName>:<hash>` — scoped per user, then per tool; `userId`/`toolName` are rejected if they contain `:`.
14+
- `createRateLimit`/`createRateLimitAuth` require an explicit `limiter` (removed `limit`/`window`); eve's `createRateLimitAuth` requires `identifier` (no implicit global bucket) and counts only `POST` requests, so a turn (a message `POST` plus its follow-up stream `GET`) is charged once, not twice.
1515
- The eve sandbox denies network egress by default.
1616

1717
**Reactive search index**

CLAUDE.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,13 +205,17 @@ pnpm -r --filter "./examples/*" build # build both demo apps
205205
- Conventional commits; use `!` for breaking changes. Commit at meaningful checkpoints.
206206

207207
## TODO (current task)
208+
> **Historical log — superseded naming.** The items below record a completed task and use the
209+
> intermediate `namespace` name, which was later renamed to **`userId`** (the per-call tenant value)
210+
> plus **`toolName`** (the cache's tool segment). For the live key naming and conventions, see the
211+
> "API conventions" section above — not this checklist.
208212
- [x] Remove model cache (code + examples done; READMEs pending below).
209213
- [x] ai-sdk: add `cachedTools` (map of `tool()`-built tools, namespace defaults to map key) alongside `cachedTool`; `cachePrefix``namespace`; dropped `toolCache` from the config.
210214
- [x] `cachedTool`/`cachedTools` are fully type-safe (config extends the AI SDK `tool()` type — input/output inference, no `any`).
211215
- [x] Search tools: ensure the index (create + `waitIndexing`, memoized) before running each tool — a missing Upstash index returns `null`/`-1` rather than throwing, so we ensure up front.
212216
- [x] `createMemoryTools` (ai-sdk) + eve memory tools: `scope``namespace` (string or per-call function). Core `AgentMemory` add/recall/forget use `namespace`.
213217
- [x] Rate limiting: `namespace` is a plain string; prefix `agentkit:rateLimit`.
214-
- [x] Key naming: `agentkit:rateLimit:<identifier>`, `agentkit:toolCache:<namespace>:<hash>`, `agentkit:memory:<namespace>:<id>`.
218+
- [x] Key naming (now `userId`/`toolName`, not `namespace`): `agentkit:rateLimit:<identifier>`, `agentkit:toolCache:<userId>:<toolName>:<hash>`, `agentkit:memory:<userId>:<id>`.
215219
- [x] Unit/e2e tests use `gpt-4o` (`TEST_MODEL`).
216220
- [x] eve: dropped the `./model` subpath — model wrappers are exported from the package root.
217221
- [x] ai-sdk example app fleshed out (memory + search + cached tool + rate limit).

examples/ai-sdk-demo/app/api/chat/route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ export async function POST(req: Request) {
2727
// as a header. Memory, history, tool cache and rate limit are all scoped to this user; only the
2828
// shared books index is common to everyone.
2929
const { id, messages } = (await req.json()) as { id: string; messages: UIMessage[] };
30+
// ⚠️ DEMO ONLY: this trusts a client-supplied header to identify the user, so anyone can send
31+
// `x-user-id: <someone-else>` and read/overwrite their data. It's only safe here because
32+
// `normalizeUser` allow-lists it to two fixed demo users. In production, derive `userId` from a
33+
// VERIFIED server-side session (Clerk, Auth.js/NextAuth, Supabase Auth, Auth0, …) — e.g.
34+
// `const { userId } = await auth()` with Clerk — and NEVER from a request header/param/body.
3035
const userId = normalizeUser(req.headers.get(USER_HEADER));
3136
const redis = getRedis();
3237

examples/ai-sdk-demo/app/api/chats/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ export const runtime = "nodejs";
55

66
// GET /api/chats → list the user's chats (summaries, newest first)
77
// GET /api/chats?q=<text> → fuzzy-search the user's chats by what was said
8-
// The active user is identified by the `x-user-id` header, so each user sees only their own chats.
8+
// ⚠️ DEMO ONLY: the user is identified by the client-supplied `x-user-id` header (allow-listed to two
9+
// fixed demo users by `normalizeUser`). In production, derive `userId` from a VERIFIED server-side
10+
// session (Clerk, Auth.js/NextAuth, Supabase Auth, Auth0, …), never from a client-supplied value.
911
export async function GET(req: Request) {
1012
const history = getHistory();
1113
const userId = normalizeUser(req.headers.get(USER_HEADER));

packages/ai-sdk/README.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,12 @@ const history = createChatHistory({
2828
});
2929
```
3030

31-
Every method takes a single object; `userId` is **required, non-empty, and must be unique per user**
32-
(your auth subject id). It's the tenant boundary — a chat can't be read or overwritten under a
33-
different `userId`. `saveChat` overwrites the **whole** message array — `useChat` sends the full
31+
Every method takes a single object; `userId` is **required, non-empty, and may not contain `:`**. It's
32+
the tenant boundary, so **derive it from a verified server-side auth source** — the subject/user id
33+
from your auth provider (Clerk, Auth.js/NextAuth, Supabase Auth, Auth0, …) — and **never from a
34+
client-supplied header, query param, or body** (e.g. read it from the session in your route, not from
35+
the request the browser controls). A chat can't be read or overwritten under a different `userId`.
36+
`saveChat` overwrites the **whole** message array — `useChat` sends the full
3437
conversation, so there's no transport trimming and no delta to merge. Persist from your route's
3538
`onFinish`:
3639

@@ -85,9 +88,10 @@ const tools = createMemoryTools({
8588
await generateText({ model, tools, stopWhen: stepCountIs(5), prompt: "What do you know about me?" });
8689
```
8790

88-
> **`userId` is required and must be non-empty** — it's the only tenant boundary for memory.
89-
> Make it **unique per user** (pass the user id, or a `(input, options) => string` deriving it); an
90-
> empty value throws rather than collapsing every user into one shared bucket.
91+
> **`userId` is required, non-empty, and may not contain `:`** — it's the only tenant boundary for
92+
> memory. **Derive it from a verified server-side auth source** (the subject/user id from Clerk,
93+
> Auth.js/NextAuth, Supabase Auth, Auth0, …), passed as a string or a `(input, options) => string`;
94+
> **never trust a client-supplied value.** An empty/separator-bearing value throws.
9195
9296
## Search tools
9397

packages/ai-sdk/src/memory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export interface CreateMemoryToolsConfig {
2929

3030
/**
3131
* Build `recall_memory` and `save_memory` AI SDK tools backed by long-term {@link AgentMemory}. Spread
32-
* the returned map into `generateText({ tools })`. Pass only a `scope`; `redis` defaults to
32+
* the returned map into `generateText({ tools })`. Pass only a `userId`; `redis` defaults to
3333
* `Redis.fromEnv()`.
3434
*
3535
* ```ts

packages/eve/README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ code-execution **sandbox backend** powered by [Upstash Box](https://github.com/u
77
cached tools.
88

99
```bash
10-
pnpm add @upstash/agentkit-eve @upstash/agentkit-sdk @upstash/redis
10+
pnpm add @upstash/agentkit-eve @upstash/redis
1111
# in your app (Eve + the OpenAI provider, plus Box only if you use /sandbox):
1212
pnpm add eve @ai-sdk/openai @upstash/box
1313
```
@@ -47,6 +47,12 @@ export default defineMemorySaveTool({
4747
});
4848
```
4949

50+
> **`userId` is the tenant boundary** (required, non-empty, no `:`). Derive it from eve's **verified
51+
> session auth**`ctx.session.auth.current?.principalId` (the authenticated principal, gated by your
52+
> channel's `auth` walk) — as above, not from anything the client supplies. Configure a real
53+
> authenticator (`vercelOidc()`, an OIDC/JWT provider like Clerk, …) so `principalId` is trustworthy;
54+
> the `?? ctx.session.id` fallback only applies when a request is unauthenticated.
55+
5056
## Search tools (`agent/tools/*.ts`)
5157

5258
`defineSearchTools` builds `search` / `aggregate` / `count` eve tools over an Upstash Redis Search
@@ -107,6 +113,12 @@ export default eveChannel({
107113
> one abusive caller can exhaust the window for everyone, so for per-user limiting derive it per
108114
> request (an authenticated user id, an API key, or `x-forwarded-for` for per-IP).
109115
116+
> **Only `POST` requests are counted.** eve runs each turn as two authenticated requests — the message
117+
> `POST` (which invokes the model) and a follow-up `GET …/stream` that opens the reply stream — and the
118+
> `auth` walk runs on both. `createRateLimitAuth` throttles only the `POST`s, so **one turn costs one
119+
> token** of your limiter (a `Ratelimit.slidingWindow(20, "1 m")` allows 20 turns/min, not 10); the
120+
> session-read `GET`s fall through unthrottled.
121+
110122
## Code-execution sandbox (`agent/sandbox.ts`)
111123

112124
`upstash()` is a drop-in replacement for Eve's `vercel()` backend, powered by Upstash Box. Swap the

packages/eve/src/auth.test.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { ForbiddenError } from "eve/channels/auth";
44
import { Ratelimit, createRateLimitAuth } from "./index.js";
55
import { hasRedisCreds, testRedis } from "./test-support.js";
66

7-
const req = () => new Request("http://localhost/agent");
7+
// Only POST requests (the model-invoking message submissions) are counted, so default to POST here.
8+
const req = () => new Request("http://localhost/agent", { method: "POST" });
89

910
describe.skipIf(!hasRedisCreds)("createRateLimitAuth (live Redis)", () => {
1011
const redis = testRedis();
@@ -35,11 +36,27 @@ describe.skipIf(!hasRedisCreds)("createRateLimitAuth (live Redis)", () => {
3536
});
3637

3738
const reqFor = (user: string) =>
38-
new Request("http://localhost/agent", { headers: { "x-user": user } });
39+
new Request("http://localhost/agent", { method: "POST", headers: { "x-user": user } });
3940

4041
// Exhaust A's bucket; B's is untouched and still passes.
4142
expect(await auth(reqFor("a"))).toBeNull();
4243
await expect(auth(reqFor("a"))).rejects.toBeInstanceOf(ForbiddenError);
4344
expect(await auth(reqFor("b"))).toBeNull();
4445
});
46+
47+
// eve runs the auth walk on the follow-up `GET …/stream` too; only POSTs (which invoke the model)
48+
// are counted, so a turn isn't charged twice. A GET always falls through without touching the limiter.
49+
it("does not count non-POST requests (e.g. the stream GET)", async () => {
50+
const auth = createRateLimitAuth({
51+
redis,
52+
limiter: Ratelimit.slidingWindow(1, "60 s"),
53+
identifier: `test:${randomUUID().slice(0, 8)}`,
54+
});
55+
const get = () => new Request("http://localhost/agent", { method: "GET" });
56+
57+
// Many GETs in a row never exhaust the (size-1) window — they're not counted at all.
58+
expect(await auth(get())).toBeNull();
59+
expect(await auth(get())).toBeNull();
60+
expect(await auth(get())).toBeNull();
61+
});
4562
});

packages/eve/src/auth.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ export interface RateLimitAuthConfig extends Omit<RateLimitConfig, "redis"> {
2424
* Over the limit it throws a `ForbiddenError` (HTTP 403). Backed by {@link createRateLimit}; keys are
2525
* `agentkit:rateLimit:<identifier>`.
2626
*
27+
* **Only `POST` requests are counted** (the message-submitting routes that actually invoke the model:
28+
* `POST /eve/v1/session` and `POST /eve/v1/session/:id`). eve drives each turn as two authenticated
29+
* requests — the message `POST` **and** a follow-up `GET …/stream` that opens the reply stream — and
30+
* the auth walk runs on both. Counting both would charge every turn twice; gating only the `POST`
31+
* makes one turn cost exactly one token, while the session-read `GET`s fall through unthrottled.
32+
*
2733
* ```ts
2834
* // agent/channels/eve.ts
2935
* import { createRateLimitAuth, Ratelimit } from "@upstash/agentkit-eve";
@@ -47,6 +53,10 @@ export function createRateLimitAuth(config: RateLimitAuthConfig): AuthFn<Request
4753
const ratelimit = createRateLimit({ ...rest, redis: redis ?? Redis.fromEnv() });
4854

4955
return async (request) => {
56+
// Only throttle the model-invoking message submissions (POST). The follow-up `GET …/stream` (and
57+
// other session reads) share this auth walk but shouldn't each cost a token — let them through so
58+
// a single turn = a single increment.
59+
if (request.method !== "POST") return null;
5060
const id = typeof identifier === "function" ? await identifier(request) : identifier;
5161
const { success } = await ratelimit.limit(id);
5262
if (!success) {

packages/sdk/README.md

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,12 @@ const hits = await history.searchChats({
8181
await history.deleteChat({ userId: "user-123", sessionId: "session-abc" }); // delete (also de-indexes it)
8282
```
8383

84-
> **`userId` and `sessionId` are required and must be non-empty** — they are the only tenant
85-
> boundary, so an empty value throws rather than silently mis-scoping a chat. Make `userId` unique
86-
> per user (e.g. your auth subject id); a chat can't be read or overwritten by a different `userId`.
84+
> **`userId` and `sessionId` are required, non-empty, and may not contain `:`** (the key separator) —
85+
> they are the only tenant boundary, so an empty or separator-bearing value throws rather than
86+
> silently mis-scoping a chat. **Derive `userId` from a verified server-side auth source** — the
87+
> subject/user id from your auth provider (Clerk, Auth.js/NextAuth, Supabase Auth, Auth0, …) — and
88+
> **never from a client-supplied header, query param, or request body**, or a caller can impersonate
89+
> any user. A chat can't be read or overwritten under a different `userId`.
8790
8891
### Agent memory
8992

@@ -113,9 +116,11 @@ const hits = await memory.recall({
113116
await memory.forget("pref-lang", { userId: "user-123" }); // required, non-empty userId
114117
```
115118

116-
> **`userId` is required and must be non-empty** on every method — it's the only tenant boundary
117-
> for memory, so an empty value throws rather than collapsing all callers into one shared bucket.
118-
> Make it **unique per user** (e.g. the user id) to keep each user's memories isolated.
119+
> **`userId` is required, non-empty, and may not contain `:`** on every method — it's the only tenant
120+
> boundary for memory, so an empty or separator-bearing value throws rather than collapsing or
121+
> colliding callers. **Derive it from a verified server-side auth source** (the subject/user id from
122+
> Clerk, Auth.js/NextAuth, Supabase Auth, Auth0, …) — **never from a client-supplied value** — to keep
123+
> each user's memories isolated.
119124
120125
### Search tools
121126

@@ -185,9 +190,10 @@ const getWeather = tools.wrap(
185190
);
186191
```
187192

188-
> **`userId` and `toolName` are both required and must be non-empty** (`get`/`set`/`invalidate`/`wrap`
189-
> all throw on an empty value). The entry is scoped to the user first, so one user's cached result is
190-
> never served to another.
193+
> **`userId` and `toolName` are both required, non-empty, and may not contain `:`** (`get`/`set`/
194+
> `invalidate`/`wrap` all throw otherwise). The entry is scoped to the user first, so one user's cached
195+
> result is never served to another — provided `userId` comes from a verified auth source, not a
196+
> client-supplied value.
191197
192198
## Testing
193199

0 commit comments

Comments
 (0)