Skip to content

fix(website): keep Azure DevOps request URLs inside the configured repo - #105

Open
Bircck wants to merge 2 commits into
mainfrom
fix/ado-request-forgery
Open

Bircck wants to merge 2 commits into
mainfrom
fix/ado-request-forgery

Conversation

@Bircck

@Bircck Bircck commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Closes CodeQL alert #3js/request-forgery, critical, open on main since 2026-08-20.

Independent of #104 and of #103; touches no workflow and no shared component. Safe to merge last.

The problem

GET /api/diagram/version read repositoryName straight off the query string:

const repositoryName = searchParams.get('repositoryName') || undefined;

and AzureDevOpsService interpolated it raw into the path:

`${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryName}/items?path=/${normalizedPath}&...`

The host always comes from ADO_ORGANIZATION_URL, so nobody can point this at an arbitrary server. But ?repositoryName=../../../OtherProject/_apis/git/repositories/Secret traverses to a different endpoint inside the organization — and that request goes out with the managed-identity token attached. filePath and commitId were likewise dropped unencoded into the query string, where an & could append parameters the caller never wrote.

The fix

Three changes. None alters behaviour for valid input.

1. version/route.ts takes the repository from configuration. Every sibling route (list, load, save, versions, export-png, repository-info) already reads process.env.ADO_REPOSITORY_NAME; this one route was the outlier. Nothing calls it with the parameter — in fact nothing calls this endpoint at all, only the plural /api/diagram/versions.

2. buildGitApiUrl builds all eight Git REST URLs. The repository path segment goes through encodeURIComponent, the query through URLSearchParams. getRepositoryInfo already encoded its segment; this applies the same treatment everywhere and removes five now-unused managedAuth.getConfig() calls.

3. makeAuthenticatedRequest refuses to leave the configured org/project. The single choke point through which every call passes, so the token cannot be attached to a URL outside {organizationUrl}{projectName}/. new URL() resolves .. before the comparison, so traversal is caught after normalization rather than by string matching. The guard returns the parsed URL and fetch is given that, so what goes out is exactly what was approved.

Verification

Each of the eight constructed URLs was checked to decode to the same origin, path and query as the string it replaced:

ok  listFiles  -> /myorg/My Project/_apis/git/repositories/MyRepo/items?scopePath=%2Fdiagrams&version=main&recursionLevel=OneLevel&api-version=7.0
ok  refs       -> …/refs?filter=heads%2Fmain&api-version=7.0
ok  pushes     -> …/pushes?api-version=7.0
ok  pullFile   -> …/items?path=%2Fdiagrams%2Fa.json&versionDescriptor.version=main&…
ok  commits    -> …/commits?searchCriteria.%24top=20&searchCriteria.itemPath=%2Fdiagrams%2Fa.json&…
ok  repoInfo   -> …/repositories/MyRepo?api-version=7.0

Percent-encoding in a query value is decoded server-side, so the request Azure DevOps receives is byte-for-byte the same as today. With hostile input the traversal now stays in one segment (..%2F..%2F..%2FOther%2F…) and the injected & becomes %26.

npm run lint and npm run build pass locally.

CodeQL alert #3 (js/request-forgery, critical): request data reached the
URL of an Azure DevOps call that carries the managed-identity token.

/api/diagram/version read `repositoryName` from the query string, and
AzureDevOpsService interpolated it raw into the path, so a value like
`../../../OtherProject/_apis/...` moved the request to a different
repository. `filePath` and `commitId` went unencoded into the query
string, where an `&` could add parameters the caller never wrote.

Three changes, all behaviour-preserving for valid input:

- `/api/diagram/version` now reads ADO_REPOSITORY_NAME from the
  environment, like every sibling route already did. Nothing calls this
  endpoint with the parameter — no frontend caller exists at all.
- `buildGitApiUrl` builds every Git REST URL, encoding the repository
  path segment and building the query with URLSearchParams.
  `getRepositoryInfo` already encoded its segment; this applies the same
  treatment everywhere.
- `makeAuthenticatedRequest` refuses to attach credentials to a URL
  outside the configured organization and project. `new URL()` resolves
  `..` before the check, so traversal is caught after normalization.

Each of the eight URLs decodes to the same origin, path and query as
before; `npm run lint` and `npm run build` pass.
assertConfiguredTarget parsed the URL to validate it but the raw string
was still handed to fetch, so what went out was never quite what was
approved. Returning the parsed URL and fetching that closes the gap and
sends the normalized form.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant