@zereight/mcp-gitlab 2.2.6 → 2.3.1

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/build/oauth.js CHANGED
@@ -44,6 +44,117 @@ export function isAuthInvalidTokenResponse(status, bodyText) {
44
44
  return false;
45
45
  }
46
46
  }
47
+ // Shared OAuth callback servers started by this process
48
+ const sharedOAuthServers = new Set();
49
+ /**
50
+ * Stop the shared OAuth callback servers started by this process.
51
+ */
52
+ export async function closeSharedOAuthServersAsync() {
53
+ const servers = [...sharedOAuthServers];
54
+ sharedOAuthServers.clear();
55
+ await Promise.all(servers.map(server => new Promise(resolve => {
56
+ server.close(() => resolve());
57
+ server.closeAllConnections();
58
+ })));
59
+ }
60
+ const SHARED_OAUTH_KEY_HEADER = "x-gitlab-mcp-oauth-key";
61
+ /**
62
+ * Key file for the shared OAuth callback server on `port`. Sibling processes
63
+ * compute the same path, so they need the same OS user and home directory.
64
+ */
65
+ function sharedOAuthKeyPath(port) {
66
+ return path.join(os.homedir(), `.gitlab-mcp-oauth-${port}.key`);
67
+ }
68
+ /**
69
+ * Write the key to an owner-only file without following symlinks. The caller
70
+ * holds the port, so an existing regular file of ours is left from an earlier run.
71
+ */
72
+ function writeSharedOAuthKey(keyPath, key) {
73
+ const flags = fs.constants.O_WRONLY |
74
+ fs.constants.O_CREAT |
75
+ fs.constants.O_EXCL |
76
+ (fs.constants.O_NOFOLLOW ?? 0);
77
+ let fd;
78
+ try {
79
+ fd = fs.openSync(keyPath, flags, 0o600);
80
+ }
81
+ catch (error) {
82
+ if (error.code !== "EEXIST") {
83
+ throw error;
84
+ }
85
+ const stat = fs.lstatSync(keyPath);
86
+ const uid = process.getuid?.();
87
+ if (!stat.isFile() || (uid !== undefined && stat.uid !== uid)) {
88
+ throw new Error(`Refusing to replace ${keyPath}: not a regular file owned by the current user`);
89
+ }
90
+ fs.unlinkSync(keyPath);
91
+ fd = fs.openSync(keyPath, flags, 0o600);
92
+ }
93
+ try {
94
+ fs.writeSync(fd, key);
95
+ }
96
+ finally {
97
+ fs.closeSync(fd);
98
+ }
99
+ }
100
+ function removeSharedOAuthKey(keyPath, key) {
101
+ try {
102
+ if (fs.readFileSync(keyPath, "utf8").trim() === key) {
103
+ fs.unlinkSync(keyPath);
104
+ }
105
+ }
106
+ catch {
107
+ // Best effort
108
+ }
109
+ }
110
+ async function readSharedOAuthKeyAsync(port) {
111
+ const keyPath = sharedOAuthKeyPath(port);
112
+ let problem = "ENOENT";
113
+ // The other process writes its key right after it binds the port.
114
+ for (let attempt = 0; attempt < 5; attempt++) {
115
+ if (attempt > 0) {
116
+ await new Promise(resolve => setTimeout(resolve, 100));
117
+ }
118
+ try {
119
+ const key = fs.readFileSync(keyPath, "utf8").trim();
120
+ if (/^[0-9a-f]{64}$/.test(key)) {
121
+ return key;
122
+ }
123
+ problem = "unexpected content";
124
+ }
125
+ catch (error) {
126
+ problem = error.code ?? String(error);
127
+ if (problem !== "ENOENT") {
128
+ break;
129
+ }
130
+ }
131
+ }
132
+ throw new Error(`cannot read OAuth server key ${keyPath} (${problem}); the process holding port ${port} ` +
133
+ "must run as the same OS user with the same home directory");
134
+ }
135
+ /**
136
+ * Host headers the shared OAuth callback server answers: loopback names with
137
+ * its port, plus the configured redirect URI host. The listener is bound to
138
+ * 127.0.0.1, so any other Host comes from a name that merely resolves there
139
+ * (DNS rebinding).
140
+ */
141
+ function sharedOAuthAllowedHosts(port, redirectUri) {
142
+ const hosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
143
+ try {
144
+ hosts.add(new URL(redirectUri).host.toLowerCase());
145
+ }
146
+ catch {
147
+ // Malformed redirectUri: loopback names only
148
+ }
149
+ return hosts;
150
+ }
151
+ function sharedOAuthKeyMatches(provided, expected) {
152
+ if (typeof provided !== "string") {
153
+ return false;
154
+ }
155
+ const digest = (value) => crypto.createHash("sha256").update(value).digest();
156
+ return crypto.timingSafeEqual(digest(provided), digest(expected));
157
+ }
47
158
  /**
48
159
  * Check if a port is already in use
49
160
  */
@@ -69,12 +180,14 @@ async function isPortInUse(port) {
69
180
  * Request authentication from an existing OAuth server
70
181
  */
71
182
  async function requestAuthFromExistingServer(port, requestId) {
183
+ const key = await readSharedOAuthKeyAsync(port);
72
184
  return new Promise((resolve, reject) => {
73
185
  const options = {
74
186
  hostname: "127.0.0.1",
75
187
  port: port,
76
188
  path: `/auth-request?requestId=${requestId}`,
77
189
  method: "GET",
190
+ headers: { [SHARED_OAUTH_KEY_HEADER]: key },
78
191
  };
79
192
  const req = http.request(options, res => {
80
193
  let data = "";
@@ -91,6 +204,9 @@ async function requestAuthFromExistingServer(port, requestId) {
91
204
  reject(new Error(`Failed to parse token data: ${error}`));
92
205
  }
93
206
  }
207
+ else if (res.statusCode === 403) {
208
+ reject(new Error(`OAuth server on port ${port} rejected the request key; it may run as a different OS user or an older version`));
209
+ }
94
210
  else {
95
211
  reject(new Error(`Auth request failed with status ${res.statusCode}: ${data}`));
96
212
  }
@@ -288,7 +404,8 @@ export class GitLabOAuth {
288
404
  }
289
405
  catch (error) {
290
406
  logger.error({ err: error }, "Failed to connect to existing OAuth server");
291
- throw new Error(`Port ${callbackPort} is in use but cannot connect to existing OAuth server. Please close other instances or use a different port.`);
407
+ const reason = error instanceof Error ? error.message : String(error);
408
+ throw new Error(`Port ${callbackPort} is in use but cannot connect to existing OAuth server: ${reason}. Please close other instances or use a different port.`);
292
409
  }
293
410
  }
294
411
  // Port is free, start the shared OAuth server
@@ -300,6 +417,9 @@ export class GitLabOAuth {
300
417
  async startSharedOAuthServer(callbackPort, initialRequestId) {
301
418
  const stateToRequestId = new Map();
302
419
  const requestIdToOAuthInstance = new Map();
420
+ const allowedHosts = sharedOAuthAllowedHosts(callbackPort, this.config.redirectUri);
421
+ const keyPath = sharedOAuthKeyPath(callbackPort);
422
+ const key = crypto.randomBytes(32).toString("hex");
303
423
  return new Promise((resolve, reject) => {
304
424
  // Create initial request
305
425
  const state = crypto.randomUUID();
@@ -312,9 +432,20 @@ export class GitLabOAuth {
312
432
  pendingAuthRequests.set(initialRequestId, { resolve, reject, timeout });
313
433
  const server = http.createServer(async (req, res) => {
314
434
  try {
435
+ // Only answer requests addressed to this listener by an expected name
436
+ if (!allowedHosts.has((req.headers.host || "").toLowerCase())) {
437
+ res.writeHead(403, { "Content-Type": "text/plain" });
438
+ res.end("Forbidden");
439
+ return;
440
+ }
315
441
  const parsedUrl = url.parse(req.url || "", true);
316
442
  // Handle auth requests from other MCP instances
317
443
  if (parsedUrl.pathname === "/auth-request") {
444
+ if (!sharedOAuthKeyMatches(req.headers[SHARED_OAUTH_KEY_HEADER], key)) {
445
+ res.writeHead(403, { "Content-Type": "text/plain" });
446
+ res.end("Forbidden");
447
+ return;
448
+ }
318
449
  const newRequestId = parsedUrl.query.requestId;
319
450
  if (!newRequestId) {
320
451
  res.writeHead(400, { "Content-Type": "text/plain" });
@@ -331,7 +462,7 @@ export class GitLabOAuth {
331
462
  const authUrl = await this.getAuthorizationUrl(newState);
332
463
  logger.info("Opening browser for new authentication request...");
333
464
  logger.info(`If browser doesn't open, visit: ${redactUrlSecretsInText(authUrl)}`);
334
- open(authUrl).catch(err => {
465
+ (this.config.openBrowser ?? open)(authUrl).catch(err => {
335
466
  logger.error({ err }, "Failed to open browser");
336
467
  logger.info(`Please manually open: ${redactUrlSecretsInText(authUrl)}`);
337
468
  });
@@ -483,11 +614,24 @@ export class GitLabOAuth {
483
614
  }
484
615
  });
485
616
  server.listen(callbackPort, "127.0.0.1", async () => {
617
+ sharedOAuthServers.add(server);
618
+ try {
619
+ writeSharedOAuthKey(keyPath, key);
620
+ const removeKey = () => removeSharedOAuthKey(keyPath, key);
621
+ process.once("exit", removeKey);
622
+ server.once("close", () => {
623
+ process.removeListener("exit", removeKey);
624
+ removeKey();
625
+ });
626
+ }
627
+ catch (error) {
628
+ logger.warn({ err: error }, `Failed to write ${keyPath}; other MCP instances cannot use this OAuth callback server`);
629
+ }
486
630
  logger.info(`Shared OAuth callback server listening on port ${callbackPort}`);
487
631
  const authUrl = await this.getAuthorizationUrl(state);
488
632
  logger.info("Opening browser for authentication...");
489
633
  logger.info(`If browser doesn't open, visit: ${redactUrlSecretsInText(authUrl)}`);
490
- open(authUrl).catch(err => {
634
+ (this.config.openBrowser ?? open)(authUrl).catch(err => {
491
635
  logger.error({ err }, "Failed to open browser");
492
636
  logger.info(`Please manually open: ${redactUrlSecretsInText(authUrl)}`);
493
637
  });