@zereight/mcp-gitlab 2.3.5-next.124 → 2.3.5-next.125
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 +2 -2
- package/README.md +2 -2
- package/README.pt-BR.md +2 -2
- package/README.zh-CN.md +2 -2
- package/build/auth-cli.js +9 -1
- package/build/cli/output.js +2 -1
- package/build/index.js +52 -20
- package/build/utils/logger.js +6 -0
- package/build/utils/redact-url.js +446 -50
- package/build/utils/safe-redirect-fetch.js +3 -1
- package/build/utils/url.js +22 -0
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -304,7 +304,7 @@ MCP 서버가 직접 로컬 브라우저 callback을 받을 때만 `GITLAB_OAUTH
|
|
|
304
304
|
| 환경 변수 | 필수 | 설명 |
|
|
305
305
|
| ----------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
306
306
|
| `GITLAB_MCP_OAUTH` | 예 | 활성화하려면 `true` |
|
|
307
|
-
| `GITLAB_API_URL` | 예 | GitLab API base URL
|
|
307
|
+
| `GITLAB_API_URL` | 예 | GitLab API base URL. URL에 자격 증명(`user:pass@`)을 넣지 마세요. 넣어도 무시됩니다. |
|
|
308
308
|
| `GITLAB_OAUTH_APP_ID` | 예 | GitLab OAuth Application ID |
|
|
309
309
|
| `MCP_SERVER_URL` | 예 | 이 MCP 서버의 공개 HTTPS URL |
|
|
310
310
|
| `STREAMABLE_HTTP` | 예 | 반드시 `true` |
|
|
@@ -572,7 +572,7 @@ node build/index.js
|
|
|
572
572
|
| `GITLAB_MCP_OAUTH` | 예 | 활성화하려면 `true` |
|
|
573
573
|
| `GITLAB_OAUTH_APP_ID` | 예 | 사전 등록 GitLab OAuth 애플리케이션의 client ID |
|
|
574
574
|
| `MCP_SERVER_URL` | 예 | MCP 서버의 공개 HTTPS URL |
|
|
575
|
-
| `GITLAB_API_URL` | 예 | GitLab 인스턴스 API URL(예: `https://gitlab.com/api/v4`)
|
|
575
|
+
| `GITLAB_API_URL` | 예 | GitLab 인스턴스 API URL(예: `https://gitlab.com/api/v4`). URL에 자격 증명(`user:pass@`)을 넣지 마세요. 넣어도 무시됩니다. |
|
|
576
576
|
| `STREAMABLE_HTTP` | 예 | 반드시 `true`(SSE 미지원) |
|
|
577
577
|
| `GITLAB_OAUTH_SCOPES` | 아니오 | 요청할 GitLab scope 목록(쉼표 구분). 기본값은 `api`이며, 유효 permission mode가 `readonly`일 때 `read_api`입니다. 사전 등록 애플리케이션에 해당 scope가 설정되어 있어야 합니다. |
|
|
578
578
|
| `OAUTH_REGISTER_RATE_LIMIT_PER_HOUR` | 아니오 | Dynamic Client Registration(`POST /register`)의 클라이언트 IP당 rolling 한도. 기본 `20`/시간, 범위 `1`–`1000`. IDE 창 여러 개 등으로 등록이 막히면 올리세요. GitLab API 한도와 무관합니다. |
|
package/README.md
CHANGED
|
@@ -326,7 +326,7 @@ exchanging credentials with GitLab on behalf of the client.
|
|
|
326
326
|
| Environment Variable | Required | Description |
|
|
327
327
|
| ----------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
328
328
|
| `GITLAB_MCP_OAUTH` | ✅ | Set to `true` to enable |
|
|
329
|
-
| `GITLAB_API_URL` | ✅ | GitLab API base URL
|
|
329
|
+
| `GITLAB_API_URL` | ✅ | GitLab API base URL. Do not embed credentials (`user:pass@`) in the URL; they are ignored. |
|
|
330
330
|
| `GITLAB_OAUTH_APP_ID` | ✅ | GitLab OAuth Application ID |
|
|
331
331
|
| `MCP_SERVER_URL` | ✅ | Public HTTPS URL of this MCP server |
|
|
332
332
|
| `STREAMABLE_HTTP` | ✅ | Must be `true` |
|
|
@@ -614,7 +614,7 @@ No `headers` field is needed — Claude.ai obtains the token via OAuth automatic
|
|
|
614
614
|
| `GITLAB_MCP_OAUTH` | Yes | Set to `true` to enable |
|
|
615
615
|
| `GITLAB_OAUTH_APP_ID` | Yes | Client ID of the pre-registered GitLab OAuth application |
|
|
616
616
|
| `MCP_SERVER_URL` | Yes | Public HTTPS URL of your MCP server; also allowed for `/mcp` Host/Origin checks |
|
|
617
|
-
| `GITLAB_API_URL` | Yes | Your GitLab instance API URL (e.g. `https://gitlab.com/api/v4`)
|
|
617
|
+
| `GITLAB_API_URL` | Yes | Your GitLab instance API URL (e.g. `https://gitlab.com/api/v4`). Do not embed credentials (`user:pass@`) in the URL; they are ignored. |
|
|
618
618
|
| `STREAMABLE_HTTP` | Yes | Must be `true` (SSE is not supported) |
|
|
619
619
|
| `GITLAB_OAUTH_SCOPES` | No | Comma-separated GitLab scopes to request (e.g. `api,read_user`). Defaults to `api` (or `read_api` when the effective permission mode is `readonly`). The pre-registered application must be configured with at least these scopes. |
|
|
620
620
|
| `OAUTH_REGISTER_RATE_LIMIT_PER_HOUR` | No | Per-IP rolling limit for Dynamic Client Registration (`POST /register`). Default `20`/hour; range `1`–`1000`. Raise when clients (e.g. multiple IDE windows) hit registration throttling. Not a GitLab API limit. |
|
package/README.pt-BR.md
CHANGED
|
@@ -304,7 +304,7 @@ Use `GITLAB_OAUTH_REDIRECT_URI` somente quando o próprio servidor MCP receber o
|
|
|
304
304
|
| Variável de ambiente | Obrigatória | Descrição |
|
|
305
305
|
| ----------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
306
306
|
| `GITLAB_MCP_OAUTH` | sim | Defina como `true` para habilitar |
|
|
307
|
-
| `GITLAB_API_URL` | sim | URL base da API do GitLab
|
|
307
|
+
| `GITLAB_API_URL` | sim | URL base da API do GitLab. Não inclua credenciais (`user:pass@`) na URL; elas são ignoradas. |
|
|
308
308
|
| `GITLAB_OAUTH_APP_ID` | sim | Application ID do OAuth do GitLab |
|
|
309
309
|
| `MCP_SERVER_URL` | sim | URL HTTPS pública deste servidor MCP |
|
|
310
310
|
| `STREAMABLE_HTTP` | sim | Deve ser `true` |
|
|
@@ -572,7 +572,7 @@ Não é necessário definir `headers`. O Claude.ai obtém o token via OAuth.
|
|
|
572
572
|
| `GITLAB_MCP_OAUTH` | sim | Defina como `true` para habilitar |
|
|
573
573
|
| `GITLAB_OAUTH_APP_ID` | sim | Client ID do aplicativo OAuth do GitLab previamente registrado |
|
|
574
574
|
| `MCP_SERVER_URL` | sim | URL HTTPS pública do servidor MCP |
|
|
575
|
-
| `GITLAB_API_URL` | sim | URL da API da instância GitLab (por exemplo, `https://gitlab.com/api/v4`)
|
|
575
|
+
| `GITLAB_API_URL` | sim | URL da API da instância GitLab (por exemplo, `https://gitlab.com/api/v4`). Não inclua credenciais (`user:pass@`) na URL; elas são ignoradas. |
|
|
576
576
|
| `STREAMABLE_HTTP` | sim | Deve ser `true` (SSE não é suportado) |
|
|
577
577
|
| `GITLAB_OAUTH_SCOPES` | não | Scopes do GitLab a solicitar, separados por vírgula. O padrão é `api`, ou `read_api` quando o permission mode efetivo é `readonly`. O aplicativo previamente registrado deve ter pelo menos esses scopes configurados. |
|
|
578
578
|
| `OAUTH_REGISTER_RATE_LIMIT_PER_HOUR` | não | Limite rolling de Dynamic Client Registration (`POST /register`) por IP do cliente. Padrão `20`/hora, intervalo `1`–`1000`. Aumente se múltiplas janelas de IDE estiverem atingindo o limite. Não está relacionado aos limites da API do GitLab. |
|
package/README.zh-CN.md
CHANGED
|
@@ -303,7 +303,7 @@ OpenCode、MCPJam、Claude.ai 等远程 MCP 客户端可能会在授权时发送
|
|
|
303
303
|
| 环境变量 | 必需 | 说明 |
|
|
304
304
|
| ----------------------------- | ---- | ----------------------------------------------------------------------------------------------------------- |
|
|
305
305
|
| `GITLAB_MCP_OAUTH` | 是 | 设置为 `true` 以启用 |
|
|
306
|
-
| `GITLAB_API_URL` | 是 | GitLab API base URL
|
|
306
|
+
| `GITLAB_API_URL` | 是 | GitLab API base URL。不要在 URL 中嵌入凭据(`user:pass@`),嵌入的凭据会被忽略。 |
|
|
307
307
|
| `GITLAB_OAUTH_APP_ID` | 是 | GitLab OAuth Application ID |
|
|
308
308
|
| `MCP_SERVER_URL` | 是 | 此 MCP 服务器的公开 HTTPS URL |
|
|
309
309
|
| `STREAMABLE_HTTP` | 是 | 必须为 `true` |
|
|
@@ -571,7 +571,7 @@ node build/index.js
|
|
|
571
571
|
| `GITLAB_MCP_OAUTH` | 是 | 设置为 `true` 以启用 |
|
|
572
572
|
| `GITLAB_OAUTH_APP_ID` | 是 | 预先注册的 GitLab OAuth 应用 client ID |
|
|
573
573
|
| `MCP_SERVER_URL` | 是 | MCP 服务器的公开 HTTPS URL |
|
|
574
|
-
| `GITLAB_API_URL` | 是 | GitLab 实例 API URL(例如 `https://gitlab.com/api/v4
|
|
574
|
+
| `GITLAB_API_URL` | 是 | GitLab 实例 API URL(例如 `https://gitlab.com/api/v4`)。不要在 URL 中嵌入凭据(`user:pass@`),嵌入的凭据会被忽略。 |
|
|
575
575
|
| `STREAMABLE_HTTP` | 是 | 必须为 `true`(不支持 SSE) |
|
|
576
576
|
| `GITLAB_OAUTH_SCOPES` | 否 | 要请求的 GitLab scopes,以逗号分隔。默认值为 `api`;有效 permission mode 为 `readonly` 时为 `read_api`。预注册应用必须配置至少这些 scopes。 |
|
|
577
577
|
| `OAUTH_REGISTER_RATE_LIMIT_PER_HOUR` | 否 | Dynamic Client Registration(`POST /register`)的每客户端 IP rolling 限制。默认 `20`/小时,范围 `1`–`1000`。多个 IDE 窗口等导致注册被限流时可调高。与 GitLab API 限额无关。 |
|
package/build/auth-cli.js
CHANGED
|
@@ -4,6 +4,8 @@ import { parseCliArgs } from "./cli-boolean-flags.js";
|
|
|
4
4
|
import { deprecatedEnvInputFromSources, getDeprecatedEnvWarnings } from "./deprecated-env.js";
|
|
5
5
|
import { GitLabOAuth } from "./oauth.js";
|
|
6
6
|
import { createLogger, flushStartupLogs } from "./utils/logger.js";
|
|
7
|
+
import { stripUrlUserinfo } from "./utils/redact-url.js";
|
|
8
|
+
import { apiUrlUserinfoWarning } from "./utils/url.js";
|
|
7
9
|
const logger = createLogger("gitlab-mcp-auth");
|
|
8
10
|
const DEFAULT_API_URL = "https://gitlab.com";
|
|
9
11
|
const DEFAULT_REDIRECT_URI = "http://127.0.0.1:8888/callback";
|
|
@@ -74,7 +76,13 @@ export async function runAuthCommandAsync(input = {}) {
|
|
|
74
76
|
if (!clientId) {
|
|
75
77
|
throw new Error("Missing OAuth client ID. Pass --client-id or set GITLAB_OAUTH_CLIENT_ID.");
|
|
76
78
|
}
|
|
77
|
-
const
|
|
79
|
+
const apiUrlFlag = readFlag(argv, "api-url");
|
|
80
|
+
const configuredApiUrl = stripUrlUserinfo(apiUrlFlag ?? env.GITLAB_API_URL ?? DEFAULT_API_URL);
|
|
81
|
+
if (configuredApiUrl.hadUserinfo) {
|
|
82
|
+
logger.warn(apiUrlUserinfoWarning(apiUrlFlag !== undefined ? "--api-url" : "GITLAB_API_URL"));
|
|
83
|
+
flushStartupLogs(logger);
|
|
84
|
+
}
|
|
85
|
+
const apiUrl = configuredApiUrl.url;
|
|
78
86
|
const tokenPath = readFlag(argv, "token-path") ??
|
|
79
87
|
env.GITLAB_OAUTH_TOKEN_PATH ??
|
|
80
88
|
path.join(os.homedir(), ".gitlab-mcp-token.json");
|
package/build/cli/output.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { renderTable } from "./tables.js";
|
|
2
|
+
import { redactUrlSecretsInText } from "../utils/redact-url.js";
|
|
2
3
|
export function formatToolOutput(input) {
|
|
3
4
|
const isError = resultIsError(input.result);
|
|
4
5
|
const text = unwrapToolText(input.result);
|
|
@@ -58,7 +59,7 @@ function ensureTrailingNewline(text) {
|
|
|
58
59
|
return text.endsWith("\n") ? text : `${text}\n`;
|
|
59
60
|
}
|
|
60
61
|
function redactSecrets(message) {
|
|
61
|
-
return message.replace(/glpat-[A-Za-z0-9_-]+/g, "[REDACTED]");
|
|
62
|
+
return redactUrlSecretsInText(message).replace(/glpat-[A-Za-z0-9_-]+/g, "[REDACTED]");
|
|
62
63
|
}
|
|
63
64
|
function isRecord(value) {
|
|
64
65
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
package/build/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { getConfig, ENABLE_DYNAMIC_API_URL, ENABLE_DYNAMIC_PROJECT_SCOPE, ENABLE_STRICT_PROJECT_SCOPE, GITLAB_AUTH_COOKIE_PATH, GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_MCP_REQUEST_BODY_LIMIT, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_OAUTH_ALLOWED_REDIRECT_URIS_RAW, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_DISABLE_VERSION_CHECK, GITLAB_MASKING_ENABLED, GITLAB_MASKING_CONFIG, GITLAB_MASKING_POLICY_FILE, GITLAB_MASKING_WORKSPACE_DIR, GITLAB_PERMISSION_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, OAUTH_STATELESS_CLIENT_TTL_SECONDS, OAUTH_STATELESS_MODE, OAUTH_STATELESS_PENDING_TTL_SECONDS, OAUTH_STATELESS_SESSION_TTL_SECONDS, OAUTH_STATELESS_STORED_TTL_SECONDS, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, MCP_TRUST_PROXY, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, GITLAB_OAUTH_ALLOWED_GROUPS, GITLAB_MCP_COMPACT_RESULTS, GITLAB_MCP_COMPACT_RESULT_CHARS, GITLAB_MCP_COMPACT_TOOL_NAMES, GITLAB_TOOL_PROFILE, GITLAB_DOWNLOAD_DIR, buildDeprecatedEnvInput, } from "./config.js";
|
|
2
|
+
import { cliArgs, getConfig, ENABLE_DYNAMIC_API_URL, ENABLE_DYNAMIC_PROJECT_SCOPE, ENABLE_STRICT_PROJECT_SCOPE, GITLAB_AUTH_COOKIE_PATH, GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_MCP_REQUEST_BODY_LIMIT, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_OAUTH_ALLOWED_REDIRECT_URIS_RAW, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_DISABLE_VERSION_CHECK, GITLAB_MASKING_ENABLED, GITLAB_MASKING_CONFIG, GITLAB_MASKING_POLICY_FILE, GITLAB_MASKING_WORKSPACE_DIR, GITLAB_PERMISSION_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, OAUTH_STATELESS_CLIENT_TTL_SECONDS, OAUTH_STATELESS_MODE, OAUTH_STATELESS_PENDING_TTL_SECONDS, OAUTH_STATELESS_SESSION_TTL_SECONDS, OAUTH_STATELESS_STORED_TTL_SECONDS, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, MCP_TRUST_PROXY, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, GITLAB_OAUTH_ALLOWED_GROUPS, GITLAB_MCP_COMPACT_RESULTS, GITLAB_MCP_COMPACT_RESULT_CHARS, GITLAB_MCP_COMPACT_TOOL_NAMES, GITLAB_TOOL_PROFILE, GITLAB_DOWNLOAD_DIR, buildDeprecatedEnvInput, } from "./config.js";
|
|
3
3
|
import { getDeprecatedEnvWarnings } from "./deprecated-env.js";
|
|
4
4
|
/** True when the server is running in remote/network mode (SSE or StreamableHTTP transport). */
|
|
5
5
|
const IS_REMOTE = SSE || STREAMABLE_HTTP;
|
|
@@ -93,14 +93,14 @@ import { SERVER_VERSION } from "./server/version.js";
|
|
|
93
93
|
import { listMcpResources, listMcpResourceTemplates, readMcpResource, } from "./server/mcp-resources.js";
|
|
94
94
|
import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readAcceptHeader, readMcpSessionIdHeader, redactSessionIdForLog, } from "./server/request-helpers.js";
|
|
95
95
|
export { readMcpSessionIdHeader } from "./server/request-helpers.js";
|
|
96
|
-
import { encodeGitLabPath, encodeGitLabPathSegment, isCleartextRemoteUrl, normalizeGitLabApiUrl, } from "./utils/url.js";
|
|
96
|
+
import { apiUrlUserinfoWarning, encodeGitLabPath, encodeGitLabPathSegment, isCleartextRemoteUrl, normalizeGitLabApiUrl, parseConfiguredGitLabApiUrls, } from "./utils/url.js";
|
|
97
97
|
import { estimateMergeCommitCount, filterDiffsByPatterns, openSafeOutputWriteStream, resolveDownloadRoot, readSafeExistingFile, summarizeWebhookEvents, } from "./utils/helpers.js";
|
|
98
98
|
import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation, } from "./utils/graphql-query.js";
|
|
99
99
|
import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
|
|
100
100
|
import { encodeRepoFilePayloadContent, fileOperationsIncludeDeleteOrMove, toGitLabCommitActions, } from "./utils/gitlab-commit-actions.js";
|
|
101
101
|
import { redactSensitiveGitLabFields } from "./utils/redact-sensitive.js";
|
|
102
102
|
import { classifyHealthCheckTransportError, describeFetchTransportError, redactGitLabUrlCredentials, } from "./utils/health-check-transport-error.js";
|
|
103
|
-
import { redactUrlSecretsInText, splitCommaSeparatedUrls } from "./utils/redact-url.js";
|
|
103
|
+
import { redactUrlSecretsInError, redactUrlSecretsInErrorMessage, redactUrlSecretsInText, splitCommaSeparatedUrls, } from "./utils/redact-url.js";
|
|
104
104
|
import { createMaskingPolicyResolver, getManagedMaskingProjectIds, } from "./masking/index.js";
|
|
105
105
|
import { checkForNewVersion } from "./utils/version-check.js";
|
|
106
106
|
import { assertGitLabVersionAtLeast } from "./utils/gitlab-version-gate.js";
|
|
@@ -303,9 +303,11 @@ function createServer() {
|
|
|
303
303
|
const isPlainTextResult = toolName === "get_pipeline_job_output" ||
|
|
304
304
|
toolName === "get_job_artifact_file" ||
|
|
305
305
|
(toolName === "download_release_asset" && !IS_REMOTE);
|
|
306
|
-
const filtered = applyJmespathToToolResult(result, jmespathExpression, {
|
|
306
|
+
const filtered = applyJmespathToToolResult(redactErrorResultUrls(result), jmespathExpression, {
|
|
307
307
|
...completionOptions,
|
|
308
|
-
maskValue: maskingEngine
|
|
308
|
+
maskValue: maskingEngine
|
|
309
|
+
? (value) => maskingEngine.maskValue(value)
|
|
310
|
+
: undefined,
|
|
309
311
|
});
|
|
310
312
|
const masked = maskingEngine
|
|
311
313
|
? maskingEngine.maskToolResult(filtered, {
|
|
@@ -322,7 +324,10 @@ function createServer() {
|
|
|
322
324
|
};
|
|
323
325
|
const logError = (error) => {
|
|
324
326
|
const durationMs = Date.now() - start;
|
|
325
|
-
|
|
327
|
+
// fetch() and new URL() quote the URL they rejected, userinfo included. The
|
|
328
|
+
// redacted error is what gets logged, returned, or rethrown to the client.
|
|
329
|
+
const redactedError = redactUrlSecretsInError(error);
|
|
330
|
+
const safeError = maskingEngine ? maskingEngine.maskError(redactedError) : redactedError;
|
|
326
331
|
logger.error({
|
|
327
332
|
tool: toolName,
|
|
328
333
|
event: "tool_call_error",
|
|
@@ -573,6 +578,23 @@ function createServer() {
|
|
|
573
578
|
});
|
|
574
579
|
return mcpServer;
|
|
575
580
|
}
|
|
581
|
+
/**
|
|
582
|
+
* An isError tool result can quote fetch/URL error text too; see redactUrlSecretsInError.
|
|
583
|
+
* Other results carry GitLab data, which is returned as is; a handler that puts
|
|
584
|
+
* a caught error into its data redacts it there (redactUrlSecretsInErrorMessage).
|
|
585
|
+
*/
|
|
586
|
+
function redactErrorResultUrls(result) {
|
|
587
|
+
if (!isRecord(result) || result.isError !== true || !Array.isArray(result.content)) {
|
|
588
|
+
return result;
|
|
589
|
+
}
|
|
590
|
+
// Same shape as the input; only text values change.
|
|
591
|
+
return {
|
|
592
|
+
...result,
|
|
593
|
+
content: result.content.map((item) => isRecord(item) && typeof item.text === "string"
|
|
594
|
+
? { ...item, text: redactUrlSecretsInText(item.text) }
|
|
595
|
+
: item),
|
|
596
|
+
};
|
|
597
|
+
}
|
|
576
598
|
function formatHostWithPort(host, port) {
|
|
577
599
|
const normalized = host
|
|
578
600
|
.trim()
|
|
@@ -856,13 +878,21 @@ let oauthClient = null;
|
|
|
856
878
|
* switch to signed/sealed opaque values instead of per-pod in-memory caches.
|
|
857
879
|
*/
|
|
858
880
|
let STATELESS_MATERIAL = null;
|
|
881
|
+
// Userinfo is dropped here, before the client pool or any request URL is built.
|
|
882
|
+
// Defined before the first emitDeprecatedEnvWarnings() call, which reports it.
|
|
883
|
+
const CONFIGURED_GITLAB_API_URLS = parseConfiguredGitLabApiUrls(getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com");
|
|
859
884
|
let hasEmittedDeprecatedEnvWarnings = false;
|
|
860
885
|
function emitDeprecatedEnvWarnings() {
|
|
861
886
|
if (hasEmittedDeprecatedEnvWarnings) {
|
|
862
887
|
return;
|
|
863
888
|
}
|
|
864
889
|
hasEmittedDeprecatedEnvWarnings = true;
|
|
865
|
-
const warnings =
|
|
890
|
+
const warnings = [
|
|
891
|
+
...getDeprecatedEnvWarnings(buildDeprecatedEnvInput()),
|
|
892
|
+
...(CONFIGURED_GITLAB_API_URLS.hadUserinfo
|
|
893
|
+
? [apiUrlUserinfoWarning(cliArgs["api-url"] ? "--api-url" : "GITLAB_API_URL")]
|
|
894
|
+
: []),
|
|
895
|
+
];
|
|
866
896
|
for (const warning of warnings) {
|
|
867
897
|
logger.warn(warning);
|
|
868
898
|
}
|
|
@@ -966,7 +996,7 @@ const hiddenToolSet = new Set((GITLAB_TOOL_POLICY_HIDDEN_RAW || "")
|
|
|
966
996
|
}
|
|
967
997
|
}
|
|
968
998
|
const clientPool = new GitLabClientPool({
|
|
969
|
-
apiUrls:
|
|
999
|
+
apiUrls: CONFIGURED_GITLAB_API_URLS.urls,
|
|
970
1000
|
httpProxy: HTTP_PROXY,
|
|
971
1001
|
httpsProxy: HTTPS_PROXY,
|
|
972
1002
|
noProxy: NO_PROXY,
|
|
@@ -1315,6 +1345,9 @@ function toAllowedGitLabApiUrl(value) {
|
|
|
1315
1345
|
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
|
|
1316
1346
|
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
1317
1347
|
return null;
|
|
1348
|
+
// Same rule as GITLAB_API_URL: fetch() rejects a URL that carries userinfo.
|
|
1349
|
+
url.username = "";
|
|
1350
|
+
url.password = "";
|
|
1318
1351
|
return { host: url.host, apiUrl: normalizeGitLabApiUrl(url.toString()) };
|
|
1319
1352
|
}
|
|
1320
1353
|
catch {
|
|
@@ -1339,8 +1372,7 @@ function resolveTrustedGitLabApiUrl(value) {
|
|
|
1339
1372
|
}
|
|
1340
1373
|
return allowedApiUrl;
|
|
1341
1374
|
}
|
|
1342
|
-
|
|
1343
|
-
const GITLAB_API_URLS = splitCommaSeparatedUrls(getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com").map(normalizeGitLabApiUrl);
|
|
1375
|
+
const GITLAB_API_URLS = CONFIGURED_GITLAB_API_URLS.urls;
|
|
1344
1376
|
const GITLAB_API_URL = GITLAB_API_URLS[0];
|
|
1345
1377
|
const GITLAB_ALLOWED_API_URLS_BY_HOST = new Map();
|
|
1346
1378
|
for (const { host, apiUrl } of [
|
|
@@ -4070,7 +4102,7 @@ async function buildMergeRequestCommitAdditionSummary(projectId, mergeRequest) {
|
|
|
4070
4102
|
};
|
|
4071
4103
|
}
|
|
4072
4104
|
catch (error) {
|
|
4073
|
-
const unavailableReason =
|
|
4105
|
+
const unavailableReason = redactUrlSecretsInErrorMessage(error);
|
|
4074
4106
|
return {
|
|
4075
4107
|
target_branch: mergeRequest.target_branch,
|
|
4076
4108
|
source_commits_count: null,
|
|
@@ -4098,7 +4130,7 @@ async function buildMergeRequestApprovalSummary(projectId, mergeRequestIid) {
|
|
|
4098
4130
|
};
|
|
4099
4131
|
}
|
|
4100
4132
|
catch (error) {
|
|
4101
|
-
const unavailableReason =
|
|
4133
|
+
const unavailableReason = redactUrlSecretsInErrorMessage(error);
|
|
4102
4134
|
return {
|
|
4103
4135
|
approved: null,
|
|
4104
4136
|
user_has_approved: null,
|
|
@@ -4196,7 +4228,7 @@ async function buildMergeRequestDeploymentSummary(projectId, mergeRequest) {
|
|
|
4196
4228
|
};
|
|
4197
4229
|
}
|
|
4198
4230
|
catch (error) {
|
|
4199
|
-
const unavailableReason =
|
|
4231
|
+
const unavailableReason = redactUrlSecretsInErrorMessage(error);
|
|
4200
4232
|
return {
|
|
4201
4233
|
lookup_sha: lookupSha,
|
|
4202
4234
|
sort: "created_at_desc",
|
|
@@ -8300,7 +8332,8 @@ async function handleToolCall(params) {
|
|
|
8300
8332
|
};
|
|
8301
8333
|
}
|
|
8302
8334
|
catch (err) {
|
|
8303
|
-
|
|
8335
|
+
// Returned as data, not isError, so the central error redaction does not see it.
|
|
8336
|
+
const message = describeFetchTransportError(err) ?? redactUrlSecretsInErrorMessage(err);
|
|
8304
8337
|
return {
|
|
8305
8338
|
content: [
|
|
8306
8339
|
{
|
|
@@ -8350,7 +8383,7 @@ async function handleToolCall(params) {
|
|
|
8350
8383
|
logger.error({ err: forkError }, "Error forking repository");
|
|
8351
8384
|
let forkErrorMessage = "Failed to fork repository";
|
|
8352
8385
|
if (forkError instanceof Error) {
|
|
8353
|
-
const reason = describeFetchTransportError(forkError) ?? forkError
|
|
8386
|
+
const reason = describeFetchTransportError(forkError) ?? redactUrlSecretsInErrorMessage(forkError);
|
|
8354
8387
|
forkErrorMessage = `${forkErrorMessage}: ${reason}`;
|
|
8355
8388
|
}
|
|
8356
8389
|
return {
|
|
@@ -9204,7 +9237,7 @@ async function handleToolCall(params) {
|
|
|
9204
9237
|
catch (noteError) {
|
|
9205
9238
|
noteResult = {
|
|
9206
9239
|
status: "failed",
|
|
9207
|
-
message: `Note creation failed: ${noteError.message ?? noteError}
|
|
9240
|
+
message: redactUrlSecretsInText(`Note creation failed: ${noteError.message ?? noteError}`),
|
|
9208
9241
|
};
|
|
9209
9242
|
}
|
|
9210
9243
|
}
|
|
@@ -13066,7 +13099,7 @@ async function main() {
|
|
|
13066
13099
|
}
|
|
13067
13100
|
catch (error) {
|
|
13068
13101
|
const message = error instanceof Error ? error.message : String(error);
|
|
13069
|
-
process.stderr.write(`${message}\n`);
|
|
13102
|
+
process.stderr.write(`${redactUrlSecretsInText(message)}\n`);
|
|
13070
13103
|
logger.error({ err: error }, "auth command failed");
|
|
13071
13104
|
exitAfterFlush(1);
|
|
13072
13105
|
}
|
|
@@ -13080,7 +13113,7 @@ async function main() {
|
|
|
13080
13113
|
try {
|
|
13081
13114
|
initOAuthClient();
|
|
13082
13115
|
const result = await handleToolCall({ name: cli.toolName, arguments: cli.args });
|
|
13083
|
-
const masked = maskCliToolResult(cli.toolName, result);
|
|
13116
|
+
const masked = maskCliToolResult(cli.toolName, redactErrorResultUrls(result));
|
|
13084
13117
|
const formatted = formatToolOutput({
|
|
13085
13118
|
result: masked,
|
|
13086
13119
|
mode: cli.output,
|
|
@@ -13107,9 +13140,8 @@ function isRecord(value) {
|
|
|
13107
13140
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13108
13141
|
}
|
|
13109
13142
|
function maskCliToolResult(toolName, result) {
|
|
13110
|
-
const gitlabInstance = getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com";
|
|
13111
13143
|
const engine = maskingPolicyResolver?.select({
|
|
13112
|
-
gitlabInstance,
|
|
13144
|
+
gitlabInstance: GITLAB_API_URL,
|
|
13113
13145
|
allowBuiltinForUnscoped: true,
|
|
13114
13146
|
});
|
|
13115
13147
|
if (!engine || !isRecord(result)) {
|
package/build/utils/logger.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { pino } from "pino";
|
|
3
|
+
import { redactUrlSecretsInLogLine } from "./redact-url.js";
|
|
3
4
|
const require = createRequire(import.meta.url);
|
|
4
5
|
const REDACT_PATHS = [
|
|
5
6
|
"token",
|
|
@@ -125,6 +126,8 @@ export function flushBufferedLogs(logger) {
|
|
|
125
126
|
* to stderr.
|
|
126
127
|
* - Any other value, including unset, keeps human-readable pino-pretty output.
|
|
127
128
|
* - All loggers redact the same set of sensitive fields.
|
|
129
|
+
* - Every line, in either format, has URL userinfo and token query values
|
|
130
|
+
* redacted, including text from thrown fetch/URL errors and their causes.
|
|
128
131
|
*/
|
|
129
132
|
export function createLogger(name) {
|
|
130
133
|
const isJson = process.env.LOG_FORMAT === "json";
|
|
@@ -135,6 +138,9 @@ export function createLogger(name) {
|
|
|
135
138
|
paths: REDACT_PATHS,
|
|
136
139
|
censor: "[REDACTED]",
|
|
137
140
|
},
|
|
141
|
+
hooks: {
|
|
142
|
+
streamWrite: redactUrlSecretsInLogLine,
|
|
143
|
+
},
|
|
138
144
|
};
|
|
139
145
|
if (isJson) {
|
|
140
146
|
const destination = createAsyncStderr();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
1
2
|
const SENSITIVE_QUERY_NAMES = new Set([
|
|
2
3
|
"access_token",
|
|
3
4
|
"private_token",
|
|
@@ -7,8 +8,9 @@ const SENSITIVE_QUERY_NAMES = new Set([
|
|
|
7
8
|
]);
|
|
8
9
|
/**
|
|
9
10
|
* Removes userinfo from a GitLab API URL before it is returned to the client.
|
|
10
|
-
* A password with an unencoded slash is not a valid WHATWG URL,
|
|
11
|
-
*
|
|
11
|
+
* A password with an unencoded slash is not a valid WHATWG URL, or parses as a
|
|
12
|
+
* host and port ("https://u:12/ab@host" has host "u"), so those forms use the
|
|
13
|
+
* same text redaction as transport error messages.
|
|
12
14
|
*/
|
|
13
15
|
export function redactGitLabUrlCredentials(url) {
|
|
14
16
|
try {
|
|
@@ -18,7 +20,7 @@ export function redactGitLabUrlCredentials(url) {
|
|
|
18
20
|
parsed.username = "";
|
|
19
21
|
parsed.password = "";
|
|
20
22
|
}
|
|
21
|
-
const serialized = hadUserinfo ? parsed.toString() : url;
|
|
23
|
+
const serialized = hadUserinfo ? parsed.toString() : redactUrlUserinfoInText(url);
|
|
22
24
|
return redactSensitiveQuery(serialized);
|
|
23
25
|
}
|
|
24
26
|
catch {
|
|
@@ -33,8 +35,125 @@ export function redactGitLabUrlCredentials(url) {
|
|
|
33
35
|
export function redactUrlSecretsInText(text) {
|
|
34
36
|
return redactSensitiveQuery(redactUrlUserinfoInText(redactParseableUrlUserinfo(text)));
|
|
35
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Redacts an error in place: `message`, `stack`, and every `cause` below it.
|
|
40
|
+
* undici and `new URL()` put the URL they rejected into the message, so a
|
|
41
|
+
* credentialed URL would otherwise reach the MCP client and the log verbatim.
|
|
42
|
+
* A thrown string is returned redacted; other values are returned unchanged.
|
|
43
|
+
*/
|
|
44
|
+
export function redactUrlSecretsInError(error) {
|
|
45
|
+
if (typeof error === "string") {
|
|
46
|
+
return redactUrlSecretsInText(error);
|
|
47
|
+
}
|
|
48
|
+
redactErrorChain(error, new Set());
|
|
49
|
+
return error;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The redacted message of a caught error, for a tool result that reports the
|
|
53
|
+
* failure in its data (`{ "error": "GraphQL request failed: ..." }`) instead of
|
|
54
|
+
* as an isError result.
|
|
55
|
+
*/
|
|
56
|
+
export function redactUrlSecretsInErrorMessage(error) {
|
|
57
|
+
return redactUrlSecretsInText(error instanceof Error ? error.message : String(error));
|
|
58
|
+
}
|
|
59
|
+
function redactErrorChain(error, seen) {
|
|
60
|
+
if (!(error instanceof Error) || seen.has(error)) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
seen.add(error);
|
|
64
|
+
redactErrorField(error, "message");
|
|
65
|
+
redactErrorField(error, "stack");
|
|
66
|
+
if (typeof error.cause === "string") {
|
|
67
|
+
redactErrorField(error, "cause");
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
redactErrorChain(error.cause, seen);
|
|
71
|
+
}
|
|
72
|
+
if (error instanceof AggregateError && Array.isArray(error.errors)) {
|
|
73
|
+
for (const inner of error.errors) {
|
|
74
|
+
redactErrorChain(inner, seen);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function redactErrorField(error, field) {
|
|
79
|
+
const value = error[field];
|
|
80
|
+
if (typeof value !== "string") {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const redacted = redactUrlSecretsInText(value);
|
|
84
|
+
if (redacted === value) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
error[field] = redacted;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// A frozen error keeps its text; the logger still redacts its own copy.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Redacts one serialized pino line. Each JSON string is redacted after
|
|
96
|
+
* JSON.parse, so a URL next to a quote cannot pull the rest of the line into
|
|
97
|
+
* the userinfo match. A line that is not JSON is redacted as text.
|
|
98
|
+
*/
|
|
99
|
+
export function redactUrlSecretsInLogLine(line) {
|
|
100
|
+
if (!line.includes("@") && !line.includes("=")) {
|
|
101
|
+
return line;
|
|
102
|
+
}
|
|
103
|
+
const newline = line.endsWith("\n") ? "\n" : "";
|
|
104
|
+
const body = newline ? line.slice(0, -1) : line;
|
|
105
|
+
let parsed;
|
|
106
|
+
try {
|
|
107
|
+
parsed = JSON.parse(body);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return redactUrlSecretsInText(line);
|
|
111
|
+
}
|
|
112
|
+
let changed = false;
|
|
113
|
+
const redacted = mapJsonStrings(parsed, value => {
|
|
114
|
+
const next = redactUrlSecretsInText(value);
|
|
115
|
+
changed ||= next !== value;
|
|
116
|
+
return next;
|
|
117
|
+
});
|
|
118
|
+
return changed ? `${JSON.stringify(redacted)}${newline}` : line;
|
|
119
|
+
}
|
|
120
|
+
function mapJsonStrings(value, map) {
|
|
121
|
+
if (typeof value === "string") {
|
|
122
|
+
return map(value);
|
|
123
|
+
}
|
|
124
|
+
if (Array.isArray(value)) {
|
|
125
|
+
return value.map(item => mapJsonStrings(item, map));
|
|
126
|
+
}
|
|
127
|
+
if (typeof value === "object" && value !== null) {
|
|
128
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, mapJsonStrings(item, map)]));
|
|
129
|
+
}
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Drops userinfo from a configured GitLab API URL. fetch() refuses a URL with
|
|
134
|
+
* credentials, and GitLab API authentication goes in request headers, so the
|
|
135
|
+
* userinfo is removed before any request is built. A value that does not parse
|
|
136
|
+
* is returned unchanged for the config validation to report.
|
|
137
|
+
*/
|
|
138
|
+
export function stripUrlUserinfo(url) {
|
|
139
|
+
const trimmed = url.trim();
|
|
140
|
+
const parsed = tryParseUrl(trimmed);
|
|
141
|
+
if (parsed === null || (parsed.username.length === 0 && parsed.password.length === 0)) {
|
|
142
|
+
return { url, hadUserinfo: false };
|
|
143
|
+
}
|
|
144
|
+
parsed.username = "";
|
|
145
|
+
parsed.password = "";
|
|
146
|
+
const serialized = parsed.toString();
|
|
147
|
+
// "https://user:pass@host" serializes as "https://host/"; keep the input's form.
|
|
148
|
+
const addedRootSlash = parsed.pathname === "/" &&
|
|
149
|
+
parsed.search.length === 0 &&
|
|
150
|
+
parsed.hash.length === 0 &&
|
|
151
|
+
!trimmed.endsWith("/");
|
|
152
|
+
return { url: addedRootSlash ? serialized.slice(0, -1) : serialized, hadUserinfo: true };
|
|
153
|
+
}
|
|
36
154
|
function redactSensitiveQuery(url) {
|
|
37
|
-
|
|
155
|
+
// The name cannot hold a separator, so a run of "?" is not rescanned from each one.
|
|
156
|
+
return url.replace(/([?&#])([^=&\s#?]+)=[^&\s#]+/g, (match, separator, rawName) => {
|
|
38
157
|
if (!isSensitiveQueryName(rawName)) {
|
|
39
158
|
return match;
|
|
40
159
|
}
|
|
@@ -52,14 +171,116 @@ function decodeQueryName(rawName) {
|
|
|
52
171
|
return rawName;
|
|
53
172
|
}
|
|
54
173
|
}
|
|
174
|
+
// Every scan below runs in time linear in the text: log lines can carry
|
|
175
|
+
// client-chosen strings such as an unknown tool name.
|
|
55
176
|
function redactUrlUserinfoInText(message) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
177
|
+
// A token can hold a comma-separated GITLAB_API_URL list; each entry has its own userinfo.
|
|
178
|
+
const withoutCompactUserinfo = replaceUrlTokens(message, urlToken => splitCommaSeparatedUrls(urlToken).map(redactCompactUserinfo).join(","));
|
|
179
|
+
return redactSpacedUserinfo(withoutCompactUserinfo);
|
|
180
|
+
}
|
|
181
|
+
const WORD_CHAR_PATTERN = /\w/;
|
|
182
|
+
const WHITESPACE_PATTERN = /\s/;
|
|
183
|
+
const NEXT_WHITESPACE_PATTERN = /\s/g;
|
|
184
|
+
// The tokens /\b[a-z][a-z0-9+.-]*:\S+/gi matches: a scheme, then the text up
|
|
185
|
+
// to the next whitespace.
|
|
186
|
+
function replaceUrlTokens(text, replace) {
|
|
187
|
+
const parts = [];
|
|
188
|
+
let copied = 0;
|
|
189
|
+
let colon = text.indexOf(":");
|
|
190
|
+
while (colon !== -1) {
|
|
191
|
+
const followedByText = colon + 1 < text.length && !WHITESPACE_PATTERN.test(text[colon + 1]);
|
|
192
|
+
const start = followedByText ? findSchemeStart(text, colon) : -1;
|
|
193
|
+
if (start === -1) {
|
|
194
|
+
colon = text.indexOf(":", colon + 1);
|
|
195
|
+
continue;
|
|
60
196
|
}
|
|
61
|
-
|
|
62
|
-
|
|
197
|
+
const end = indexOfPattern(text, NEXT_WHITESPACE_PATTERN, colon + 1);
|
|
198
|
+
parts.push(text.slice(copied, start), replace(text.slice(start, end)));
|
|
199
|
+
copied = end;
|
|
200
|
+
colon = text.indexOf(":", end);
|
|
201
|
+
}
|
|
202
|
+
parts.push(text.slice(copied));
|
|
203
|
+
return parts.join("");
|
|
204
|
+
}
|
|
205
|
+
// "scheme://user:pa ss@host": a password with whitespace splits the token above.
|
|
206
|
+
function redactSpacedUserinfo(text) {
|
|
207
|
+
const parts = [];
|
|
208
|
+
let copied = 0;
|
|
209
|
+
let colon = text.indexOf("://");
|
|
210
|
+
while (colon !== -1) {
|
|
211
|
+
const start = findSchemeStart(text, colon);
|
|
212
|
+
const authorityStart = colon + 3;
|
|
213
|
+
const atIndex = start === -1 ? -1 : findSpacedUserinfoEnd(text, authorityStart);
|
|
214
|
+
if (atIndex === -1) {
|
|
215
|
+
colon = text.indexOf("://", colon + 1);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const end = atIndex + 1;
|
|
219
|
+
if (!looksLikeHostPort(text.slice(authorityStart, atIndex), text.slice(end))) {
|
|
220
|
+
parts.push(text.slice(copied, start), `${text.slice(start, authorityStart)}[REDACTED]@`);
|
|
221
|
+
copied = end;
|
|
222
|
+
}
|
|
223
|
+
colon = text.indexOf("://", end);
|
|
224
|
+
}
|
|
225
|
+
parts.push(text.slice(copied));
|
|
226
|
+
return parts.join("");
|
|
227
|
+
}
|
|
228
|
+
const SPACED_USERINFO_STOP_PATTERN = /[\s/:@.]/;
|
|
229
|
+
// Index of the "@" that ends /[^\s/:@.]+(?::[^\s/:@.]+)+(?:\s+[^\s/:@.]+)+@/ at
|
|
230
|
+
// `start`, or -1. Its parts cannot overlap, so one pass decides the match, and
|
|
231
|
+
// the scan stops at the next "://".
|
|
232
|
+
function findSpacedUserinfoEnd(text, start) {
|
|
233
|
+
let index = skipUserinfoRun(text, start);
|
|
234
|
+
if (index === start || text[index] !== ":") {
|
|
235
|
+
return -1;
|
|
236
|
+
}
|
|
237
|
+
while (text[index] === ":") {
|
|
238
|
+
const runEnd = skipUserinfoRun(text, index + 1);
|
|
239
|
+
if (runEnd === index + 1) {
|
|
240
|
+
return -1;
|
|
241
|
+
}
|
|
242
|
+
index = runEnd;
|
|
243
|
+
}
|
|
244
|
+
let spacedRuns = 0;
|
|
245
|
+
while (index < text.length && WHITESPACE_PATTERN.test(text[index])) {
|
|
246
|
+
let runStart = index + 1;
|
|
247
|
+
while (runStart < text.length && WHITESPACE_PATTERN.test(text[runStart])) {
|
|
248
|
+
runStart += 1;
|
|
249
|
+
}
|
|
250
|
+
const runEnd = skipUserinfoRun(text, runStart);
|
|
251
|
+
if (runEnd === runStart) {
|
|
252
|
+
return -1;
|
|
253
|
+
}
|
|
254
|
+
index = runEnd;
|
|
255
|
+
spacedRuns += 1;
|
|
256
|
+
}
|
|
257
|
+
return spacedRuns > 0 && text[index] === "@" ? index : -1;
|
|
258
|
+
}
|
|
259
|
+
function skipUserinfoRun(text, from) {
|
|
260
|
+
let index = from;
|
|
261
|
+
while (index < text.length && !SPACED_USERINFO_STOP_PATTERN.test(text[index])) {
|
|
262
|
+
index += 1;
|
|
263
|
+
}
|
|
264
|
+
return index;
|
|
265
|
+
}
|
|
266
|
+
// Start of the scheme that ends at `colon`, where /\b[a-z][a-z0-9+.-]*:/i
|
|
267
|
+
// would match it, or -1. Walking back from the ":" reads a run like "a.a.a.a"
|
|
268
|
+
// once; the regex would rescan it from every letter.
|
|
269
|
+
function findSchemeStart(text, colon) {
|
|
270
|
+
let start = -1;
|
|
271
|
+
for (let index = colon - 1; index >= 0 && SCHEME_CHAR_PATTERN.test(text[index]); index -= 1) {
|
|
272
|
+
const atWordBoundary = index === 0 || !WORD_CHAR_PATTERN.test(text[index - 1]);
|
|
273
|
+
if (atWordBoundary && SCHEME_START_PATTERN.test(text[index])) {
|
|
274
|
+
start = index;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return start;
|
|
278
|
+
}
|
|
279
|
+
// Index of the first match of a global `pattern` at or after `from`, or the text length.
|
|
280
|
+
function indexOfPattern(text, pattern, from) {
|
|
281
|
+
pattern.lastIndex = from;
|
|
282
|
+
const match = pattern.exec(text);
|
|
283
|
+
return match === null ? text.length : match.index;
|
|
63
284
|
}
|
|
64
285
|
function redactParseableUrlUserinfo(text) {
|
|
65
286
|
const trimmed = text.trim();
|
|
@@ -106,16 +327,176 @@ function splitAuthorityUserinfo(url) {
|
|
|
106
327
|
afterAt: `${authority.slice(atIndex + 1)}${rest.slice(authorityEnd)}`,
|
|
107
328
|
};
|
|
108
329
|
}
|
|
330
|
+
// A URL inside another URL's path or query ("...?next=https://user:pass@host")
|
|
331
|
+
// has its own authority. The text regex sees both as one token.
|
|
332
|
+
// One pass per URL in the token, outer first, so a long token of nested URLs
|
|
333
|
+
// does not grow the call stack. URLs nested in one authority share its end, and
|
|
334
|
+
// the authority reader reads that text once for all of them.
|
|
109
335
|
function redactCompactUserinfo(urlToken) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
336
|
+
// Without an "@" after a URL's scheme there is no userinfo to find, so
|
|
337
|
+
// segments past the last "@" skip the authority scan.
|
|
338
|
+
const lastAt = urlToken.lastIndexOf("@");
|
|
339
|
+
const lastUserinfoAt = lastAtOutsidePathSegmentStart(urlToken);
|
|
340
|
+
const authorities = createAuthorityReader(urlToken);
|
|
341
|
+
const parts = [];
|
|
342
|
+
let offset = 0;
|
|
343
|
+
for (;;) {
|
|
344
|
+
const prefix = readSchemePrefix(urlToken.slice(offset));
|
|
345
|
+
if (prefix === null) {
|
|
346
|
+
parts.push(urlToken.slice(offset));
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
const restStart = offset + prefix.scheme.length;
|
|
350
|
+
const hasAuthority = prefix.hasAuthoritySlashes || SPECIAL_SCHEMES.has(prefix.name);
|
|
351
|
+
const atIndex = hasAuthority && lastAt >= restStart
|
|
352
|
+
? findUserinfoEnd(urlToken, restStart, authorities, lastAt, lastUserinfoAt)
|
|
353
|
+
: -1;
|
|
354
|
+
const nestedFrom = atIndex > 0 ? atIndex + 1 : 0;
|
|
355
|
+
parts.push(atIndex > 0 ? `${prefix.scheme}[REDACTED]@` : prefix.scheme);
|
|
356
|
+
const rest = prefix.rest.slice(nestedFrom);
|
|
357
|
+
const nestedStart = findNestedUrlStart(rest);
|
|
358
|
+
if (nestedStart === -1) {
|
|
359
|
+
parts.push(rest);
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
parts.push(rest.slice(0, nestedStart));
|
|
363
|
+
offset = restStart + nestedFrom + nestedStart;
|
|
113
364
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
365
|
+
return parts.join("");
|
|
366
|
+
}
|
|
367
|
+
const SCHEME_CHAR_PATTERN = /[a-z0-9+.-]/i;
|
|
368
|
+
const SCHEME_START_PATTERN = /[a-z]/i;
|
|
369
|
+
// Index of the first match of /[a-z][a-z0-9+.-]*:\S/i, or -1. It walks back
|
|
370
|
+
// from each ":" instead of trying every start position, so a long run of
|
|
371
|
+
// letters in a path is read once rather than once per letter.
|
|
372
|
+
function findNestedUrlStart(text) {
|
|
373
|
+
for (let colon = text.indexOf(":"); colon !== -1; colon = text.indexOf(":", colon + 1)) {
|
|
374
|
+
if (colon + 1 >= text.length || /\s/.test(text[colon + 1])) {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
let start = -1;
|
|
378
|
+
for (let index = colon - 1; index >= 0 && SCHEME_CHAR_PATTERN.test(text[index]); index -= 1) {
|
|
379
|
+
if (SCHEME_START_PATTERN.test(text[index])) {
|
|
380
|
+
start = index;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (start !== -1) {
|
|
384
|
+
return start;
|
|
385
|
+
}
|
|
117
386
|
}
|
|
118
|
-
return
|
|
387
|
+
return -1;
|
|
388
|
+
}
|
|
389
|
+
// WHATWG special schemes read userinfo without "//" ("https:user:pass@host").
|
|
390
|
+
// Other schemes have an authority only after "//", so "mailto:a@b.com" has none.
|
|
391
|
+
const SPECIAL_SCHEMES = new Set(["http:", "https:", "ws:", "wss:", "ftp:"]);
|
|
392
|
+
// Path, query, fragment, and the quote or bracket that closes a URL in prose or JSON.
|
|
393
|
+
const AUTHORITY_END_PATTERN = /[/\\?#"'<>`]/g;
|
|
394
|
+
const DIGITS_PATTERN = /^\d*$/;
|
|
395
|
+
const DOTTED_HOST_NAME_PATTERN = /^[a-z0-9_.-]+$/i;
|
|
396
|
+
const LOCALHOST_PATTERN = /^localhost$/i;
|
|
397
|
+
// Starts only move forward, and so do both positions here, as in
|
|
398
|
+
// splitCommaSeparatedUrls: the authorities of a token are read once in total.
|
|
399
|
+
function createAuthorityReader(token) {
|
|
400
|
+
let text;
|
|
401
|
+
let closeBracket = -1;
|
|
402
|
+
return {
|
|
403
|
+
read(start) {
|
|
404
|
+
if (text === undefined || text.end < start) {
|
|
405
|
+
text = readAuthorityText(token, start);
|
|
406
|
+
}
|
|
407
|
+
return text;
|
|
408
|
+
},
|
|
409
|
+
closeBracket(start) {
|
|
410
|
+
if (closeBracket < start) {
|
|
411
|
+
const index = token.indexOf("]", start);
|
|
412
|
+
closeBracket = index === -1 ? token.length : index;
|
|
413
|
+
}
|
|
414
|
+
return closeBracket;
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
function readAuthorityText(token, from) {
|
|
419
|
+
const end = indexOfPattern(token, AUTHORITY_END_PATTERN, from);
|
|
420
|
+
let lastAt = -1;
|
|
421
|
+
let lastColon = -1;
|
|
422
|
+
let colonBeforeLast = -1;
|
|
423
|
+
for (let index = from; index < end; index += 1) {
|
|
424
|
+
if (token[index] === "@") {
|
|
425
|
+
lastAt = index;
|
|
426
|
+
}
|
|
427
|
+
else if (token[index] === ":") {
|
|
428
|
+
colonBeforeLast = lastColon;
|
|
429
|
+
lastColon = index;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const port = lastColon === -1 ? "" : token.slice(lastColon + 1, end);
|
|
433
|
+
const digitsAfterLastColon = DIGITS_PATTERN.test(port);
|
|
434
|
+
return {
|
|
435
|
+
end,
|
|
436
|
+
closed: end < token.length,
|
|
437
|
+
lastAt,
|
|
438
|
+
lastColon,
|
|
439
|
+
colonBeforeLast,
|
|
440
|
+
digitsAfterLastColon,
|
|
441
|
+
// WHATWG reads ":0000080" as port 80.
|
|
442
|
+
portTooLarge: digitsAfterLastColon && Number(port) > 65535,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
// The userinfo ends at the last "@" before the path, so "https://host/a?x=1@2"
|
|
446
|
+
// and ".../npm/@scope/pkg" keep their "@". A password with an unencoded "/"
|
|
447
|
+
// ("user:p/ss@host") moves that "@" past the first "/"; it is told apart from
|
|
448
|
+
// a URL by the "user:p" before the "/", which is not a host[:port].
|
|
449
|
+
// A password of digits first ("u:12/ab@host", "u:123?x@host") reads as a port.
|
|
450
|
+
// So when the name before the port cannot be a host (a dotted name of letters,
|
|
451
|
+
// digits, "-" and "_", localhost, or an IPv6 literal), or the digits are above
|
|
452
|
+
// 65535, the last "@" counts unless it starts a path segment ("/@scope"), and
|
|
453
|
+
// "gitlab.example:8443/users?search=a@b.com" keeps its "@".
|
|
454
|
+
// Two shapes stay ambiguous. A username that is also a dotted host name
|
|
455
|
+
// ("first.last:12/ab@host") is left unchanged. A single-label host on a port
|
|
456
|
+
// ("gitlab:8080/users?search=a@b.com") is redacted up to that "@".
|
|
457
|
+
// `start` is the authority start in `token`; the result is an index from it.
|
|
458
|
+
function findUserinfoEnd(token, start, authorities, lastAt, lastUserinfoAt) {
|
|
459
|
+
const text = authorities.read(start);
|
|
460
|
+
if (text.lastAt >= start || !text.closed) {
|
|
461
|
+
return Math.max(text.lastAt - start, -1);
|
|
462
|
+
}
|
|
463
|
+
const { end, lastColon } = text;
|
|
464
|
+
const hasColon = lastColon >= start;
|
|
465
|
+
const oneColon = hasColon && text.colonBeforeLast < start;
|
|
466
|
+
// ":" and then only digits, maybe none.
|
|
467
|
+
const portDigits = hasColon && text.digitsAfterLastColon;
|
|
468
|
+
const bracketEnd = token[start] === "[" ? authorities.closeBracket(start) : -1;
|
|
469
|
+
// /^(?:\[[^\]]*\]|[^:]*):\d+$/: "host:port" or "[...]:port".
|
|
470
|
+
if (portDigits && lastColon + 1 < end && (oneColon || bracketEnd === lastColon - 1)) {
|
|
471
|
+
const host = token.slice(start, lastColon);
|
|
472
|
+
return !text.portTooLarge && couldBeHostName(host) ? -1 : Math.max(lastUserinfoAt - start, -1);
|
|
473
|
+
}
|
|
474
|
+
// The rest of /^(?:\[[^\]]*\]|[^:]*)(?::\d*)?$/: "host", "host:", "[...]", or "[...]:".
|
|
475
|
+
const hostWithoutPort = !hasColon ||
|
|
476
|
+
(oneColon && portDigits) ||
|
|
477
|
+
bracketEnd === end - 1 ||
|
|
478
|
+
(portDigits && bracketEnd === lastColon - 1);
|
|
479
|
+
return hostWithoutPort ? -1 : lastAt - start;
|
|
480
|
+
}
|
|
481
|
+
// "%" (an encoded "@" in an email username) or "+" cannot appear in a host name.
|
|
482
|
+
// The character test comes first and stops at the first other character.
|
|
483
|
+
function couldBeHostName(host) {
|
|
484
|
+
if (host.startsWith("[")) {
|
|
485
|
+
return host.endsWith("]") && isIP(host.slice(1, -1)) === 6;
|
|
486
|
+
}
|
|
487
|
+
return (LOCALHOST_PATTERN.test(host) ||
|
|
488
|
+
(DOTTED_HOST_NAME_PATTERN.test(host) &&
|
|
489
|
+
host.includes(".") &&
|
|
490
|
+
!host.startsWith(".") &&
|
|
491
|
+
!host.includes("..")));
|
|
492
|
+
}
|
|
493
|
+
// Found once per token, so each nested URL reads it in constant time.
|
|
494
|
+
function lastAtOutsidePathSegmentStart(urlToken) {
|
|
495
|
+
let index = urlToken.lastIndexOf("@");
|
|
496
|
+
while (index > 0 && urlToken[index - 1] === "/") {
|
|
497
|
+
index = urlToken.lastIndexOf("@", index - 1);
|
|
498
|
+
}
|
|
499
|
+
return index;
|
|
119
500
|
}
|
|
120
501
|
const SCHEME_PREFIX_PATTERN = /^([a-z][a-z0-9+.-]*:)(\/\/|\/)?/i;
|
|
121
502
|
function readSchemePrefix(value) {
|
|
@@ -126,54 +507,69 @@ function readSchemePrefix(value) {
|
|
|
126
507
|
const scheme = schemeMatch[0];
|
|
127
508
|
return {
|
|
128
509
|
scheme,
|
|
510
|
+
name: schemeMatch[1].toLowerCase(),
|
|
511
|
+
hasAuthoritySlashes: schemeMatch[2] === "//",
|
|
129
512
|
rest: value.slice(scheme.length),
|
|
130
513
|
};
|
|
131
514
|
}
|
|
515
|
+
// A comma is inside a password when the entry's authority is still open (no
|
|
516
|
+
// "@", "/", "?", or "#" after the scheme yet) and the next of those after the
|
|
517
|
+
// comma, or whitespace, is an "@".
|
|
518
|
+
const AUTHORITY_CLOSE_PATTERN = /[@/?#]/g;
|
|
519
|
+
const USERINFO_STOP_PATTERN = /[@/?# \t]/g;
|
|
132
520
|
export function splitCommaSeparatedUrls(value) {
|
|
133
521
|
const parts = [];
|
|
134
522
|
let start = 0;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
523
|
+
// Where the current entry's authority starts, -1 without a scheme; read at its first comma.
|
|
524
|
+
let restStart;
|
|
525
|
+
let inUserinfo = false;
|
|
526
|
+
// Both positions only move forward, so a value with many commas is read once.
|
|
527
|
+
let closeIndex = -1;
|
|
528
|
+
let stopIndex = -1;
|
|
529
|
+
for (let comma = value.indexOf(","); comma !== -1; comma = value.indexOf(",", comma + 1)) {
|
|
530
|
+
if (restStart === undefined) {
|
|
531
|
+
restStart = readRestStart(value, start, comma);
|
|
138
532
|
}
|
|
139
|
-
if (
|
|
140
|
-
|
|
533
|
+
if (restStart !== -1) {
|
|
534
|
+
if (closeIndex < restStart) {
|
|
535
|
+
closeIndex = indexOfPattern(value, AUTHORITY_CLOSE_PATTERN, restStart);
|
|
536
|
+
}
|
|
537
|
+
if (stopIndex < comma) {
|
|
538
|
+
stopIndex = indexOfPattern(value, USERINFO_STOP_PATTERN, comma + 1);
|
|
539
|
+
}
|
|
540
|
+
const commaInUserinfo = closeIndex > comma &&
|
|
541
|
+
value[stopIndex] === "@" &&
|
|
542
|
+
(inUserinfo || !startsNextUrl(value, start, comma));
|
|
543
|
+
if (commaInUserinfo) {
|
|
544
|
+
inUserinfo = true;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
141
547
|
}
|
|
142
|
-
parts.push(value.slice(start,
|
|
143
|
-
start =
|
|
548
|
+
parts.push(value.slice(start, comma).trim());
|
|
549
|
+
start = comma + 1;
|
|
550
|
+
restStart = undefined;
|
|
551
|
+
inUserinfo = false;
|
|
144
552
|
}
|
|
145
553
|
parts.push(value.slice(start).trim());
|
|
146
554
|
return parts;
|
|
147
555
|
}
|
|
556
|
+
function readRestStart(value, start, comma) {
|
|
557
|
+
const raw = value.slice(start, comma);
|
|
558
|
+
const prefix = readSchemePrefix(raw.trim());
|
|
559
|
+
if (prefix === null) {
|
|
560
|
+
return -1;
|
|
561
|
+
}
|
|
562
|
+
return start + raw.length - raw.trimStart().length + prefix.scheme.length;
|
|
563
|
+
}
|
|
148
564
|
// "https://a.example,https:user:pass@b.example" is two URLs; the "@" after the
|
|
149
565
|
// comma belongs to the next one. The text before the comma must parse as a URL
|
|
150
566
|
// by itself, so "https://alice:secret,https:word@host" stays one entry.
|
|
151
|
-
// Only http(s) counts, so "pass,abc:def@host" stays.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return false;
|
|
158
|
-
}
|
|
159
|
-
const afterComma = value.slice(commaIndex + 1);
|
|
160
|
-
const startsNextUrl = NEXT_HTTP_URL_PATTERN.test(afterComma) && tryParseUrl(entry) !== null;
|
|
161
|
-
return !startsNextUrl && userinfoAtFollows(afterComma);
|
|
162
|
-
}
|
|
163
|
-
function authorityAlreadyClosed(rest) {
|
|
164
|
-
return rest.includes("@") || rest.includes("/") || rest.includes("?") || rest.includes("#");
|
|
165
|
-
}
|
|
166
|
-
function userinfoAtFollows(afterComma) {
|
|
167
|
-
for (let index = 0; index < afterComma.length; index += 1) {
|
|
168
|
-
const char = afterComma[index];
|
|
169
|
-
if (char === "@") {
|
|
170
|
-
return true;
|
|
171
|
-
}
|
|
172
|
-
if (char === "/" || char === "?" || char === "#" || char === " " || char === "\t") {
|
|
173
|
-
return false;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return false;
|
|
567
|
+
// Only http(s) counts, so "pass,abc:def@host" stays. Checked at an entry's
|
|
568
|
+
// first comma only: once a comma is in the password, so is every comma up to its "@".
|
|
569
|
+
const NEXT_HTTP_URL_PATTERN = /https?:/iy;
|
|
570
|
+
function startsNextUrl(value, start, comma) {
|
|
571
|
+
NEXT_HTTP_URL_PATTERN.lastIndex = comma + 1;
|
|
572
|
+
return (NEXT_HTTP_URL_PATTERN.test(value) && tryParseUrl(value.slice(start, comma).trim()) !== null);
|
|
177
573
|
}
|
|
178
574
|
// "user:pa ss@host" is a password. "localhost:3000 a@b.com" is a host, then an email.
|
|
179
575
|
// A numeric port counts only when one token follows it and the text after "@" is an email domain.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { lookup } from "node:dns/promises";
|
|
2
2
|
import { isIP } from "node:net";
|
|
3
3
|
import { fetch as undiciFetch } from "undici";
|
|
4
|
+
import { redactUrlSecretsInText } from "./redact-url.js";
|
|
4
5
|
/** Maximum number of redirect hops an outbound download request may follow. */
|
|
5
6
|
export const DEFAULT_MAX_REDIRECTS = 5;
|
|
6
7
|
/**
|
|
@@ -308,7 +309,8 @@ export async function fetchWithValidatedRedirects(url, options) {
|
|
|
308
309
|
// keeps — on the wire in cleartext, so it is refused before the destination is
|
|
309
310
|
// evaluated and before any trust decision can re-enable the credentials.
|
|
310
311
|
if (currentProtocol === "https:" && target.protocol === "http:") {
|
|
311
|
-
|
|
312
|
+
// The download proxy returns this message to the client, and Location can carry userinfo.
|
|
313
|
+
throw new UnsafeRedirectError(redactUrlSecretsInText(`Refusing to follow redirect from ${currentUrl} to cleartext ${target.toString()}`));
|
|
312
314
|
}
|
|
313
315
|
// A hop that stays on the originating origin needs no destination check. It keeps
|
|
314
316
|
// the credentials only when they were not withheld on the way here: a target that
|
package/build/utils/url.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { splitCommaSeparatedUrls, stripUrlUserinfo } from "./redact-url.js";
|
|
1
2
|
/**
|
|
2
3
|
* Smart URL handling for GitLab API
|
|
3
4
|
*
|
|
@@ -17,6 +18,27 @@ export function normalizeGitLabApiUrl(url) {
|
|
|
17
18
|
}
|
|
18
19
|
return normalizedUrl;
|
|
19
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Startup warning for a configured API URL that had userinfo. `source` names
|
|
23
|
+
* where the URL came from. The advice stays generic: a remote-authorization or
|
|
24
|
+
* MCP OAuth deployment must not be told to add a server token.
|
|
25
|
+
*/
|
|
26
|
+
export function apiUrlUserinfoWarning(source) {
|
|
27
|
+
return (`${source} contains a username or password before the host. It is ignored: requests ` +
|
|
28
|
+
"go to the URL without it. Use a token or OAuth configuration to authenticate instead.");
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Split a comma-separated GITLAB_API_URL, drop userinfo from each entry, and
|
|
32
|
+
* normalize it. fetch() refuses a URL with credentials, so a URL that kept
|
|
33
|
+
* them would fail every request and echo the password in the error.
|
|
34
|
+
*/
|
|
35
|
+
export function parseConfiguredGitLabApiUrls(value) {
|
|
36
|
+
const entries = splitCommaSeparatedUrls(value).map(stripUrlUserinfo);
|
|
37
|
+
return {
|
|
38
|
+
urls: entries.map(entry => normalizeGitLabApiUrl(entry.url)),
|
|
39
|
+
hadUserinfo: entries.some(entry => entry.hadUserinfo),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
20
42
|
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
21
43
|
/**
|
|
22
44
|
* True when the URL is plain `http://` to a non-loopback host, i.e. credentials sent
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zereight/mcp-gitlab",
|
|
3
|
-
"version": "2.3.5-next.
|
|
3
|
+
"version": "2.3.5-next.125",
|
|
4
4
|
"mcpName": "io.github.zereight/gitlab-mcp",
|
|
5
5
|
"description": "GitLab MCP server for projects, merge requests, issues, pipelines, wiki, releases, and more",
|
|
6
6
|
"keywords": [
|