mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(security): CCR cross-tenant IDOR — scope store per-principal + bound memory (#3859)
Integrated into release/v3.8.25 — fix(security): CCR cross-tenant IDOR (scope store per-principal + bound memory).
This commit is contained in:
committed by
GitHub
parent
aa8fc4157d
commit
cf5898205a
@@ -15,7 +15,7 @@
|
||||
"open-sse/executors/muse-spark-web.ts": 1284,
|
||||
"open-sse/executors/perplexity-web.ts": 868,
|
||||
"open-sse/handlers/audioSpeech.ts": 952,
|
||||
"open-sse/handlers/chatCore.ts": 5811,
|
||||
"open-sse/handlers/chatCore.ts": 5812,
|
||||
"open-sse/handlers/imageGeneration.ts": 3777,
|
||||
"open-sse/handlers/responseSanitizer.ts": 1103,
|
||||
"open-sse/handlers/search.ts": 1442,
|
||||
|
||||
@@ -2626,6 +2626,7 @@ export async function handleChatCore({
|
||||
const result = await applyCompressionAsync(compressionInputBody, mode, {
|
||||
model: effectiveModel,
|
||||
config,
|
||||
principalId: apiKeyInfo?.id ? String(apiKeyInfo.id) : undefined,
|
||||
});
|
||||
if (result.stats) {
|
||||
if (result.compressed) {
|
||||
|
||||
@@ -243,6 +243,7 @@ import {
|
||||
compressionComboStatsInput,
|
||||
} from "../schemas/tools.ts";
|
||||
import { handleCcrRetrieve } from "../../services/compression/engines/ccr/index.ts";
|
||||
import { resolveCallerScopeContext } from "../scopeEnforcement.ts";
|
||||
|
||||
const ccrRetrieveInput = z.object({
|
||||
hash: z
|
||||
@@ -351,6 +352,11 @@ export const compressionTools = {
|
||||
"Scope: read:compression. Always available (sticky-on).",
|
||||
scopes: ["read:compression"],
|
||||
inputSchema: ccrRetrieveInput,
|
||||
handler: (args: z.infer<typeof ccrRetrieveInput>) => handleCcrRetrieve(args),
|
||||
handler: (args: z.infer<typeof ccrRetrieveInput>, extra?: McpToolExtraLike) => {
|
||||
// Derive caller identity from MCP auth context so the retrieve is scoped to the
|
||||
// same principal that stored the block. This closes the cross-tenant IDOR (HIGH).
|
||||
const { callerId } = resolveCallerScopeContext(extra, ["read:compression"]);
|
||||
return handleCcrRetrieve(args, callerId === "anonymous" ? undefined : callerId);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,21 +4,30 @@
|
||||
* Replaces large contiguous blocks of text with a content-addressed
|
||||
* retrieve marker: `[CCR retrieve hash=<24hex> chars=<N>]`
|
||||
*
|
||||
* The verbatim block is stored in an in-module content-addressed store
|
||||
* (keyed by 24-hex content hash). The `retrieve` MCP tool (or the
|
||||
* `handleCcrRetrieve` helper exported here) returns the block on demand.
|
||||
* The verbatim block is stored in a principal-scoped, bounded in-module store.
|
||||
* The store key is `${principalId ?? "__anon__"} ${contentHash}` so that one
|
||||
* principal cannot read another's stored blocks (IDOR protection).
|
||||
*
|
||||
* The `retrieve` MCP tool (or the `handleCcrRetrieve` helper exported here)
|
||||
* returns the block on demand when called with the matching callerId.
|
||||
*
|
||||
* Algorithm:
|
||||
* - Scan non-system messages; for each `type:"text"` part or string content,
|
||||
* find contiguous text blocks ≥ minChars characters.
|
||||
* - Replace the block with `[CCR retrieve hash=<24hex> chars=<N>]` only if
|
||||
* the marker is shorter than the original block.
|
||||
* - Store the original block keyed by hash in the CCR store.
|
||||
* - Store the original block keyed by (principalId, hash) in the CCR store.
|
||||
*
|
||||
* Feedback:
|
||||
* - `recordRetrieval(hash)` increments a retrieval counter for that hash.
|
||||
* - `shouldSkipCompression(hash)` returns true once the counter reaches
|
||||
* RETRIEVAL_THRESHOLD, signalling "do not compress this block again".
|
||||
* Feedback (scoped by principal):
|
||||
* - `recordRetrieval(hash, principalId)` increments a retrieval counter for
|
||||
* that (principalId, hash) pair.
|
||||
* - `shouldSkipCompression(hash, principalId)` returns true once the counter
|
||||
* reaches RETRIEVAL_THRESHOLD for that principal — one principal's behaviour
|
||||
* does not affect another's (cross-tenant state drift protection).
|
||||
*
|
||||
* Memory bound:
|
||||
* - Both `ccrStore` and `retrievalCounts` are capped at MAX_CCR_ENTRIES
|
||||
* entries using FIFO eviction (Map insertion-order guarantees).
|
||||
*
|
||||
* Conservative guards:
|
||||
* - Never touch `role: "system"`.
|
||||
@@ -42,57 +51,95 @@ import type { CompressionResult } from "../../types.ts";
|
||||
const ENGINE_ID = "ccr";
|
||||
/** Default minimum character count for a block to be a CCR candidate. */
|
||||
const DEFAULT_MIN_CHARS = 600;
|
||||
/** Number of retrievals before a block is flagged "do-not-compress". */
|
||||
/** Number of retrievals before a block is flagged "do-not-compress" for that principal. */
|
||||
const RETRIEVAL_THRESHOLD = 3;
|
||||
/** Regex to match CCR markers for reconstruction. */
|
||||
const MARKER_RE = /\[CCR retrieve hash=([0-9a-f]{24}) chars=\d+\]/g;
|
||||
/**
|
||||
* Maximum number of entries in each bounded store.
|
||||
* When inserting beyond this cap, the oldest entry (Map insertion order) is evicted.
|
||||
* 5 000 entries × ~2 KB average ≈ 10 MB upper bound for each map.
|
||||
*/
|
||||
export const MAX_CCR_ENTRIES = 5_000;
|
||||
|
||||
// ─── content-addressed store ──────────────────────────────────────────────────
|
||||
// ─── principal-scoped, bounded content store ──────────────────────────────────
|
||||
|
||||
/** Map from 24-hex hash → verbatim block text. */
|
||||
/**
|
||||
* Store key = `${principalId ?? "__anon__"} ${contentHash}`.
|
||||
* Using a compound key scopes data to the principal that stored it.
|
||||
*/
|
||||
const ccrStore = new Map<string, string>();
|
||||
/** Map from 24-hex hash → retrieval count (feedback signal). */
|
||||
/** Retrieval counter store — same scoping as ccrStore. */
|
||||
const retrievalCounts = new Map<string, number>();
|
||||
|
||||
/** Sentinel used when no principalId is provided. */
|
||||
const ANON = "__anon__";
|
||||
|
||||
function buildStoreKey(hash: string, principalId?: string): string {
|
||||
return `${principalId ?? ANON} ${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a value into a bounded Map, evicting the oldest entry when over the cap.
|
||||
*/
|
||||
function boundedSet<V>(map: Map<string, V>, key: string, value: V): void {
|
||||
if (!map.has(key) && map.size >= MAX_CCR_ENTRIES) {
|
||||
// Map preserves insertion order — the first iterator result is the oldest entry.
|
||||
const firstKey = map.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
map.delete(firstKey);
|
||||
}
|
||||
}
|
||||
map.set(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a 24-hex content hash for a text block (SHA-256 prefix).
|
||||
* This is the hash embedded in the marker; principal scoping is internal to
|
||||
* the store key and is NOT part of the marker itself.
|
||||
*/
|
||||
function hashContent(text: string): string {
|
||||
return crypto.createHash("sha256").update(text).digest("hex").slice(0, 24);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a block in the CCR store, returning its hash.
|
||||
* Store a block in the CCR store under the given principal.
|
||||
* Returns the 24-hex content hash (for embedding in the marker).
|
||||
*/
|
||||
function storeBlock(text: string): string {
|
||||
export function storeBlock(text: string, principalId?: string): string {
|
||||
const hash = hashContent(text);
|
||||
if (!ccrStore.has(hash)) {
|
||||
ccrStore.set(hash, text);
|
||||
const key = buildStoreKey(hash, principalId);
|
||||
if (!ccrStore.has(key)) {
|
||||
boundedSet(ccrStore, key, text);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the verbatim block for a given hash.
|
||||
* Returns null if not found.
|
||||
* Retrieve the verbatim block for a given hash and principal.
|
||||
* Returns null if not found or if the principal does not match the stored key.
|
||||
*/
|
||||
export function retrieveBlock(hash: string): string | null {
|
||||
return ccrStore.get(hash) ?? null;
|
||||
export function retrieveBlock(hash: string, principalId?: string): string | null {
|
||||
const key = buildStoreKey(hash, principalId);
|
||||
return ccrStore.get(key) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a retrieval event for a given hash (feedback signal).
|
||||
* Record a retrieval event for a given (hash, principal) pair (feedback signal).
|
||||
*/
|
||||
export function recordRetrieval(hash: string): void {
|
||||
retrievalCounts.set(hash, (retrievalCounts.get(hash) ?? 0) + 1);
|
||||
export function recordRetrieval(hash: string, principalId?: string): void {
|
||||
const key = buildStoreKey(hash, principalId);
|
||||
boundedSet(retrievalCounts, key, (retrievalCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the block has been retrieved often enough that it
|
||||
* should be excluded from compression in future requests.
|
||||
* Returns true if the block has been retrieved often enough by this principal
|
||||
* that it should be excluded from compression in future requests.
|
||||
* Each principal's feedback is isolated from other principals.
|
||||
*/
|
||||
export function shouldSkipCompression(hash: string): boolean {
|
||||
return (retrievalCounts.get(hash) ?? 0) >= RETRIEVAL_THRESHOLD;
|
||||
export function shouldSkipCompression(hash: string, principalId?: string): boolean {
|
||||
const key = buildStoreKey(hash, principalId);
|
||||
return (retrievalCounts.get(key) ?? 0) >= RETRIEVAL_THRESHOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,21 +154,29 @@ export function resetCcrStore(): void {
|
||||
|
||||
/**
|
||||
* Handler for the `omniroute_ccr_retrieve` MCP tool.
|
||||
*
|
||||
* The `callerId` parameter must be the authenticated principal id derived from
|
||||
* the MCP `extra` context (see compressionTools.ts). Only the principal that
|
||||
* stored the block can retrieve it.
|
||||
*
|
||||
* Returns the verbatim block for the given hash, or an error object.
|
||||
*/
|
||||
export function handleCcrRetrieve(args: { hash: string }): { content: string } | { error: string } {
|
||||
export function handleCcrRetrieve(
|
||||
args: { hash: string },
|
||||
callerId?: string
|
||||
): { content: string } | { error: string } {
|
||||
if (!args.hash || typeof args.hash !== "string") {
|
||||
return { error: "hash parameter is required and must be a string" };
|
||||
}
|
||||
|
||||
const block = retrieveBlock(args.hash);
|
||||
const block = retrieveBlock(args.hash, callerId);
|
||||
if (block === null) {
|
||||
return {
|
||||
error: `CCR block not found for hash=${args.hash}. The block may have expired or the hash is invalid.`,
|
||||
};
|
||||
}
|
||||
|
||||
recordRetrieval(args.hash);
|
||||
recordRetrieval(args.hash, callerId);
|
||||
return { content: block };
|
||||
}
|
||||
|
||||
@@ -146,7 +201,8 @@ function buildMarker(hash: string, charCount: number): string {
|
||||
*/
|
||||
function maybeCcrReplace(
|
||||
text: string,
|
||||
minChars: number
|
||||
minChars: number,
|
||||
principalId?: string
|
||||
): { text: string; replaced: boolean; hash: string | null } {
|
||||
if (text.length < minChars) {
|
||||
return { text, replaced: false, hash: null };
|
||||
@@ -154,8 +210,8 @@ function maybeCcrReplace(
|
||||
|
||||
const hash = hashContent(text);
|
||||
|
||||
// Skip if this hash is flagged as do-not-compress
|
||||
if (shouldSkipCompression(hash)) {
|
||||
// Skip if this (principal, hash) pair is flagged as do-not-compress
|
||||
if (shouldSkipCompression(hash, principalId)) {
|
||||
return { text, replaced: false, hash: null };
|
||||
}
|
||||
|
||||
@@ -166,7 +222,7 @@ function maybeCcrReplace(
|
||||
return { text, replaced: false, hash: null };
|
||||
}
|
||||
|
||||
storeBlock(text);
|
||||
storeBlock(text, principalId);
|
||||
return { text: marker, replaced: true, hash };
|
||||
}
|
||||
|
||||
@@ -175,7 +231,8 @@ function maybeCcrReplace(
|
||||
*/
|
||||
function processMessages(
|
||||
messages: MessageLike[],
|
||||
minChars: number
|
||||
minChars: number,
|
||||
principalId?: string
|
||||
): { messages: MessageLike[]; replacedCount: number } {
|
||||
let replacedCount = 0;
|
||||
|
||||
@@ -183,7 +240,7 @@ function processMessages(
|
||||
if (msg.role === "system") return { ...msg };
|
||||
|
||||
if (typeof msg.content === "string") {
|
||||
const { text, replaced } = maybeCcrReplace(msg.content, minChars);
|
||||
const { text, replaced } = maybeCcrReplace(msg.content, minChars, principalId);
|
||||
if (replaced) {
|
||||
replacedCount++;
|
||||
return { ...msg, content: text };
|
||||
@@ -195,7 +252,7 @@ function processMessages(
|
||||
let changed = false;
|
||||
const newContent = msg.content.map((part) => {
|
||||
if (part["type"] !== "text" || typeof part["text"] !== "string") return part;
|
||||
const { text, replaced } = maybeCcrReplace(part["text"] as string, minChars);
|
||||
const { text, replaced } = maybeCcrReplace(part["text"] as string, minChars, principalId);
|
||||
if (replaced) {
|
||||
changed = true;
|
||||
replacedCount++;
|
||||
@@ -253,11 +310,15 @@ function validateCcrConfig(config: Record<string, unknown>): EngineValidationRes
|
||||
|
||||
/**
|
||||
* Reconstruct a body by replacing all `[CCR retrieve hash=<24hex> chars=N]`
|
||||
* markers with the stored verbatim blocks.
|
||||
* markers with the stored verbatim blocks for the given principal.
|
||||
*
|
||||
* Returns a new body object with markers restored to their original text.
|
||||
* Markers that do not belong to the given principal remain unchanged.
|
||||
*/
|
||||
export function reconstructCcr(body: Record<string, unknown>): Record<string, unknown> {
|
||||
export function reconstructCcr(
|
||||
body: Record<string, unknown>,
|
||||
principalId?: string
|
||||
): Record<string, unknown> {
|
||||
const messages = body["messages"];
|
||||
if (!Array.isArray(messages)) return body;
|
||||
|
||||
@@ -272,7 +333,7 @@ export function reconstructCcr(body: Record<string, unknown>): Record<string, un
|
||||
|
||||
if (typeof content === "string") {
|
||||
const reconstructed = content.replace(MARKER_RE, (_m, hash: string) => {
|
||||
return ccrStore.get(hash) ?? _m;
|
||||
return retrieveBlock(hash, principalId) ?? _m;
|
||||
});
|
||||
return reconstructed !== content ? { ...msg, content: reconstructed } : { ...msg };
|
||||
}
|
||||
@@ -282,7 +343,7 @@ export function reconstructCcr(body: Record<string, unknown>): Record<string, un
|
||||
const newContent = content.map((part) => {
|
||||
if (part["type"] !== "text" || typeof part["text"] !== "string") return part;
|
||||
const reconstructed = (part["text"] as string).replace(MARKER_RE, (_m, hash: string) => {
|
||||
return ccrStore.get(hash) ?? _m;
|
||||
return retrieveBlock(hash, principalId) ?? _m;
|
||||
});
|
||||
if (reconstructed !== part["text"]) {
|
||||
changed = true;
|
||||
@@ -307,7 +368,8 @@ export const ccrEngine: CompressionEngine = {
|
||||
description:
|
||||
"Replaces large blocks of text with content-addressed retrieve markers " +
|
||||
"`[CCR retrieve hash=<24hex> chars=N]`. The original block is stored and " +
|
||||
"retrievable via the `omniroute_ccr_retrieve` MCP tool (H4).",
|
||||
"retrievable via the `omniroute_ccr_retrieve` MCP tool (H4). " +
|
||||
"Store is principal-scoped: only the storing principal can retrieve their blocks.",
|
||||
icon: "archive",
|
||||
targets: ["messages"],
|
||||
stackable: true,
|
||||
@@ -319,7 +381,7 @@ export const ccrEngine: CompressionEngine = {
|
||||
name: "CCR (Content-Compression-Retrieve)",
|
||||
description:
|
||||
"Reversible compression: large blocks → retrieve marker. " +
|
||||
"Original retrievable via MCP tool (H4).",
|
||||
"Original retrievable via MCP tool (H4). Principal-scoped for tenant isolation.",
|
||||
inputScope: "messages",
|
||||
targetLatencyMs: 1,
|
||||
supportsPreview: true,
|
||||
@@ -346,7 +408,8 @@ export const ccrEngine: CompressionEngine = {
|
||||
const start = performance.now();
|
||||
const { messages: newMessages, replacedCount } = processMessages(
|
||||
messages as MessageLike[],
|
||||
minChars
|
||||
minChars,
|
||||
options?.principalId
|
||||
);
|
||||
|
||||
if (replacedCount === 0) {
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface CompressionEngineApplyOptions {
|
||||
config?: CompressionConfig;
|
||||
compressionComboId?: string | null;
|
||||
stepConfig?: Record<string, unknown>;
|
||||
/** Authenticated principal (API key id) making the request. Used by CCR to scope its store. */
|
||||
principalId?: string;
|
||||
}
|
||||
|
||||
export interface CompressionEngine {
|
||||
|
||||
@@ -70,7 +70,12 @@ export function selectCompressionStrategy(
|
||||
export function applyCompression(
|
||||
body: Record<string, unknown>,
|
||||
mode: CompressionMode,
|
||||
options?: { model?: string; supportsVision?: boolean | null; config?: CompressionConfig }
|
||||
options?: {
|
||||
model?: string;
|
||||
supportsVision?: boolean | null;
|
||||
config?: CompressionConfig;
|
||||
principalId?: string;
|
||||
}
|
||||
): CompressionResult {
|
||||
if (mode === "off") {
|
||||
return { body, compressed: false, stats: null };
|
||||
@@ -190,7 +195,12 @@ export function applyCompression(
|
||||
export async function applyCompressionAsync(
|
||||
body: Record<string, unknown>,
|
||||
mode: CompressionMode,
|
||||
options?: { model?: string; supportsVision?: boolean | null; config?: CompressionConfig }
|
||||
options?: {
|
||||
model?: string;
|
||||
supportsVision?: boolean | null;
|
||||
config?: CompressionConfig;
|
||||
principalId?: string;
|
||||
}
|
||||
): Promise<CompressionResult> {
|
||||
if (mode === "stacked") {
|
||||
const adapter = adaptBodyForCompression(body);
|
||||
@@ -231,6 +241,8 @@ interface StackOptions {
|
||||
compressionComboId?: string | null;
|
||||
/** TV1 bail-out discipline (opt-in, default disabled). */
|
||||
bailout?: BailoutConfig;
|
||||
/** Authenticated principal id — threaded through to CCR engine for store scoping. */
|
||||
principalId?: string;
|
||||
}
|
||||
|
||||
/** Accumulates per-step telemetry across a stacked run (shared sync/async). */
|
||||
@@ -274,6 +286,7 @@ function buildStepOptions(
|
||||
return {
|
||||
...options,
|
||||
compressionComboId: options?.compressionComboId ?? options?.config?.compressionComboId,
|
||||
principalId: options?.principalId,
|
||||
stepConfig: {
|
||||
...(step.config ?? {}),
|
||||
...(step.intensity ? { intensity: step.intensity } : {}),
|
||||
|
||||
268
tests/unit/compression/ccr-cross-tenant.test.ts
Normal file
268
tests/unit/compression/ccr-cross-tenant.test.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* TDD security tests: CCR cross-tenant IDOR + bounded memory + scoped feedback.
|
||||
* Run: node --import tsx/esm --test tests/unit/compression/ccr-cross-tenant.test.ts
|
||||
*
|
||||
* RED → GREEN: these tests define the security contract and must drive the fix.
|
||||
*
|
||||
* Security assertions:
|
||||
* 1. Cross-tenant IDOR blocked: principal B cannot retrieve a block stored by A.
|
||||
* 2. Anonymous principal cannot retrieve a block stored by a named principal.
|
||||
* 3. Memory bound: store never exceeds MAX_CCR_ENTRIES (FIFO eviction).
|
||||
* 4. Scoped feedback: A's retrievals do NOT flip B's shouldSkipCompression.
|
||||
* 5. handleCcrRetrieve end-to-end: cross-tenant retrieve returns not-found error.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { describe, it, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
storeBlock,
|
||||
retrieveBlock,
|
||||
recordRetrieval,
|
||||
shouldSkipCompression,
|
||||
resetCcrStore,
|
||||
handleCcrRetrieve,
|
||||
ccrEngine,
|
||||
MAX_CCR_ENTRIES,
|
||||
} from "../../../open-sse/services/compression/engines/ccr/index.ts";
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeText(seed: string, length: number = 700): string {
|
||||
return seed.repeat(Math.ceil(length / seed.length)).slice(0, length);
|
||||
}
|
||||
|
||||
const TEXT_A = makeText("content for principal A — confidential data");
|
||||
const TEXT_B = makeText("content for principal B — different tenant");
|
||||
|
||||
function contentHash(text: string): string {
|
||||
return crypto.createHash("sha256").update(text).digest("hex").slice(0, 24);
|
||||
}
|
||||
|
||||
// ─── cross-tenant IDOR isolation ─────────────────────────────────────────────
|
||||
|
||||
describe("ccr security: cross-tenant IDOR isolation", () => {
|
||||
beforeEach(() => resetCcrStore());
|
||||
|
||||
it("[HIGH] principal B cannot retrieve a block stored by principal A", () => {
|
||||
const hash = storeBlock(TEXT_A, "principalA");
|
||||
|
||||
// Same principal can retrieve
|
||||
const resultA = retrieveBlock(hash, "principalA");
|
||||
assert.equal(resultA, TEXT_A, "principal A must be able to retrieve its own block");
|
||||
|
||||
// Different principal is blocked — THE CORE SECURITY ASSERTION
|
||||
const resultB = retrieveBlock(hash, "principalB");
|
||||
assert.equal(
|
||||
resultB,
|
||||
null,
|
||||
"[HIGH IDOR] principal B must NOT be able to retrieve principal A's block"
|
||||
);
|
||||
});
|
||||
|
||||
it("[HIGH] anonymous principal cannot retrieve a block stored by a named principal", () => {
|
||||
const hash = storeBlock(TEXT_A, "principalA");
|
||||
|
||||
const resultAnon = retrieveBlock(hash, undefined);
|
||||
assert.equal(
|
||||
resultAnon,
|
||||
null,
|
||||
"[HIGH IDOR] anonymous principal must NOT retrieve a named principal's block"
|
||||
);
|
||||
});
|
||||
|
||||
it("named principal cannot retrieve a block stored by anonymous", () => {
|
||||
const hash = storeBlock(TEXT_B, undefined);
|
||||
|
||||
const resultA = retrieveBlock(hash, "principalA");
|
||||
assert.equal(resultA, null, "principal A must NOT retrieve an anonymous block");
|
||||
|
||||
// The anonymous store owner can retrieve
|
||||
const resultAnon = retrieveBlock(hash, undefined);
|
||||
assert.equal(resultAnon, TEXT_B, "anonymous can retrieve its own block");
|
||||
});
|
||||
|
||||
it("principal with no stored block cannot retrieve via another principal's hash", () => {
|
||||
// Store TEXT_A only under principalA — principalB never stores anything.
|
||||
const hashA = storeBlock(TEXT_A, "principalA");
|
||||
|
||||
// principalA can retrieve their own block
|
||||
assert.equal(retrieveBlock(hashA, "principalA"), TEXT_A, "A can retrieve its own block");
|
||||
|
||||
// principalB never stored anything — cannot retrieve even with the correct hash
|
||||
assert.equal(
|
||||
retrieveBlock(hashA, "principalB"),
|
||||
null,
|
||||
"[HIGH IDOR] B (never stored) cannot retrieve A's block by reusing A's hash"
|
||||
);
|
||||
|
||||
// anonymous similarly cannot
|
||||
assert.equal(
|
||||
retrieveBlock(hashA, undefined),
|
||||
null,
|
||||
"[HIGH IDOR] anonymous cannot retrieve A's block"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── handleCcrRetrieve end-to-end isolation ───────────────────────────────────
|
||||
|
||||
describe("ccr security: handleCcrRetrieve end-to-end isolation", () => {
|
||||
beforeEach(() => resetCcrStore());
|
||||
|
||||
it("handleCcrRetrieve with correct callerId returns content", () => {
|
||||
const hash = storeBlock(TEXT_A, "callerA");
|
||||
const result = handleCcrRetrieve({ hash }, "callerA");
|
||||
assert.ok("content" in result, "same-caller retrieve must return content");
|
||||
assert.equal((result as { content: string }).content, TEXT_A);
|
||||
});
|
||||
|
||||
it("[HIGH] handleCcrRetrieve with wrong callerId returns not-found error", () => {
|
||||
const hash = storeBlock(TEXT_A, "callerA");
|
||||
const result = handleCcrRetrieve({ hash }, "callerB");
|
||||
assert.ok(
|
||||
"error" in result,
|
||||
"[HIGH IDOR] cross-tenant retrieve via handleCcrRetrieve must return error, not content"
|
||||
);
|
||||
assert.ok(!("content" in result), "[HIGH IDOR] cross-tenant retrieve must NOT return content");
|
||||
});
|
||||
|
||||
it("handleCcrRetrieve without callerId cannot access named-principal block", () => {
|
||||
const hash = storeBlock(TEXT_A, "callerA");
|
||||
const result = handleCcrRetrieve({ hash }, undefined);
|
||||
assert.ok("error" in result, "anonymous retrieve of named-principal block must return error");
|
||||
});
|
||||
|
||||
it("handleCcrRetrieve returns error for completely unknown hash", () => {
|
||||
const result = handleCcrRetrieve({ hash: "000000000000000000000000" }, "anyPrincipal");
|
||||
assert.ok("error" in result, "unknown hash must return error");
|
||||
assert.ok(typeof (result as { error: string }).error === "string");
|
||||
});
|
||||
|
||||
it("handleCcrRetrieve returns error for missing hash parameter", () => {
|
||||
// @ts-expect-error — intentional wrong call to verify guard
|
||||
const result = handleCcrRetrieve({}, "caller");
|
||||
assert.ok("error" in result, "missing hash must return error");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── scoped retrieval feedback ────────────────────────────────────────────────
|
||||
|
||||
describe("ccr security: [MEDIUM] scoped retrieval feedback (shouldSkipCompression)", () => {
|
||||
beforeEach(() => resetCcrStore());
|
||||
|
||||
it("A's retrievals do NOT flip B's shouldSkipCompression", () => {
|
||||
const hash = storeBlock(TEXT_A, "principalA");
|
||||
storeBlock(TEXT_A, "principalB"); // same content stored under B
|
||||
|
||||
// Record retrievals for A above the threshold (default 3)
|
||||
recordRetrieval(hash, "principalA");
|
||||
recordRetrieval(hash, "principalA");
|
||||
recordRetrieval(hash, "principalA");
|
||||
|
||||
// A's compression should be skipped
|
||||
assert.equal(
|
||||
shouldSkipCompression(hash, "principalA"),
|
||||
true,
|
||||
"A's compression should be skipped after threshold retrievals"
|
||||
);
|
||||
|
||||
// B's compression must NOT be affected
|
||||
assert.equal(
|
||||
shouldSkipCompression(hash, "principalB"),
|
||||
false,
|
||||
"[MEDIUM] A's retrievals must NOT flip B's shouldSkipCompression"
|
||||
);
|
||||
});
|
||||
|
||||
it("shouldSkipCompression returns false for a principal with no retrievals", () => {
|
||||
const hash = storeBlock(TEXT_A, "principalA");
|
||||
assert.equal(shouldSkipCompression(hash, "principalA"), false);
|
||||
assert.equal(shouldSkipCompression(hash, "principalB"), false);
|
||||
assert.equal(shouldSkipCompression(hash, undefined), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── bounded memory (FIFO eviction) ──────────────────────────────────────────
|
||||
|
||||
describe("ccr security: [MEDIUM] bounded memory (FIFO eviction)", () => {
|
||||
beforeEach(() => resetCcrStore());
|
||||
|
||||
it("MAX_CCR_ENTRIES is exported and positive", () => {
|
||||
assert.ok(typeof MAX_CCR_ENTRIES === "number", "MAX_CCR_ENTRIES must be a number");
|
||||
assert.ok(MAX_CCR_ENTRIES > 0, "MAX_CCR_ENTRIES must be positive");
|
||||
});
|
||||
|
||||
it("FIFO eviction: oldest entry is evicted when cap is reached", () => {
|
||||
const principal = "evictionTest";
|
||||
|
||||
// Fill to exactly cap with unique blocks
|
||||
const firstText = `eviction block 0 ${"y".repeat(30)}`;
|
||||
const firstHash = storeBlock(firstText, principal);
|
||||
|
||||
for (let i = 1; i < MAX_CCR_ENTRIES; i++) {
|
||||
storeBlock(`eviction block ${i} ${"y".repeat(30)}`, principal);
|
||||
}
|
||||
|
||||
// Confirm first block is present before overflow
|
||||
assert.equal(
|
||||
retrieveBlock(firstHash, principal),
|
||||
firstText,
|
||||
"first block must be present at exactly-cap state"
|
||||
);
|
||||
|
||||
// Insert one more unique block — should evict the first (oldest) entry
|
||||
storeBlock(`eviction overflow block ${"z".repeat(50)}`, principal);
|
||||
|
||||
// First block should now be gone
|
||||
assert.equal(
|
||||
retrieveBlock(firstHash, principal),
|
||||
null,
|
||||
"[MEDIUM] FIFO eviction: oldest block must be evicted when cap is reached"
|
||||
);
|
||||
});
|
||||
|
||||
it("store does not grow unboundedly beyond MAX_CCR_ENTRIES distinct principals", () => {
|
||||
// Insert MAX+5 distinct (principal, content) pairs and verify the overall store
|
||||
// did not go unbounded by checking that the first inserted block is evicted.
|
||||
const firstText = `overflow test block 0 ${"a".repeat(40)}`;
|
||||
const firstPrincipal = "overflow-p-0";
|
||||
const firstHash = storeBlock(firstText, firstPrincipal);
|
||||
|
||||
for (let i = 1; i < MAX_CCR_ENTRIES + 5; i++) {
|
||||
storeBlock(`overflow test block ${i} ${"a".repeat(40)}`, `overflow-p-${i}`);
|
||||
}
|
||||
|
||||
// The first inserted store-key should have been evicted by FIFO
|
||||
assert.equal(
|
||||
retrieveBlock(firstHash, firstPrincipal),
|
||||
null,
|
||||
"[MEDIUM] unbounded-growth prevented: first inserted entry must be evicted after MAX+5 inserts"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── engine-level scoping: apply() threads principalId end-to-end ─────────────
|
||||
|
||||
describe("ccr security: [HIGH] ccrEngine.apply scopes the stored block to the principal", () => {
|
||||
beforeEach(() => resetCcrStore());
|
||||
|
||||
const bigBlock = makeText("a large block that CCR would normally compress ", 5000);
|
||||
const makeBody = () => ({ messages: [{ role: "user", content: bigBlock }] });
|
||||
|
||||
it("apply with a principalId stores the block retrievable ONLY by that principal", () => {
|
||||
const result = ccrEngine.apply(makeBody(), {
|
||||
principalId: "principalA",
|
||||
stepConfig: { minChars: 100 },
|
||||
});
|
||||
assert.equal(result.compressed, true, "CCR compresses the large block");
|
||||
const hash = contentHash(bigBlock);
|
||||
assert.equal(retrieveBlock(hash, "principalA"), bigBlock, "owner principal A can retrieve");
|
||||
assert.equal(
|
||||
retrieveBlock(hash, "principalB"),
|
||||
null,
|
||||
"[HIGH IDOR] principal B must NOT retrieve principal A's apply()-stored block"
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user