Skip to content

Commit 93de1dd

Browse files
ChasLuiBlackHole1
andauthored
feat(gitlab): support self-hosted GitLab instances (#146)
## Summary The GitLab provider currently hardcodes `https://gitlab.com/api/v4`, so connections can only target GitLab.com. Self-hosted GitLab (CE/EE) deployments are very common, and the official GitLab CLI ([gitlab-org/cli](https://gitlab.com/gitlab-org/cli)) treats instance host as a first-class setting (`GITLAB_HOST` / per-host config). This PR brings the per-connection equivalent to the GitLab provider, following the repo's self-hosted reference pattern (Dokploy, per AGENTS.md "Provider Network Egress (SSRF)"). ## Changes - **`definition.ts`** – optional `baseUrl` ("Instance URL") extra field on the `api_key` auth. Left empty, everything behaves exactly as before (GitLab.com). - **`executors.ts`** – executors now build a per-connection `apiBaseUrl` via `normalizeGitlabApiBaseUrl`: defaults to `https://gitlab.com/api/v4`, otherwise validates the instance URL with the shared `assertPublicHttpUrl` SSRF guard (DNS validation stays on), rejects embedded credentials, drops query/hash, and appends `/api/v4`. Private/overlay targets stay blocked unless the deployment opts in via `OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK`, mirroring Dokploy. - **proxy** – removed the hardcoded `gitlab.com` entry from `proxy.registry.ts` and exported a module proxy that derives its base URL from the connection credential (`metadata.apiBaseUrl` / `values.baseUrl`). Without this, `POST /v1/proxy/gitlab` would keep sending a self-hosted instance's PAT to gitlab.com. - **validator** – validates against the configured instance with a re-guarded fetcher (so private instances validate when the deployment allows it), stores the resolved `apiBaseUrl` in metadata, and scopes account ids by instance host for self-hosted connections so the same numeric user id on two instances never collides. GitLab.com account ids are unchanged (`gitlab:<id>`). ## Backward compatibility Existing GitLab.com connections are unaffected: empty/absent `baseUrl` resolves to the previous hardcoded URL, account id format for GitLab.com is untouched, and the proxy still defaults to `https://gitlab.com/api/v4` when the credential carries no instance URL. ## Tests - `network-access.test.ts` – URL normalization (default, subpath instances, `/api/v4` idempotence, query/hash stripping, credential rejection) and private-network gating (blocked by default, allowed with the deployment flag, unsafe/metadata targets always blocked), plus request routing to a configured instance. - `egress-guard.test.ts` – validator blocks a redirect to a cloud-metadata target, reaches a private instance when the deployment opts in, and keeps the GitLab.com account id format. `npm run fix-check` and `npm test` (600 passed) are green. --------- Co-authored-by: Kevin Cui <bh@bugs.cc>
1 parent c464ad9 commit 93de1dd

5 files changed

Lines changed: 435 additions & 15 deletions

File tree

src/providers/gitlab/definition.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ export const provider: ProviderDefinition = {
1919
placeholder: "glpat-xxxxxxxxxxxxxxxxxxxx",
2020
description:
2121
"GitLab personal access token sent with the PRIVATE-TOKEN header. Create one in GitLab user preferences under Access tokens.",
22+
extraFields: [
23+
{
24+
key: "baseUrl",
25+
label: "Instance URL",
26+
inputType: "text",
27+
required: false,
28+
secret: false,
29+
placeholder: "https://gitlab.example.com",
30+
description:
31+
"Optional base URL of a self-hosted GitLab instance, without the /api/v4 path. Leave empty for GitLab.com. Private/overlay targets (RFC 1918, Tailscale, NetBird, private hostnames) require the self-hosted runtime to enable OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK.",
32+
},
33+
],
2234
},
2335
],
2436
homepageUrl: "https://gitlab.com",
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { setPrivateNetworkAccessAllowed } from "../../core/request.ts";
3+
import { credentialValidators } from "./executors.ts";
4+
5+
afterEach(() => {
6+
vi.unstubAllGlobals();
7+
setPrivateNetworkAccessAllowed(false);
8+
});
9+
10+
function stubFetchSequence(responses: Response[]): Array<{ url: string }> {
11+
const calls: Array<{ url: string }> = [];
12+
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
13+
calls.push({ url: input instanceof Request ? input.url : String(input) });
14+
const response = responses.shift();
15+
if (!response) {
16+
throw new Error("unexpected extra request");
17+
}
18+
return response;
19+
});
20+
return calls;
21+
}
22+
23+
describe("GitLab validator egress guard", () => {
24+
it("rejects a public baseUrl that redirects validation to a metadata target", async () => {
25+
const calls = stubFetchSequence([
26+
new Response(null, {
27+
status: 302,
28+
headers: { location: "http://169.254.169.254/latest/meta-data/" },
29+
}),
30+
]);
31+
32+
await expect(
33+
credentialValidators.apiKey!(
34+
{
35+
apiKey: "glpat-test",
36+
values: { baseUrl: "https://gitlab.example.com" },
37+
},
38+
{ fetcher: fetch },
39+
),
40+
).rejects.toThrow(/redirect location/u);
41+
expect(calls).toHaveLength(1);
42+
});
43+
44+
it("still reaches a private baseUrl when the deployment allows private networks", async () => {
45+
setPrivateNetworkAccessAllowed(true);
46+
const calls = stubFetchSequence([
47+
new Response(JSON.stringify({ id: 7, username: "root", name: "Administrator" }), {
48+
status: 200,
49+
headers: { "content-type": "application/json" },
50+
}),
51+
]);
52+
53+
const result = await credentialValidators.apiKey!(
54+
{ apiKey: "glpat-test", values: { baseUrl: "http://10.0.0.5" } },
55+
{ fetcher: fetch },
56+
);
57+
58+
if (!result) {
59+
throw new Error("expected a validation result");
60+
}
61+
expect(calls[0]?.url).toBe("http://10.0.0.5/api/v4/user");
62+
expect(result.profile?.accountId).toBe("gitlab:10.0.0.5:7");
63+
expect(result.metadata?.apiBaseUrl).toBe("http://10.0.0.5/api/v4");
64+
});
65+
66+
it("keeps the GitLab.com account id format for default connections", async () => {
67+
const calls = stubFetchSequence([
68+
new Response(JSON.stringify({ id: 42, username: "jane", name: "Jane" }), {
69+
status: 200,
70+
headers: { "content-type": "application/json" },
71+
}),
72+
]);
73+
74+
const result = await credentialValidators.apiKey!({ apiKey: "glpat-test", values: {} }, { fetcher: fetch });
75+
76+
if (!result) {
77+
throw new Error("expected a validation result");
78+
}
79+
expect(calls[0]?.url).toBe("https://gitlab.com/api/v4/user");
80+
expect(result.profile?.accountId).toBe("gitlab:42");
81+
expect(result.metadata?.apiBaseUrl).toBe("https://gitlab.com/api/v4");
82+
});
83+
});

src/providers/gitlab/executors.ts

Lines changed: 109 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { CredentialValidators, ProviderExecutors } from "../../core/types.ts";
1+
import type {
2+
CredentialValidators,
3+
ExecutionContext,
4+
ProviderExecutors,
5+
ProviderProxyExecutor,
6+
} from "../../core/types.ts";
27
import type { ApiKeyProviderContext } from "../provider-runtime.ts";
38

49
import {
@@ -7,16 +12,26 @@ import {
712
optionalIntegerLike,
813
optionalString as asOptionalString,
914
} from "../../core/cast.ts";
10-
import { defineApiKeyProviderExecutors, ProviderRequestError, providerUserAgent } from "../provider-runtime.ts";
11-
12-
const gitlabApiBaseUrl = "https://gitlab.com/api/v4";
15+
import { assertPublicHttpUrl, isPrivateNetworkAccessAllowed } from "../../core/request.ts";
16+
import {
17+
createProviderFetch,
18+
defineProviderExecutors,
19+
defineProviderProxy,
20+
ProviderRequestError,
21+
providerUserAgent,
22+
requireApiKeyCredential,
23+
} from "../provider-runtime.ts";
24+
25+
const defaultGitlabApiBaseUrl = "https://gitlab.com/api/v4";
1326
const service = "gitlab";
1427

1528
type GitlabRequestPhase = "validate" | "execute";
1629
type GitlabActionInput = Record<string, unknown>;
1730
type GitlabActionHandler = (input: GitlabActionInput, context: GitlabActionContext) => Promise<unknown>;
1831

19-
type GitlabActionContext = ApiKeyProviderContext;
32+
interface GitlabActionContext extends ApiKeyProviderContext {
33+
apiBaseUrl: string;
34+
}
2035

2136
interface GitlabRequestOptions {
2237
method?: "GET" | "POST";
@@ -43,23 +58,71 @@ export const gitlabActionHandlers: Record<string, GitlabActionHandler> = {
4358
},
4459
};
4560

46-
export const executors: ProviderExecutors = defineApiKeyProviderExecutors(service, gitlabActionHandlers);
61+
export const executors: ProviderExecutors = defineProviderExecutors<GitlabActionContext>({
62+
service,
63+
handlers: gitlabActionHandlers,
64+
async createContext(context: ExecutionContext, fetcher: typeof fetch): Promise<GitlabActionContext> {
65+
const credential = await requireApiKeyCredential(context, service);
66+
const providerContext: GitlabActionContext = {
67+
apiKey: credential.apiKey,
68+
apiBaseUrl: normalizeGitlabApiBaseUrl(credential.values.baseUrl),
69+
fetcher,
70+
signal: context.signal,
71+
};
72+
if (context.transitFiles) {
73+
providerContext.transitFiles = context.transitFiles;
74+
}
75+
return providerContext;
76+
},
77+
allowPrivateNetwork: isPrivateNetworkAccessAllowed,
78+
});
79+
80+
export const proxy: ProviderProxyExecutor = defineProviderProxy({
81+
service,
82+
baseUrl: async (context) => {
83+
const credential = await requireApiKeyCredential(context, service);
84+
const value = asOptionalString(credential.metadata.apiBaseUrl) ?? asOptionalString(credential.values.baseUrl);
85+
return normalizeGitlabApiBaseUrl(value);
86+
},
87+
auth: { type: "api_key_header", name: "private-token" },
88+
allowPrivateNetwork: isPrivateNetworkAccessAllowed,
89+
});
4790

4891
export const credentialValidators: CredentialValidators = {
4992
async apiKey(input, { fetcher }) {
50-
const user = await gitlabRequestJson("/user", { apiKey: input.apiKey, fetcher }, "validate");
93+
const apiBaseUrl = normalizeGitlabApiBaseUrl(input.values.baseUrl);
94+
// Re-guard the shared validator fetcher with GitLab's private-network
95+
// opt-in so validating a self-hosted instance on a private network works
96+
// when the deployment allows it (createProviderFetch unwraps an
97+
// already-guarded fetcher).
98+
const guardedFetcher = createProviderFetch({
99+
fetch: fetcher,
100+
allowPrivateNetwork: isPrivateNetworkAccessAllowed,
101+
});
102+
const user = await gitlabRequestJson(
103+
"/user",
104+
{ apiKey: input.apiKey, apiBaseUrl, fetcher: guardedFetcher },
105+
"validate",
106+
);
51107
const userObject = asGitlabObject(user);
52108
const userId = readOptionalPrimitive(userObject.id);
53109
const username = asOptionalString(userObject.username);
54110
const name = asOptionalString(userObject.name);
111+
// Scope the account id by instance host for self-hosted connections so the
112+
// same numeric user id on different instances never collides.
113+
const instanceHost = apiBaseUrl === defaultGitlabApiBaseUrl ? undefined : new URL(apiBaseUrl).host;
55114

56115
return {
57116
profile: {
58-
accountId: userId ? `gitlab:${userId}` : (username ?? "gitlab:user"),
117+
accountId: instanceHost
118+
? `gitlab:${instanceHost}:${userId ?? username ?? "user"}`
119+
: userId
120+
? `gitlab:${userId}`
121+
: (username ?? "gitlab:user"),
59122
displayName: name ?? username ?? "GitLab User",
60123
},
61124
metadata: compactObject({
62-
apiBaseUrl: gitlabApiBaseUrl,
125+
apiBaseUrl,
63126
validationEndpoint: "/user",
64127
userId,
65128
username,
@@ -69,6 +132,42 @@ export const credentialValidators: CredentialValidators = {
69132
},
70133
};
71134

135+
/**
136+
* Resolves the GitLab API base URL for a connection. Empty input targets
137+
* GitLab.com; otherwise the self-hosted instance URL is validated, embedded
138+
* credentials are rejected, query/hash components are removed, and the path
139+
* is ensured to end in `/api/v4`. Private/overlay targets (RFC 1918,
140+
* Tailscale, NetBird, private hostnames) are only accepted when the
141+
* deployment opts in through `OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK`;
142+
* `allowPrivateNetwork` may be passed explicitly (used by tests).
143+
*/
144+
export function normalizeGitlabApiBaseUrl(
145+
value: unknown,
146+
allowPrivateNetwork: boolean = isPrivateNetworkAccessAllowed(),
147+
): string {
148+
const instanceUrl = trimOptionalString(value);
149+
if (!instanceUrl) {
150+
return defaultGitlabApiBaseUrl;
151+
}
152+
const url = assertPublicHttpUrl(instanceUrl, {
153+
fieldName: "baseUrl",
154+
createError: credentialError,
155+
allowPrivateNetwork,
156+
});
157+
if (url.username || url.password) {
158+
throw credentialError("baseUrl must not include credentials");
159+
}
160+
url.hash = "";
161+
url.search = "";
162+
const path = url.pathname.replace(/\/+$/u, "");
163+
url.pathname = path.endsWith("/api/v4") ? path : `${path}/api/v4`;
164+
return url.toString().replace(/\/$/u, "");
165+
}
166+
167+
function credentialError(message: string): ProviderRequestError {
168+
return new ProviderRequestError(400, message);
169+
}
170+
72171
async function listGitlabProjects(
73172
input: GitlabActionInput,
74173
context: GitlabActionContext,
@@ -174,7 +273,7 @@ async function gitlabRequest(
174273
context: GitlabActionContext,
175274
options: GitlabRequestOptions = {},
176275
): Promise<Response> {
177-
const url = new URL(`${gitlabApiBaseUrl}${path}`);
276+
const url = new URL(`${context.apiBaseUrl}${path}`);
178277
for (const [key, value] of Object.entries(options.query ?? {})) {
179278
if (value !== undefined) {
180279
url.searchParams.set(key, String(value));

0 commit comments

Comments
 (0)