Skip to content

Commit 2dc6c21

Browse files
MQ37jirispilka
andauthored
fix(cache): scope Actor definition cache to its owner (#985)
> Mostly tests + comments — the actual logic change is small: a ~10-line ownership gate in `getActorDefinitionCached`, a simplified `getActorMcpUrlCached`, and deleting the `mcpServerCache` global. ## What Gate `actorDefinitionCache` hits by ownership: public Actors are served to any caller, private Actors only to their owner; everyone else falls through to a fresh fetch under their own token. Drop the separate `mcpServerCache` and derive the MCP URL from the now-gated definition cache. ## Why The cache is process-wide and was keyed by Actor name only. On a multi-tenant HTTP deployment, token B requesting an Actor that token A already cached got a cache **hit that bypassed B's authorization check** — leaking a private Actor's definition (input schema, README, run options) across tenants. `getActorMCPServerURL` does no I/O, so `mcpServerCache` guarded nothing expensive and re-introduced the same leak for the MCP URL; removing it closes that vector and deletes a global. Owner check reuses the already-cached `getUserInfoCached`, so no extra API call on the hot path. Found in an internal security review of the public package. The gate hinges on two invariants (documented in `callerMaySeeCachedActor`): `info.userId` is the platform-set **owner**, and the caller's identity is resolved with the same `user('me')` identity the platform authorizes with. Verified against the **live Apify API**: a private Actor returns `isPublic: false` and `userId` equal to its owner's `user('me').id`; a public Actor returns `isPublic: true`. So a non-owner token resolves to a different `user('me').id`, the predicate denies, and the cache can never grant more than a bare re-fetch would. ## Testing - New `tests/unit/utils.actor.cache.test.ts` (7 cases) pinning the authorization truth table: public→served to all (asserts no `user('me')` lookup), private→owner served, private→non-owner returns the **re-fetched** object (never the cached one), private→anonymous denied, and the `getActorMcpUrlCached` gate (owner→URL, non-owner private→`false`/re-fetch, missing→`false` no throw). - `pnpm type-check`, `pnpm lint`, `pnpm format:check`, `pnpm test:unit` (870 pass / 2 skipped) all green. > **Residual risk (accepted):** an Actor cached while public, then flipped private by its owner, is still served from the public fast-path until the 30-min TTL expires. The data was world-readable at cache time and the window is bounded; closing it would require per-request visibility re-checks that defeat the cache. > > **Note:** a private Actor legitimately shared with another user re-fetches each call (cache miss) rather than serving from cache — safe, slightly less efficient for that uncommon case. --------- Co-authored-by: Jiří Spilka <jiri.spilka@apify.com>
1 parent 138a349 commit 2dc6c21

3 files changed

Lines changed: 149 additions & 47 deletions

File tree

src/state.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ const ACTOR_CACHE_MAX_SIZE = 500;
55
const ACTOR_CACHE_TTL_SECS = 30 * 60; // 30 minutes
66
const APIFY_DOCS_CACHE_MAX_SIZE = 500;
77
const APIFY_DOCS_CACHE_TTL_SECS = 60 * 60; // 1 hour
8-
const MCP_SERVER_CACHE_MAX_SIZE = 500;
9-
const MCP_SERVER_CACHE_TTL_SECS = 30 * 60; // 30 minutes
108

119
export const actorDefinitionCache = new TTLLRUCache<ActorDefinitionWithInfo>(
1210
ACTOR_CACHE_MAX_SIZE,
@@ -18,9 +16,3 @@ export const searchApifyDocsCache = new TTLLRUCache<ApifyDocsSearchResult[]>(
1816
);
1917
/** Stores processed Markdown content */
2018
export const fetchApifyDocsCache = new TTLLRUCache<string>(APIFY_DOCS_CACHE_MAX_SIZE, APIFY_DOCS_CACHE_TTL_SECS);
21-
/**
22-
* Stores MCP server resolution per actor:
23-
* - false: not an MCP server
24-
* - string: MCP server URL
25-
*/
26-
export const mcpServerCache = new TTLLRUCache<boolean | string>(MCP_SERVER_CACHE_MAX_SIZE, MCP_SERVER_CACHE_TTL_SECS);

src/utils/actor.ts

Lines changed: 30 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,30 @@
11
import type { ApifyClient } from '../apify_client.js';
22
import { getActorMCPServerPath, getActorMCPServerURL } from '../mcp/actors.js';
3-
import { actorDefinitionCache, mcpServerCache } from '../state.js';
3+
import { actorDefinitionCache } from '../state.js';
44
import { getActorDefinition } from '../tools/build.js';
55
import type { ActorDefinitionStorage, ActorDefinitionWithInfo, DatasetItem } from '../types.js';
66
import { getValuesByDotKeys } from './generic.js';
7+
import { getUserInfoCached } from './userid_cache.js';
8+
9+
/**
10+
* `actorDefinitionCache` is process-wide, so a private Actor's definition must never be served from it to
11+
* anyone but its owner — else another token on the same process reads it with no auth check. Two invariants
12+
* keep this gate from inverting into a leak:
13+
* 1. `info.userId` is the platform-set OWNER, not the fetching token — so a non-owner's re-fetch can't
14+
* overwrite the cached ownership.
15+
* 2. The caller is identified by `user('me')` under their own token (the same identity the platform
16+
* authorizes with) and `null` is the sole non-identity sentinel — so a hit grants no more than a bare
17+
* re-fetch would. Don't drop the `!== null` guard or swap in a cheaper identity source.
18+
* Trade-off: an org-owned private Actor is cached under the org's userId, so an org member
19+
* calling with a personal token never matches and re-fetches every time.
20+
* Fail-safe (no leak), just uncached for members - accepted over an org-membership lookup
21+
* that would put a per-call API round trip back on * this path.
22+
*/
23+
async function callerMaySeeCachedActor(cached: ActorDefinitionWithInfo, apifyClient: ApifyClient): Promise<boolean> {
24+
if (cached.info.isPublic) return true;
25+
const { userId } = await getUserInfoCached(apifyClient.token, apifyClient);
26+
return userId !== null && userId === cached.info.userId;
27+
}
728

829
/**
930
* Returns the cached Actor definition + info, fetching from the platform on miss
@@ -17,52 +38,22 @@ export async function getActorDefinitionCached(
1738
apifyClient: ApifyClient,
1839
): Promise<ActorDefinitionWithInfo | null> {
1940
const cached = actorDefinitionCache.get(actorIdOrName);
20-
if (cached) return cached;
41+
if (cached && (await callerMaySeeCachedActor(cached, apifyClient))) return cached;
2142
const fetched = await getActorDefinition(actorIdOrName, apifyClient);
2243
if (fetched) actorDefinitionCache.set(actorIdOrName, fetched);
2344
return fetched;
2445
}
2546

2647
/**
27-
* Resolve and cache the MCP server URL for the given Actor.
28-
* - Returns a string URL when the Actor exposes an MCP server
29-
* - Returns false when the Actor is not an MCP server
30-
* Uses a TTL LRU cache to avoid repeated API calls.
48+
* Resolve the Actor's MCP server URL, or `false` if it isn't an MCP server. The URL is a pure function of
49+
* the definition (`getActorMCPServerURL` does no I/O), so this rides the authorization-gated
50+
* `getActorDefinitionCached` instead of a separate cache that would leak a private Actor's URL across tenants.
3151
*/
3252
export async function getActorMcpUrlCached(actorIdOrName: string, apifyClient: ApifyClient): Promise<string | false> {
33-
const cached = mcpServerCache.get(actorIdOrName);
34-
if (cached !== null && cached !== undefined) {
35-
return cached as string | false;
36-
}
37-
38-
try {
39-
const actorDefinitionWithInfo = await getActorDefinitionCached(actorIdOrName, apifyClient);
40-
const definition = actorDefinitionWithInfo?.definition;
41-
const mcpPath = definition && getActorMCPServerPath(definition);
42-
if (mcpPath) {
43-
const url = await getActorMCPServerURL(definition.id, mcpPath);
44-
mcpServerCache.set(actorIdOrName, url);
45-
return url;
46-
}
47-
48-
mcpServerCache.set(actorIdOrName, false);
49-
return false;
50-
} catch (error) {
51-
// Check if it's a "not found" error (404 or 400 status codes)
52-
const isNotFound =
53-
typeof error === 'object' &&
54-
error !== null &&
55-
'statusCode' in error &&
56-
(error.statusCode === 404 || error.statusCode === 400);
57-
58-
if (isNotFound) {
59-
// Actor doesn't exist - cache false and return false
60-
mcpServerCache.set(actorIdOrName, false);
61-
return false;
62-
}
63-
// Real server error - don't cache, let it propagate
64-
throw error;
65-
}
53+
const definition = (await getActorDefinitionCached(actorIdOrName, apifyClient))?.definition;
54+
const mcpPath = definition && getActorMCPServerPath(definition);
55+
if (!mcpPath) return false;
56+
return getActorMCPServerURL(definition.id, mcpPath);
6657
}
6758

6859
/**
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import type { ApifyClient } from '../../src/apify_client.js';
4+
import type { ActorDefinitionWithInfo } from '../../src/types.js';
5+
6+
vi.mock('../../src/tools/build.js', () => ({ getActorDefinition: vi.fn() }));
7+
vi.mock('../../src/utils/userid_cache.js', () => ({ getUserInfoCached: vi.fn() }));
8+
9+
import { actorDefinitionCache } from '../../src/state.js';
10+
import { getActorDefinition } from '../../src/tools/build.js';
11+
import { getActorDefinitionCached, getActorMcpUrlCached } from '../../src/utils/actor.js';
12+
import { getUserInfoCached } from '../../src/utils/userid_cache.js';
13+
14+
const getActorDefinitionMock = vi.mocked(getActorDefinition);
15+
const getUserInfoCachedMock = vi.mocked(getUserInfoCached);
16+
17+
// Each test uses a unique Actor name, so the shared module-level cache never collides between cases.
18+
function seedCache(
19+
name: string,
20+
isPublic: boolean,
21+
ownerUserId: string,
22+
opts: { id?: string; webServerMcpPath?: string } = {},
23+
): ActorDefinitionWithInfo {
24+
const id = opts.id ?? name;
25+
const entry = {
26+
definition: {
27+
id,
28+
actorFullName: name,
29+
...(opts.webServerMcpPath && { webServerMcpPath: opts.webServerMcpPath }),
30+
},
31+
info: { id, isPublic, userId: ownerUserId },
32+
} as unknown as ActorDefinitionWithInfo;
33+
actorDefinitionCache.set(name, entry);
34+
return entry;
35+
}
36+
37+
const client = { token: 'caller-token' } as unknown as ApifyClient;
38+
39+
beforeEach(() => {
40+
getActorDefinitionMock.mockReset();
41+
getUserInfoCachedMock.mockReset();
42+
});
43+
44+
describe('getActorDefinitionCached — tenant isolation', () => {
45+
it('serves a cached public Actor to any caller without an ownership check', async () => {
46+
const cached = seedCache('acme/public-1', true, 'owner-1');
47+
48+
const result = await getActorDefinitionCached('acme/public-1', client);
49+
50+
expect(result).toBe(cached);
51+
expect(getUserInfoCachedMock).not.toHaveBeenCalled();
52+
expect(getActorDefinitionMock).not.toHaveBeenCalled();
53+
});
54+
55+
it('serves a cached private Actor to its owner', async () => {
56+
const cached = seedCache('acme/private-owner', false, 'owner-2');
57+
getUserInfoCachedMock.mockResolvedValue({ userId: 'owner-2', userPlanTier: 'FREE', isOrganization: false });
58+
59+
const result = await getActorDefinitionCached('acme/private-owner', client);
60+
61+
expect(result).toBe(cached);
62+
expect(getActorDefinitionMock).not.toHaveBeenCalled();
63+
});
64+
65+
it('does NOT serve a cached private Actor to a non-owner — returns the re-fetched object, never the cached one', async () => {
66+
const cached = seedCache('acme/private-other', false, 'owner-3');
67+
getUserInfoCachedMock.mockResolvedValue({ userId: 'intruder', userPlanTier: 'FREE', isOrganization: false });
68+
const refetched = {
69+
definition: {},
70+
info: { isPublic: false, userId: 'owner-3' },
71+
} as unknown as ActorDefinitionWithInfo;
72+
getActorDefinitionMock.mockResolvedValue(refetched);
73+
74+
const result = await getActorDefinitionCached('acme/private-other', client);
75+
76+
expect(result).toBe(refetched);
77+
expect(result).not.toBe(cached);
78+
expect(getActorDefinitionMock).toHaveBeenCalledWith('acme/private-other', client);
79+
});
80+
81+
it('does NOT serve a cached private Actor to an anonymous caller', async () => {
82+
seedCache('acme/private-anon', false, 'owner-4');
83+
getUserInfoCachedMock.mockResolvedValue({ userId: null, userPlanTier: 'FREE', isOrganization: false });
84+
getActorDefinitionMock.mockResolvedValue(null);
85+
86+
const result = await getActorDefinitionCached('acme/private-anon', client);
87+
88+
expect(result).toBeNull();
89+
expect(getActorDefinitionMock).toHaveBeenCalledWith('acme/private-anon', client);
90+
});
91+
});
92+
93+
describe('getActorMcpUrlCached — tenant isolation', () => {
94+
it('derives the MCP URL from a cached Actor the caller may see', async () => {
95+
seedCache('acme/mcp-public', true, 'owner-6', { id: 'actorpub', webServerMcpPath: '/mcp' });
96+
97+
const result = await getActorMcpUrlCached('acme/mcp-public', client);
98+
99+
expect(result).toBe('https://actorpub.apify.actor/mcp');
100+
expect(getActorDefinitionMock).not.toHaveBeenCalled();
101+
});
102+
103+
it('does NOT leak a cached private Actor MCP URL to a non-owner — re-fetches and returns false', async () => {
104+
seedCache('acme/mcp-private', false, 'owner-7', { id: 'actorpriv', webServerMcpPath: '/mcp' });
105+
getUserInfoCachedMock.mockResolvedValue({ userId: 'intruder', userPlanTier: 'FREE', isOrganization: false });
106+
getActorDefinitionMock.mockResolvedValue(null); // intruder's own fetch is unauthorized
107+
108+
const result = await getActorMcpUrlCached('acme/mcp-private', client);
109+
110+
expect(result).toBe(false);
111+
expect(getActorDefinitionMock).toHaveBeenCalledWith('acme/mcp-private', client);
112+
});
113+
114+
it('returns false for a non-existent Actor without throwing', async () => {
115+
getActorDefinitionMock.mockResolvedValue(null);
116+
117+
await expect(getActorMcpUrlCached('acme/missing', client)).resolves.toBe(false);
118+
});
119+
});

0 commit comments

Comments
 (0)