@zereight/mcp-gitlab 2.1.46 → 2.1.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Per-pod replay-prevention cache for sealed proxy authorization codes.
3
+ *
4
+ * Unlike an LRU map, entries are retained until their TTL expires — never
5
+ * evicted early. Inserts fail closed when the cache is full of still-valid
6
+ * entries, so a burst of traffic cannot reopen a capture-replay window by
7
+ * pushing a consumed hash out before `storedTtlSeconds` elapses.
8
+ */
9
+ import { randomBytes } from "node:crypto";
10
+ export const PROXY_CODE_CACHE_FULL = "PROXY_CODE_CACHE_FULL";
11
+ /** Largest TTL (seconds) that keeps `now + ttl * 1000` finite. */
12
+ const MAX_TTL_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1000);
13
+ function ttlToExpiryMs(now, ttlSeconds) {
14
+ // Non-finite / non-positive TTLs must still create a usable expiry so a
15
+ // reservation blocks concurrent replay instead of leaving expiresAt as NaN.
16
+ let safeSeconds = Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : 1;
17
+ if (safeSeconds > MAX_TTL_SECONDS) {
18
+ safeSeconds = MAX_TTL_SECONDS;
19
+ }
20
+ const expiresAt = now + safeSeconds * 1000;
21
+ return Number.isFinite(expiresAt) ? expiresAt : Number.MAX_SAFE_INTEGER;
22
+ }
23
+ export class ConsumedProxyCodeCache {
24
+ _map = new Map();
25
+ _maxSize;
26
+ _now;
27
+ constructor(maxSize, now = Date.now) {
28
+ this._maxSize = maxSize;
29
+ this._now = now;
30
+ }
31
+ purgeExpired(now) {
32
+ for (const [key, entry] of this._map) {
33
+ if (entry.expiresAt <= now)
34
+ this._map.delete(key);
35
+ }
36
+ }
37
+ /**
38
+ * Atomically reserve a code hash for an in-flight exchange.
39
+ * @returns `{ ok: true }` if reserved, or `{ ok: false, reason }` when an
40
+ * in-TTL pending/consumed entry already exists.
41
+ * @throws Error with message {@link PROXY_CODE_CACHE_FULL} when the cache
42
+ * is full of non-expired entries (fail closed).
43
+ */
44
+ tryReserve(key, ttlSeconds) {
45
+ const now = this._now();
46
+ this.purgeExpired(now);
47
+ const existing = this._map.get(key);
48
+ if (existing && existing.expiresAt > now) {
49
+ return { ok: false, reason: existing.state };
50
+ }
51
+ if (this._map.size >= this._maxSize) {
52
+ throw new Error(PROXY_CODE_CACHE_FULL);
53
+ }
54
+ this._map.set(key, {
55
+ expiresAt: ttlToExpiryMs(now, ttlSeconds),
56
+ state: "pending",
57
+ reservationId: randomBytes(16).toString("hex"),
58
+ });
59
+ const entry = this._map.get(key);
60
+ return { ok: true, reservationId: entry.reservationId };
61
+ }
62
+ /** Mark a reserved code as fully consumed (replay rejected until TTL). */
63
+ commit(key, reservationId) {
64
+ const entry = this._map.get(key);
65
+ if (entry && entry.reservationId === reservationId) {
66
+ entry.state = "consumed";
67
+ }
68
+ }
69
+ /** Drop an in-flight reservation so a later legitimate exchange can proceed. */
70
+ release(key, reservationId) {
71
+ const entry = this._map.get(key);
72
+ if (entry?.state === "pending" && entry.reservationId === reservationId) {
73
+ this._map.delete(key);
74
+ }
75
+ }
76
+ get size() {
77
+ return this._map.size;
78
+ }
79
+ get maxSize() {
80
+ return this._maxSize;
81
+ }
82
+ }
@@ -11,4 +11,5 @@ export { open, seal, sign, verify, } from "./codec.js";
11
11
  export { looksLikeStatelessClientId, mintClientId, openClientId, } from "./client-id.js";
12
12
  export { looksLikeStatelessState, mintPendingAuthState, openPendingAuthState, } from "./pending-auth.js";
13
13
  export { looksLikeStatelessStoredTokensCode, mintStoredTokensCode, openStoredTokensCode, } from "./stored-tokens.js";
14
+ export { ConsumedProxyCodeCache, PROXY_CODE_CACHE_FULL, } from "./consumed-proxy-code-cache.js";
14
15
  export { looksLikeStatelessSessionId, mintSessionId, openSessionId, } from "./session-id.js";
@@ -15,9 +15,11 @@
15
15
  * Uses AEAD (AES-256-GCM) because the payload contains the GitLab access
16
16
  * token and refresh token.
17
17
  *
18
- * Replay: proxyCode replay is defeated by two mitigations:
19
- * - short TTL (OAUTH_STATELESS_STORED_TTL_SECONDS, default 600s)
18
+ * Replay: proxyCode replay is defeated by three mitigations:
19
+ * - short TTL (OAUTH_STATELESS_STORED_TTL_SECONDS, default 120s)
20
20
  * - PKCE binding (the client must still present a matching code_verifier)
21
+ * - per-pod TTL-bound replay cache (pending reservation → commit on success;
22
+ * failed binding/PKCE releases the reservation; never LRU-evicts early)
21
23
  */
22
24
  import { open, seal } from "./codec.js";
23
25
  import { StatelessCodecError } from "./errors.js";
@@ -400,6 +400,102 @@ describe("MCP OAuth — createGitLabOAuthProvider", () => {
400
400
  stub.close();
401
401
  }
402
402
  });
403
+ test("verifyAccessToken falls back to access_token query param when Bearer gets 401", async () => {
404
+ // Simulates a GitLab behind an edge cache that strips the Authorization
405
+ // header on /oauth/* (e.g. git.drupalcode.org behind Varnish): the
406
+ // Bearer-header request 401s, the RFC 6750 query-param retry succeeds.
407
+ const TOKEN = "tok+en/with?special=chars&more"; // exercises URL encoding
408
+ const requests = [];
409
+ const { createServer } = await import("node:http");
410
+ const stub = createServer((req, res) => {
411
+ requests.push({ url: req.url, authHeader: req.headers["authorization"] });
412
+ const url = new URL(req.url, "http://localhost");
413
+ if (url.searchParams.get("access_token") === TOKEN) {
414
+ res.writeHead(200, { "Content-Type": "application/json" });
415
+ res.end(JSON.stringify({
416
+ resource_owner_id: 7,
417
+ scopes: ["api"],
418
+ expires_in_seconds: 3600,
419
+ application: { uid: "app-uid-abc" },
420
+ created_at: Math.floor(Date.now() / 1000),
421
+ }));
422
+ }
423
+ else {
424
+ // Header-stripping edge: reject anything not using the query param
425
+ res.writeHead(401, { "Content-Type": "application/json" });
426
+ res.end(JSON.stringify({ error: "invalid_token" }));
427
+ }
428
+ });
429
+ await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve));
430
+ const addr = stub.address();
431
+ const baseUrl = `http://127.0.0.1:${addr.port}`;
432
+ try {
433
+ const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
434
+ const provider = createGitLabOAuthProvider(baseUrl, "test-app-id");
435
+ const authInfo = await provider.verifyAccessToken(TOKEN);
436
+ assert.strictEqual(requests.length, 2, "exactly one retry after the 401");
437
+ assert.strictEqual(requests[0].authHeader, `Bearer ${TOKEN}`, "first attempt sends the token as a Bearer Authorization header");
438
+ assert.ok(!requests[0].url.includes("access_token="), "first attempt does not use the query param");
439
+ assert.strictEqual(requests[1].authHeader, undefined, "retry omits the Authorization header");
440
+ assert.ok(requests[1].url.includes(`access_token=${encodeURIComponent(TOKEN)}`), "retry carries the URL-encoded access_token query param");
441
+ assert.strictEqual(authInfo.token, TOKEN, "token preserved through the fallback path");
442
+ assert.strictEqual(authInfo.clientId, "app-uid-abc", "AuthInfo built from retry response");
443
+ console.log(" ✓ verifyAccessToken falls back to query param after Bearer 401");
444
+ }
445
+ finally {
446
+ stub.close();
447
+ }
448
+ });
449
+ test("verifyAccessToken does not retry when the Bearer request succeeds", async () => {
450
+ let requestCount = 0;
451
+ const { createServer } = await import("node:http");
452
+ const stub = createServer((_req, res) => {
453
+ requestCount++;
454
+ res.writeHead(200, { "Content-Type": "application/json" });
455
+ res.end(JSON.stringify({
456
+ resource_owner_id: 7,
457
+ scopes: ["api"],
458
+ expires_in_seconds: 3600,
459
+ application: { uid: "app-uid-abc" },
460
+ created_at: Math.floor(Date.now() / 1000),
461
+ }));
462
+ });
463
+ await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve));
464
+ const addr = stub.address();
465
+ const baseUrl = `http://127.0.0.1:${addr.port}`;
466
+ try {
467
+ const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
468
+ const provider = createGitLabOAuthProvider(baseUrl, "test-app-id");
469
+ await provider.verifyAccessToken("good-token");
470
+ assert.strictEqual(requestCount, 1, "no retry on a 200 response");
471
+ console.log(" ✓ verifyAccessToken makes a single request on success");
472
+ }
473
+ finally {
474
+ stub.close();
475
+ }
476
+ });
477
+ test("verifyAccessToken does not retry on non-401 errors", async () => {
478
+ let requestCount = 0;
479
+ const { createServer } = await import("node:http");
480
+ const stub = createServer((_req, res) => {
481
+ requestCount++;
482
+ res.writeHead(503, { "Content-Type": "application/json" });
483
+ res.end(JSON.stringify({ error: "service_unavailable" }));
484
+ });
485
+ await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve));
486
+ const addr = stub.address();
487
+ const baseUrl = `http://127.0.0.1:${addr.port}`;
488
+ try {
489
+ const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
490
+ const provider = createGitLabOAuthProvider(baseUrl, "test-app-id");
491
+ await assert.rejects(() => provider.verifyAccessToken("tok"), /invalid or expired/i, "non-401 errors still reject");
492
+ assert.strictEqual(requestCount, 1, "the query-param fallback is 401-only");
493
+ console.log(" ✓ verifyAccessToken does not retry on non-401 errors");
494
+ }
495
+ finally {
496
+ stub.close();
497
+ }
498
+ });
403
499
  test("verifyAccessToken maps GitLab token info to AuthInfo", async () => {
404
500
  const createdAt = Math.floor(Date.now() / 1000);
405
501
  const { createServer } = await import("node:http");
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
- import { GitLabCompareResultSchema, GitLabProtectedBranchAccessLevelSchema, } from "../schemas.js";
3
+ import { GitLabCiLintResultSchema, GitLabCompareResultSchema, GitLabProtectedBranchAccessLevelSchema, } from "../schemas.js";
4
4
  test("role-based protected-branch access levels accept null user_id/group_id", () => {
5
5
  // #575: GitLab returns null for role-based levels, not omitted fields
6
6
  const parsed = GitLabProtectedBranchAccessLevelSchema.parse({
@@ -20,3 +20,18 @@ test("branch compare accepts null commit", () => {
20
20
  });
21
21
  assert.equal(parsed.commit, null);
22
22
  });
23
+ test("ci lint result accepts null merged_yaml/includes on an invalid config", () => {
24
+ // #638: GitLab nulls both fields whenever the config is invalid — i.e. in
25
+ // exactly the case validate_ci_lint exists to report on.
26
+ const parsed = GitLabCiLintResultSchema.parse({
27
+ valid: false,
28
+ errors: ["image is defined in top-level and `default:` entry"],
29
+ warnings: [],
30
+ merged_yaml: null,
31
+ includes: null,
32
+ });
33
+ assert.equal(parsed.valid, false);
34
+ assert.equal(parsed.merged_yaml, null);
35
+ assert.equal(parsed.includes, null);
36
+ assert.deepEqual(parsed.errors, ["image is defined in top-level and `default:` entry"]);
37
+ });
@@ -0,0 +1,33 @@
1
+ import { describe, test } from "node:test";
2
+ import assert from "node:assert";
3
+ import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readMcpSessionIdHeader, redactSessionIdForLog, } from "../../server/request-helpers.js";
4
+ describe("streamable request helpers", () => {
5
+ test("redacts stateless session ids and truncates legacy ids", () => {
6
+ assert.strictEqual(redactSessionIdForLog(undefined), "<none>");
7
+ assert.strictEqual(redactSessionIdForLog("v1.sid.secret-payload"), "v1.sid.<redacted>");
8
+ assert.strictEqual(redactSessionIdForLog("123456789abcdef"), "12345678…");
9
+ assert.strictEqual(redactSessionIdForLog("short"), "short");
10
+ });
11
+ test("detects initialize requests in single and batch bodies", () => {
12
+ assert.strictEqual(isInitializationRequestBody({ method: "initialize" }), true);
13
+ assert.strictEqual(isInitializationRequestBody([{ method: "tools/list" }, { method: "initialize" }]), true);
14
+ assert.strictEqual(isInitializationRequestBody({ method: "tools/list" }), false);
15
+ assert.strictEqual(isInitializationRequestBody(null), false);
16
+ });
17
+ test("allows only discovery methods in unauthenticated discovery bodies", () => {
18
+ assert.strictEqual(isUnauthenticatedDiscoveryRequestBody({ method: "tools/list" }), true);
19
+ assert.strictEqual(isUnauthenticatedDiscoveryRequestBody([
20
+ { method: "initialize" },
21
+ { method: "notifications/initialized" },
22
+ { method: "tools/list" },
23
+ ]), true);
24
+ assert.strictEqual(isUnauthenticatedDiscoveryRequestBody([{ method: "tools/list" }, { method: "call_tool" }]), false);
25
+ assert.strictEqual(isUnauthenticatedDiscoveryRequestBody(undefined), false);
26
+ });
27
+ test("normalizes Mcp-Session-Id headers", () => {
28
+ assert.strictEqual(readMcpSessionIdHeader({ headers: { "mcp-session-id": "sid-1" } }), "sid-1");
29
+ assert.strictEqual(readMcpSessionIdHeader({ headers: { "mcp-session-id": "" } }), undefined);
30
+ assert.strictEqual(readMcpSessionIdHeader({ headers: { "mcp-session-id": ["sid-1", "sid-2"] } }), undefined);
31
+ assert.strictEqual(readMcpSessionIdHeader({ headers: {} }), undefined);
32
+ });
33
+ });
@@ -290,6 +290,83 @@ describe("callback-proxy cross-pod flow (stateless)", () => {
290
290
  const wrongVerifier = randomBytes(32).toString("base64url");
291
291
  await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, wrongVerifier, CLIENT_REDIRECT), /PKCE verification failed/);
292
292
  });
293
+ test("failed PKCE does not burn the code; legitimate exchange still succeeds", async () => {
294
+ const s = secret();
295
+ const pod = makeProvider(loadMaterial(s));
296
+ const registered = await pod.clientsStore.registerClient({
297
+ redirect_uris: [CLIENT_REDIRECT],
298
+ token_endpoint_auth_method: "none",
299
+ });
300
+ const correctVerifier = randomBytes(32).toString("base64url");
301
+ const correctChallenge = createHash("sha256")
302
+ .update(correctVerifier)
303
+ .digest("base64url");
304
+ const authorizeRes = makeAuthorizeRes();
305
+ await pod.authorize(registered, {
306
+ state: "s",
307
+ scopes: ["api"],
308
+ redirectUri: CLIENT_REDIRECT,
309
+ codeChallenge: correctChallenge,
310
+ }, authorizeRes);
311
+ const proxyState = new URL(authorizeRes.redirectedTo).searchParams.get("state");
312
+ const cbRes = makeFakeRes();
313
+ await pod.handleCallback(fakeReq({ code: "gitlab-code", state: proxyState }), cbRes);
314
+ const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
315
+ const wrongVerifier = randomBytes(32).toString("base64url");
316
+ await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, wrongVerifier, CLIENT_REDIRECT), /PKCE verification failed/);
317
+ const tokens = await pod.exchangeAuthorizationCode(registered, proxyCode, correctVerifier, CLIENT_REDIRECT);
318
+ assert.equal(tokens.access_token, "gl-access-123");
319
+ });
320
+ test("successful exchange then replay is rejected on the same pod", async () => {
321
+ const s = secret();
322
+ const pod = makeProvider(loadMaterial(s));
323
+ const registered = await pod.clientsStore.registerClient({
324
+ redirect_uris: [CLIENT_REDIRECT],
325
+ token_endpoint_auth_method: "none",
326
+ });
327
+ const verifier = randomBytes(32).toString("base64url");
328
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
329
+ const authorizeRes = makeAuthorizeRes();
330
+ await pod.authorize(registered, {
331
+ state: "s",
332
+ scopes: ["api"],
333
+ redirectUri: CLIENT_REDIRECT,
334
+ codeChallenge: challenge,
335
+ }, authorizeRes);
336
+ const proxyState = new URL(authorizeRes.redirectedTo).searchParams.get("state");
337
+ const cbRes = makeFakeRes();
338
+ await pod.handleCallback(fakeReq({ code: "gitlab-code", state: proxyState }), cbRes);
339
+ const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
340
+ const tokens = await pod.exchangeAuthorizationCode(registered, proxyCode, verifier, CLIENT_REDIRECT);
341
+ assert.equal(tokens.access_token, "gl-access-123");
342
+ await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, verifier, CLIENT_REDIRECT), /Authorization code already used/);
343
+ });
344
+ test("exchangeAuthorizationCode reports busy when replay cache is full", async () => {
345
+ const s = secret();
346
+ const pod = makeProvider(loadMaterial(s));
347
+ const registered = await pod.clientsStore.registerClient({
348
+ redirect_uris: [CLIENT_REDIRECT],
349
+ token_endpoint_auth_method: "none",
350
+ });
351
+ const verifier = randomBytes(32).toString("base64url");
352
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
353
+ const authorizeRes = makeAuthorizeRes();
354
+ await pod.authorize(registered, {
355
+ state: "s",
356
+ scopes: ["api"],
357
+ redirectUri: CLIENT_REDIRECT,
358
+ codeChallenge: challenge,
359
+ }, authorizeRes);
360
+ const proxyState = new URL(authorizeRes.redirectedTo).searchParams.get("state");
361
+ const cbRes = makeFakeRes();
362
+ await pod.handleCallback(fakeReq({ code: "gitlab-code", state: proxyState }), cbRes);
363
+ const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
364
+ const cache = pod._usedProxyCodes;
365
+ for (let i = 0; i < cache.maxSize; i++) {
366
+ cache.tryReserve(`fill-${i}`, 600);
367
+ }
368
+ await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, verifier, CLIENT_REDIRECT), /Authorization server busy/);
369
+ });
293
370
  test("exchangeAuthorizationCode rejects wrong client_id", async () => {
294
371
  const s = secret();
295
372
  const pod = makeProvider(loadMaterial(s));
@@ -316,6 +393,9 @@ describe("callback-proxy cross-pod flow (stateless)", () => {
316
393
  const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
317
394
  // Try to redeem as r2 (different client_id)
318
395
  await assert.rejects(() => pod.exchangeAuthorizationCode(r2, proxyCode, verifier, CLIENT_REDIRECT), /Invalid client for authorization code/);
396
+ // Failed binding must not burn the code for the legitimate client.
397
+ const tokens = await pod.exchangeAuthorizationCode(r1, proxyCode, verifier, CLIENT_REDIRECT);
398
+ assert.equal(tokens.access_token, "gl-access-123");
319
399
  });
320
400
  test("exchangeAuthorizationCode rejects wrong redirect_uri", async () => {
321
401
  const s = secret();
@@ -338,6 +418,9 @@ describe("callback-proxy cross-pod flow (stateless)", () => {
338
418
  await pod.handleCallback(fakeReq({ code: "g", state: proxyState }), cbRes);
339
419
  const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
340
420
  await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, verifier, "https://attacker.example/cb"), /Invalid redirect_uri for authorization code/);
421
+ // Failed binding must not burn the code for the legitimate client.
422
+ const tokens = await pod.exchangeAuthorizationCode(registered, proxyCode, verifier, CLIENT_REDIRECT);
423
+ assert.equal(tokens.access_token, "gl-access-123");
341
424
  });
342
425
  test("stale state rejected at /callback", async () => {
343
426
  const s = secret();
@@ -373,6 +456,32 @@ describe("callback-proxy cross-pod flow (stateless)", () => {
373
456
  const tokens = await pod.exchangeAuthorizationCode(registered, proxyCode, verifier, CLIENT_REDIRECT);
374
457
  assert.equal(tokens.access_token, "gl-access-123");
375
458
  });
459
+ test("legacy: failed PKCE does not burn the code; legitimate exchange still succeeds", async () => {
460
+ const pod = makeProvider(null);
461
+ const registered = await pod.clientsStore.registerClient({
462
+ redirect_uris: [CLIENT_REDIRECT],
463
+ token_endpoint_auth_method: "none",
464
+ });
465
+ const correctVerifier = randomBytes(32).toString("base64url");
466
+ const correctChallenge = createHash("sha256")
467
+ .update(correctVerifier)
468
+ .digest("base64url");
469
+ const aRes = makeAuthorizeRes();
470
+ await pod.authorize(registered, {
471
+ state: "s",
472
+ scopes: ["api"],
473
+ redirectUri: CLIENT_REDIRECT,
474
+ codeChallenge: correctChallenge,
475
+ }, aRes);
476
+ const legacyState = new URL(aRes.redirectedTo).searchParams.get("state");
477
+ const cbRes = makeFakeRes();
478
+ await pod.handleCallback(fakeReq({ code: "g", state: legacyState }), cbRes);
479
+ const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
480
+ const wrongVerifier = randomBytes(32).toString("base64url");
481
+ await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, wrongVerifier, CLIENT_REDIRECT), /PKCE verification failed/);
482
+ const tokens = await pod.exchangeAuthorizationCode(registered, proxyCode, correctVerifier, CLIENT_REDIRECT);
483
+ assert.equal(tokens.access_token, "gl-access-123");
484
+ });
376
485
  test("different secret on pod B rejects the sealed state", async () => {
377
486
  const podA = makeProvider(loadMaterial(secret()));
378
487
  const podB = makeProvider(loadMaterial(secret())); // different secret
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Unit tests for ConsumedProxyCodeCache — TTL-aware replay prevention.
3
+ *
4
+ * Expectation (CWE-294): consumed hashes remain rejected until the configured
5
+ * code TTL expires; the cache never LRU-evicts early; inserts fail closed when
6
+ * still-valid entries fill capacity.
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import { describe, test } from "node:test";
10
+ import { ConsumedProxyCodeCache, PROXY_CODE_CACHE_FULL, } from "../../stateless/consumed-proxy-code-cache.js";
11
+ function reserveId(cache, key, ttlSeconds) {
12
+ const result = cache.tryReserve(key, ttlSeconds);
13
+ assert.equal(result.ok, true);
14
+ if (!result.ok) {
15
+ throw new Error("expected reservation to succeed");
16
+ }
17
+ return result.reservationId;
18
+ }
19
+ describe("ConsumedProxyCodeCache", () => {
20
+ test("rejects replay of a committed hash for the full configured TTL", () => {
21
+ let now = 1_000_000;
22
+ const cache = new ConsumedProxyCodeCache(10, () => now);
23
+ const ttlSeconds = 120;
24
+ const id = reserveId(cache, "hash-a", ttlSeconds);
25
+ cache.commit("hash-a", id);
26
+ // Still within TTL — must reject as consumed.
27
+ now += (ttlSeconds - 1) * 1000;
28
+ assert.deepEqual(cache.tryReserve("hash-a", ttlSeconds), {
29
+ ok: false,
30
+ reason: "consumed",
31
+ });
32
+ // Exactly at expiry boundary — still rejected while expiresAt > now.
33
+ now += 999;
34
+ assert.deepEqual(cache.tryReserve("hash-a", ttlSeconds), {
35
+ ok: false,
36
+ reason: "consumed",
37
+ });
38
+ // After TTL — purged; a new reservation is allowed (sealed code itself
39
+ // would also be expired by openStoredTokensCode at this point).
40
+ now += 1;
41
+ assert.equal(cache.tryReserve("hash-a", ttlSeconds).ok, true);
42
+ });
43
+ test("never LRU-evicts a live entry; fails closed when full", () => {
44
+ const now = 5_000_000;
45
+ const maxSize = 3;
46
+ const cache = new ConsumedProxyCodeCache(maxSize, () => now);
47
+ const ttlSeconds = 600;
48
+ cache.commit("h1", reserveId(cache, "h1", ttlSeconds));
49
+ cache.commit("h2", reserveId(cache, "h2", ttlSeconds));
50
+ cache.commit("h3", reserveId(cache, "h3", ttlSeconds));
51
+ assert.equal(cache.size, 3);
52
+ // A fourth insert must fail closed — not silently drop h1.
53
+ assert.throws(() => cache.tryReserve("h4", ttlSeconds), (err) => {
54
+ return err.message === PROXY_CODE_CACHE_FULL;
55
+ });
56
+ // Original consumed hashes are still covered through the TTL window.
57
+ assert.deepEqual(cache.tryReserve("h1", ttlSeconds), {
58
+ ok: false,
59
+ reason: "consumed",
60
+ });
61
+ assert.deepEqual(cache.tryReserve("h2", ttlSeconds), {
62
+ ok: false,
63
+ reason: "consumed",
64
+ });
65
+ assert.deepEqual(cache.tryReserve("h3", ttlSeconds), {
66
+ ok: false,
67
+ reason: "consumed",
68
+ });
69
+ assert.equal(cache.size, 3);
70
+ });
71
+ test("purge of expired entries frees capacity without reopening live replays", () => {
72
+ let now = 9_000_000;
73
+ const cache = new ConsumedProxyCodeCache(2, () => now);
74
+ cache.commit("old", reserveId(cache, "old", 10));
75
+ cache.commit("live", reserveId(cache, "live", 600));
76
+ assert.equal(cache.size, 2);
77
+ // Expire only "old" so a new insert can reclaim that slot.
78
+ now += 10_000;
79
+ cache.commit("fresh", reserveId(cache, "fresh", 600));
80
+ assert.equal(cache.size, 2);
81
+ // Still-valid "live" hash must remain rejected through its TTL.
82
+ assert.deepEqual(cache.tryReserve("live", 600), {
83
+ ok: false,
84
+ reason: "consumed",
85
+ });
86
+ // Cache is full of non-expired entries — fail closed, do not LRU-evict.
87
+ assert.throws(() => cache.tryReserve("another", 600), (err) => {
88
+ return err.message === PROXY_CODE_CACHE_FULL;
89
+ });
90
+ });
91
+ test("release drops pending reservation but not committed entries", () => {
92
+ const now = 2_000_000;
93
+ const cache = new ConsumedProxyCodeCache(10, () => now);
94
+ const pendingId = reserveId(cache, "pending", 60);
95
+ cache.release("pending", pendingId);
96
+ assert.equal(cache.size, 0);
97
+ const retryId = reserveId(cache, "pending", 60);
98
+ cache.commit("pending", retryId);
99
+ cache.release("pending", retryId);
100
+ assert.equal(cache.size, 1);
101
+ assert.deepEqual(cache.tryReserve("pending", 60), {
102
+ ok: false,
103
+ reason: "consumed",
104
+ });
105
+ });
106
+ test("pending reservation also blocks concurrent replay attempts", () => {
107
+ const cache = new ConsumedProxyCodeCache(10, () => 3_000_000);
108
+ assert.equal(cache.tryReserve("in-flight", 120).ok, true);
109
+ assert.deepEqual(cache.tryReserve("in-flight", 120), {
110
+ ok: false,
111
+ reason: "pending",
112
+ });
113
+ });
114
+ test("non-finite TTL still blocks a second reservation for the same key", () => {
115
+ const cache = new ConsumedProxyCodeCache(10, () => 4_000_000);
116
+ assert.equal(cache.tryReserve("nan-ttl", Number.NaN).ok, true);
117
+ assert.deepEqual(cache.tryReserve("nan-ttl", Number.NaN), {
118
+ ok: false,
119
+ reason: "pending",
120
+ });
121
+ });
122
+ test("extreme finite TTL keeps expiresAt finite and evictable", () => {
123
+ let now = 1_000_000;
124
+ const cache = new ConsumedProxyCodeCache(10, () => now);
125
+ cache.commit("max-ttl", reserveId(cache, "max-ttl", Number.MAX_VALUE));
126
+ assert.deepEqual(cache.tryReserve("max-ttl", Number.MAX_VALUE), {
127
+ ok: false,
128
+ reason: "consumed",
129
+ });
130
+ // Advance past the capped expiry so purgeExpired can reclaim the slot.
131
+ now += Math.floor(Number.MAX_SAFE_INTEGER / 1000) * 1000 + 1;
132
+ assert.equal(cache.tryReserve("max-ttl", 60).ok, true);
133
+ });
134
+ test("stale reservation id cannot release or commit a newer reservation", () => {
135
+ let now = 1_000_000;
136
+ const cache = new ConsumedProxyCodeCache(10, () => now);
137
+ const ttlSeconds = 10;
138
+ const staleId = reserveId(cache, "key", ttlSeconds);
139
+ now += 11_000;
140
+ const liveId = reserveId(cache, "key", ttlSeconds);
141
+ cache.release("key", staleId);
142
+ assert.deepEqual(cache.tryReserve("key", ttlSeconds), {
143
+ ok: false,
144
+ reason: "pending",
145
+ });
146
+ cache.commit("key", staleId);
147
+ cache.release("key", liveId);
148
+ assert.equal(cache.tryReserve("key", ttlSeconds).ok, true);
149
+ });
150
+ });
@@ -2,6 +2,7 @@ import { describe, test, before, after } from 'node:test';
2
2
  import assert from 'node:assert';
3
3
  import { spawn } from 'node:child_process';
4
4
  import fs from 'node:fs';
5
+ import os from 'node:os';
5
6
  import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
6
7
  const MOCK_TOKEN = 'glpat-mock-token-12345';
7
8
  const TEST_PROJECT_ID = '123';
@@ -125,6 +126,32 @@ describe('download_attachment', () => {
125
126
  raw.result.content.some(c => typeof c.text === 'string' && c.text.toLowerCase().includes('traversal'));
126
127
  assert.ok(isRpcError || isContentError, 'Should return an error mentioning directory traversal');
127
128
  });
129
+ test('local_path through a symlink directory is rejected', async () => {
130
+ const linkDir = `omc-test-symlink-${RUN_ID}`;
131
+ fs.rmSync(linkDir, { recursive: true, force: true });
132
+ fs.symlinkSync(os.tmpdir(), linkDir);
133
+ try {
134
+ const raw = await callDownloadAttachment({
135
+ project_id: TEST_PROJECT_ID,
136
+ secret: TEST_SECRET,
137
+ filename: 'image.png',
138
+ local_path: linkDir,
139
+ }, env);
140
+ const msg = (typeof raw.error?.message === 'string' ? raw.error.message : '') +
141
+ (Array.isArray(raw.result?.content)
142
+ ? raw.result.content.map(c => c.text || '').join(' ')
143
+ : '');
144
+ const lower = msg.toLowerCase();
145
+ assert.ok(lower.includes('symbolic') ||
146
+ lower.includes('symlink') ||
147
+ lower.includes('escapes') ||
148
+ lower.includes('traversal') ||
149
+ lower.includes('invalid'), `Expected symlink rejection, got: ${msg}`);
150
+ }
151
+ finally {
152
+ fs.rmSync(linkDir, { recursive: true, force: true });
153
+ }
154
+ });
128
155
  test('filename with directory traversal is rejected before writing to disk', async () => {
129
156
  const raw = await callDownloadAttachment({ project_id: TEST_PROJECT_ID, secret: TEST_SECRET, filename: 'image.png#../../owned.txt' }, env);
130
157
  const isRpcError = typeof raw.error?.message === 'string' &&