@zereight/mcp-gitlab 2.1.45 → 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.
- package/README.ko.md +4 -1
- package/README.md +135 -129
- package/README.zh-CN.md +4 -1
- package/build/config.js +1 -1
- package/build/index.js +87 -122
- package/build/oauth-proxy.js +151 -87
- package/build/schemas.js +20 -2
- package/build/server/request-helpers.js +63 -0
- package/build/server/version.js +28 -0
- package/build/stateless/consumed-proxy-code-cache.js +82 -0
- package/build/stateless/index.js +1 -0
- package/build/stateless/stored-tokens.js +4 -2
- package/build/test/mcp-oauth-tests.js +96 -0
- package/build/test/nullable-gitlab-response-fields.test.js +16 -1
- package/build/test/server/request-helpers.test.js +33 -0
- package/build/test/server/version.test.js +13 -0
- package/build/test/stateless/callback-proxy.test.js +109 -0
- package/build/test/stateless/consumed-proxy-code-cache.test.js +150 -0
- package/build/test/test-download-attachment.js +27 -0
- package/build/test/test-get-file-contents.js +128 -0
- package/build/test/test-job-artifacts.js +27 -2
- package/build/test/test-list-group-members.js +150 -0
- package/build/test/test-merge-request-approval-state-tools.js +42 -0
- package/build/test/test-toolset-filtering.js +1 -1
- package/build/test/test-upload-markdown.js +38 -7
- package/build/test/utils/helpers-path.test.js +104 -0
- package/build/tools/registry.js +8 -1
- package/build/utils/helpers.js +205 -0
- package/build/utils/logger.js +4 -0
- package/package.json +2 -2
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Produce a safe short form of a session id for logs. Legacy UUIDs pass
|
|
3
|
+
* through; stateless sealed sids are truncated to the "v1.sid." prefix
|
|
4
|
+
* plus a handful of bytes of the ciphertext so operators can correlate
|
|
5
|
+
* flows without the log line carrying the sealed bearer token.
|
|
6
|
+
*/
|
|
7
|
+
export function redactSessionIdForLog(sid) {
|
|
8
|
+
if (!sid)
|
|
9
|
+
return "<none>";
|
|
10
|
+
if (sid.startsWith("v1.sid."))
|
|
11
|
+
return "v1.sid.<redacted>";
|
|
12
|
+
// UUIDs / other formats are low-sensitivity; show first 8 chars.
|
|
13
|
+
return sid.length > 8 ? `${sid.slice(0, 8)}…` : sid;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Detect whether an MCP JSON-RPC request body represents an "initialize"
|
|
17
|
+
* request. Accepts both single-message and batch forms. Returns false on any
|
|
18
|
+
* unexpected shape — callers treat an ambiguous body as non-init, which is
|
|
19
|
+
* the safer default (it means the SDK will fail loudly rather than silently
|
|
20
|
+
* spawning a new session).
|
|
21
|
+
*/
|
|
22
|
+
export function isInitializationRequestBody(body) {
|
|
23
|
+
if (!body)
|
|
24
|
+
return false;
|
|
25
|
+
const isInitObj = (m) => typeof m === "object" && m !== null && m.method === "initialize";
|
|
26
|
+
if (Array.isArray(body))
|
|
27
|
+
return body.some(isInitObj);
|
|
28
|
+
return isInitObj(body);
|
|
29
|
+
}
|
|
30
|
+
export function isUnauthenticatedDiscoveryRequestBody(body) {
|
|
31
|
+
if (!body)
|
|
32
|
+
return false;
|
|
33
|
+
const isDiscoveryMethod = (m) => {
|
|
34
|
+
if (typeof m !== "object" || m === null)
|
|
35
|
+
return false;
|
|
36
|
+
const method = m.method;
|
|
37
|
+
return (method === "initialize" || method === "notifications/initialized" || method === "tools/list");
|
|
38
|
+
};
|
|
39
|
+
if (Array.isArray(body))
|
|
40
|
+
return body.every(isDiscoveryMethod);
|
|
41
|
+
return isDiscoveryMethod(body);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Normalize an `Mcp-Session-Id` header value.
|
|
45
|
+
*
|
|
46
|
+
* Node's HTTP types allow any request header to surface as `string[]` when
|
|
47
|
+
* the client sends it more than once. Casting to `string` and calling
|
|
48
|
+
* `.startsWith()` on an array throws `TypeError: startsWith is not a
|
|
49
|
+
* function`, which Express converts to a 500 — silently turning malformed
|
|
50
|
+
* requests into server errors and breaking the 401/404 semantics we
|
|
51
|
+
* carefully distinguish in stateless mode. A duplicated `Mcp-Session-Id` is
|
|
52
|
+
* also ill-formed at the protocol level: there is no well-defined way to
|
|
53
|
+
* pick between two values, so we reject arrays rather than guess.
|
|
54
|
+
*
|
|
55
|
+
* Empty-string is normalized to `undefined` so call sites can use truthy
|
|
56
|
+
* checks and `?? undefined`-style fallbacks uniformly.
|
|
57
|
+
*/
|
|
58
|
+
export function readMcpSessionIdHeader(req) {
|
|
59
|
+
const raw = req.headers["mcp-session-id"];
|
|
60
|
+
if (typeof raw !== "string")
|
|
61
|
+
return undefined;
|
|
62
|
+
return raw.length > 0 ? raw : undefined;
|
|
63
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path, { dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
5
|
+
const __dirname = dirname(__filename);
|
|
6
|
+
const packageJsonPaths = [
|
|
7
|
+
// Source file under tsx/node:test: server/version.ts -> ../package.json
|
|
8
|
+
path.resolve(__dirname, "../package.json"),
|
|
9
|
+
// Built file: build/server/version.js -> ../../package.json
|
|
10
|
+
path.resolve(__dirname, "../../package.json"),
|
|
11
|
+
];
|
|
12
|
+
export const SERVER_VERSION = (() => {
|
|
13
|
+
for (const packageJsonPath of packageJsonPaths) {
|
|
14
|
+
try {
|
|
15
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
16
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
17
|
+
const version = packageJson.version;
|
|
18
|
+
if (typeof version === "string" && version.length > 0) {
|
|
19
|
+
return version;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Intentionally ignored: version read failure is non-critical.
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return "unknown";
|
|
28
|
+
})();
|
|
@@ -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
|
+
}
|
package/build/stateless/index.js
CHANGED
|
@@ -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
|
|
19
|
-
* - short TTL (OAUTH_STATELESS_STORED_TTL_SECONDS, default
|
|
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
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path, { dirname } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { SERVER_VERSION } from "../../server/version.js";
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
const packageJsonPath = path.resolve(__dirname, "../../package.json");
|
|
10
|
+
const packageVersion = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")).version;
|
|
11
|
+
test("SERVER_VERSION matches package.json", () => {
|
|
12
|
+
assert.strictEqual(SERVER_VERSION, packageVersion);
|
|
13
|
+
});
|
|
@@ -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
|
+
});
|