fix(gitlab): pin pagination cursor to configured host + consolidate isSameOrigin (#4873)

* 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 <noreply@anthropic.com>

* 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.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-06-03 14:42:54 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 85942a5301
commit 80d966d336
3 changed files with 13 additions and 17 deletions
+4
View File
@@ -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'
@@ -741,6 +742,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()}`
+7 -6
View File
@@ -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
}
+2 -11
View File
@@ -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())
}
/**