From 0a7ebd848f980a7ea4bcb9fcbb327e1076f12234 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 3 Jun 2026 14:13:04 -0700 Subject: [PATCH 1/2] fix(gitlab): pin pagination cursor to configured host before following it The repository-tree keyset cursor stores GitLab's verbatim rel="next" URL and re-fetches it with an Authorization: Bearer header. Assert the cursor's origin matches the configured apiBase before following it, so a tampered or corrupted fileNextUrl cannot exfiltrate the access token to an attacker-controlled host. Fails closed on mismatch. Co-Authored-By: Claude Opus 4.8 --- apps/sim/connectors/gitlab/gitlab.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/sim/connectors/gitlab/gitlab.ts b/apps/sim/connectors/gitlab/gitlab.ts index a7f0917c7e..d7b7802b6d 100644 --- a/apps/sim/connectors/gitlab/gitlab.ts +++ b/apps/sim/connectors/gitlab/gitlab.ts @@ -103,6 +103,21 @@ function parseNextLink(linkHeader: string | null): string | undefined { return undefined } +/** + * Returns true when `candidate` resolves to the same origin as `base`. Used to + * pin a persisted pagination cursor to the configured GitLab host before + * following it with an `Authorization` header, so a tampered or corrupted + * `fileNextUrl` cannot exfiltrate the access token to an attacker-controlled + * host. Returns false on any unparseable URL. + */ +function isSameOrigin(candidate: string, base: string): boolean { + try { + return new URL(candidate).origin === new URL(base).origin + } catch { + return false + } +} + /** * Returns the ordered list of active sync phases for a content-type choice. */ @@ -741,6 +756,9 @@ export const gitlabConnector: ConnectorConfig = { per_page: String(PAGE_SIZE), pagination: 'keyset', }) + if (state.fileNextUrl && !isSameOrigin(state.fileNextUrl, apiBase)) { + throw new Error('GitLab pagination cursor points to an unexpected host') + } const url = state.fileNextUrl ?? `${apiBase}/projects/${encodedProject}/repository/tree?${treeParams.toString()}` From 6c7e41acaf5a34fc170f7e9893c6b2c6790971fc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 3 Jun 2026 14:22:39 -0700 Subject: [PATCH 2/2] improvement(validation): generalize isSameOrigin and reuse across connectors/tools Add an optional base argument to the shared isSameOrigin (defaulting to the app base URL) so callers can pin a URL to any trusted origin. The GitLab connector's cursor host-check and the tools self-origin check now consume the shared helper instead of their own URL-parsing. --- apps/sim/connectors/gitlab/gitlab.ts | 16 +--------------- apps/sim/lib/core/utils/validation.ts | 13 +++++++------ apps/sim/tools/index.ts | 13 ++----------- 3 files changed, 10 insertions(+), 32 deletions(-) diff --git a/apps/sim/connectors/gitlab/gitlab.ts b/apps/sim/connectors/gitlab/gitlab.ts index d7b7802b6d..66e71796d6 100644 --- a/apps/sim/connectors/gitlab/gitlab.ts +++ b/apps/sim/connectors/gitlab/gitlab.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { GitLabIcon } from '@/components/icons' +import { isSameOrigin } from '@/lib/core/utils/validation' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' import { computeContentHash, joinTagArray, parseTagDate } from '@/connectors/utils' @@ -103,21 +104,6 @@ function parseNextLink(linkHeader: string | null): string | undefined { return undefined } -/** - * Returns true when `candidate` resolves to the same origin as `base`. Used to - * pin a persisted pagination cursor to the configured GitLab host before - * following it with an `Authorization` header, so a tampered or corrupted - * `fileNextUrl` cannot exfiltrate the access token to an attacker-controlled - * host. Returns false on any unparseable URL. - */ -function isSameOrigin(candidate: string, base: string): boolean { - try { - return new URL(candidate).origin === new URL(base).origin - } catch { - return false - } -} - /** * Returns the ordered list of active sync phases for a content-type choice. */ diff --git a/apps/sim/lib/core/utils/validation.ts b/apps/sim/lib/core/utils/validation.ts index 5fcfc4d357..ddb3136c6f 100644 --- a/apps/sim/lib/core/utils/validation.ts +++ b/apps/sim/lib/core/utils/validation.ts @@ -1,17 +1,18 @@ import { getBaseUrl } from './urls' /** - * Checks if a URL is same-origin with the application's base URL. - * Used to prevent open redirect vulnerabilities. + * Checks if a URL is same-origin with a base URL. Defaults to the application's + * base URL, used to prevent open redirect vulnerabilities; pass an explicit + * `base` to pin a URL to another trusted origin (e.g. a configured API host) + * before following it with credentials. * * @param url - The URL to validate + * @param base - The origin to compare against (defaults to the app base URL) * @returns True if the URL is same-origin, false otherwise (secure default) */ -export function isSameOrigin(url: string): boolean { +export function isSameOrigin(url: string, base: string = getBaseUrl()): boolean { try { - const targetUrl = new URL(url) - const appUrl = new URL(getBaseUrl()) - return targetUrl.origin === appUrl.origin + return new URL(url).origin === new URL(base).origin } catch { return false } diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index d98c6bd0ce..3c83d634f0 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -19,6 +19,7 @@ import { } from '@/lib/core/utils/stream-limits' import { getBaseUrl, getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { isUserFile } from '@/lib/core/utils/user-file' +import { isSameOrigin } from '@/lib/core/utils/validation' import { SIM_VIA_HEADER, serializeCallChain } from '@/lib/execution/call-chain' import { parseMcpToolId } from '@/lib/mcp/utils' import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -1364,17 +1365,7 @@ function isErrorResponse( * the platform's own workflow execution endpoints via absolute URL. */ function isSelfOriginUrl(url: string): boolean { - try { - const targetOrigin = new URL(url).origin - const publicOrigin = new URL(getBaseUrl()).origin - if (targetOrigin === publicOrigin) return true - - const internalOrigin = new URL(getInternalApiBaseUrl()).origin - if (targetOrigin === internalOrigin) return true - } catch { - return false - } - return false + return isSameOrigin(url, getBaseUrl()) || isSameOrigin(url, getInternalApiBaseUrl()) } /**