Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion Website/app/api/auth/azuredevops/ManagedIdentityAuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,32 @@ class ManagedIdentityAuth {
}
}

/**
* Rejects any URL that does not sit inside the configured organization and
* project. Callers assemble URLs from request data, so this is the last
* point at which a value that escaped its path segment can be caught -
* before the managed-identity token is attached and sent. `new URL()`
* resolves `..` first, so traversal is compared after normalization.
*/
private assertConfiguredTarget(url: string): URL {
const target = new URL(url);
const allowed = new URL(`${this.config.organizationUrl}${this.config.projectName}/`);

if (target.origin !== allowed.origin || !target.pathname.startsWith(allowed.pathname)) {
throw new Error(
`Refusing to send Azure DevOps credentials to ${target.origin}${target.pathname} - ` +
`only ${allowed.origin}${allowed.pathname} is configured.`
);
}

return target;
}

async makeAuthenticatedRequest(url: string, options: RequestInit = {}): Promise<Response> {
// Send the URL that was checked, not the string it was parsed from, so
// the request cannot differ from what the guard approved.
const target = this.assertConfiguredTarget(url);

// Use PAT for local development, Managed Identity for production
const pat = process.env.ADO_PAT;
const isLocal = process.env.NODE_ENV === 'development' || pat;
Expand All @@ -71,7 +96,7 @@ class ManagedIdentityAuth {
};
}

return fetch(url, {
return fetch(target, {
...options,
headers: {
...options.headers,
Expand Down
5 changes: 4 additions & 1 deletion Website/app/api/diagram/version/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const filePath = searchParams.get('filePath');
const commitId = searchParams.get('commitId');
const repositoryName = searchParams.get('repositoryName') || undefined;
// Sourced from configuration, never from the request: it lands in the
// path of the Azure DevOps URL, where a caller-supplied value could
// point the authenticated request at another repository.
const repositoryName = process.env.ADO_REPOSITORY_NAME || '';

if (!filePath) {
return NextResponse.json(
Expand Down
79 changes: 56 additions & 23 deletions Website/app/api/services/AzureDevOpsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,37 @@ class AzureDevOpsError extends Error {
}
}

/**
* Builds a URL for the Azure DevOps Git REST API.
*
* The organization and project come from configuration, but the repository
* name and the query values are routinely taken from a request. Interpolating
* those straight into the URL lets a `../` walk out of the repositories path
* and an `&` append parameters the caller never wrote - on a request that
* carries the managed-identity token. Encoding each part keeps every value
* inside the slot it was meant for.
*/
function buildGitApiUrl(
repositoryName: string | undefined,
resourcePath = '',
query: Record<string, string | number | undefined> = {}
): string {
const config = managedAuth.getConfig();

const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) {
params.set(key, String(value));
}
}
params.set('api-version', '7.0');

const repository = encodeURIComponent(repositoryName ?? '');
const resource = resourcePath ? `/${resourcePath}` : '';

return `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repository}${resource}?${params}`;
}

/**
* Lists files in the Azure DevOps Git repository
* @param options Configuration for file retrieval
Expand All @@ -97,12 +128,13 @@ export async function listFilesFromRepo(options: LoadFileOptions): Promise<GitIt
} = options;

try {
// Get ADO configuration
const config = managedAuth.getConfig();

// Construct the API URL for listing items in a folder
const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
const itemsUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryName}/items?scopePath=/${normalizedPath}&version=${branch}&recursionLevel=OneLevel&api-version=7.0`;
const itemsUrl = buildGitApiUrl(repositoryName, 'items', {
scopePath: `/${normalizedPath}`,
version: branch,
recursionLevel: 'OneLevel'
});

const response = await managedAuth.makeAuthenticatedRequest(itemsUrl);

Expand Down Expand Up @@ -157,9 +189,6 @@ export async function commitFileToRepo(options: CreateFileOptions): Promise<GitC
} = options;

try {
// Get ADO configuration
const config = managedAuth.getConfig();

// Validate inputs
if (!filePath || content === undefined) {
throw new AzureDevOpsError('File path and content are required');
Expand All @@ -173,7 +202,7 @@ export async function commitFileToRepo(options: CreateFileOptions): Promise<GitC
: Buffer.from(content).toString('base64');

// Get the latest commit ID for the branch (needed for push operation)
const refsUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryName}/refs?filter=heads/${branch}&api-version=7.0`;
const refsUrl = buildGitApiUrl(repositoryName, 'refs', { filter: `heads/${branch}` });
const refsResponse = await managedAuth.makeAuthenticatedRequest(refsUrl);

if (!refsResponse.ok) {
Expand Down Expand Up @@ -220,7 +249,7 @@ export async function commitFileToRepo(options: CreateFileOptions): Promise<GitC
};

// Push the changes
const pushUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryName}/pushes?api-version=7.0`;
const pushUrl = buildGitApiUrl(repositoryName, 'pushes');
const pushResponse = await managedAuth.makeAuthenticatedRequest(pushUrl, {
method: 'POST',
body: JSON.stringify(pushPayload)
Expand Down Expand Up @@ -261,17 +290,19 @@ export async function pullFileFromRepo<T>(options: LoadFileOptions): Promise<T>
} = options;

try {
// Get ADO configuration
const config = managedAuth.getConfig();

// Validate inputs
if (!filePath) {
throw new AzureDevOpsError('File path is required');
}

// Construct the API URL for getting file content
const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
const fileUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryName}/items?path=/${normalizedPath}&versionDescriptor.version=${branch}&versionDescriptor.versionType=branch&includeContent=true&api-version=7.0`;
const fileUrl = buildGitApiUrl(repositoryName, 'items', {
path: `/${normalizedPath}`,
'versionDescriptor.version': branch,
'versionDescriptor.versionType': 'branch',
includeContent: 'true'
});

const response = await managedAuth.makeAuthenticatedRequest(fileUrl);

Expand Down Expand Up @@ -316,17 +347,17 @@ export async function listFileVersions(options: FileVersionOptions): Promise<Fil
} = options;

try {
// Get ADO configuration
const config = managedAuth.getConfig();

// Validate inputs
if (!filePath) {
throw new AzureDevOpsError('File path is required');
}

// Construct the API URL for getting file commit history
const normalizedPath = filePath.startsWith('/') ? filePath : `/${filePath}`;
const commitsUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryName}/commits?searchCriteria.$top=${maxVersions}&searchCriteria.itemPath=${normalizedPath}&api-version=7.0`;
const commitsUrl = buildGitApiUrl(repositoryName, 'commits', {
'searchCriteria.$top': maxVersions,
'searchCriteria.itemPath': normalizedPath
});

const response = await managedAuth.makeAuthenticatedRequest(commitsUrl);

Expand All @@ -350,7 +381,7 @@ export async function listFileVersions(options: FileVersionOptions): Promise<Fil
for (const commit of commitsData.value) {
try {
// Get the changes for this specific commit to determine the change type
const changesUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryName}/commits/${commit.commitId}/changes?api-version=7.0`;
const changesUrl = buildGitApiUrl(repositoryName, `commits/${encodeURIComponent(commit.commitId)}/changes`);
const changesResponse = await managedAuth.makeAuthenticatedRequest(changesUrl);

if (changesResponse.ok) {
Expand Down Expand Up @@ -409,17 +440,19 @@ export async function pullFileVersion<T>(options: LoadFileVersionOptions): Promi
} = options;

try {
// Get ADO configuration
const config = managedAuth.getConfig();

// Validate inputs
if (!filePath || !commitId) {
throw new AzureDevOpsError('File path and commit ID are required');
}

// Construct the API URL for getting file content at specific commit
const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
const fileUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${repositoryName}/items?path=/${normalizedPath}&versionDescriptor.version=${commitId}&versionDescriptor.versionType=commit&includeContent=true&api-version=7.0`;
const fileUrl = buildGitApiUrl(repositoryName, 'items', {
path: `/${normalizedPath}`,
'versionDescriptor.version': commitId,
'versionDescriptor.versionType': 'commit',
includeContent: 'true'
});

const response = await managedAuth.makeAuthenticatedRequest(fileUrl);

Expand Down Expand Up @@ -461,7 +494,7 @@ export async function getRepositoryInfo(repositoryName?: string): Promise<{ id:
throw new AzureDevOpsError('Repository name not found. Set AdoRepositoryName environment variable or pass repositoryName parameter.');
}

const repoUrl = `${config.organizationUrl}${config.projectName}/_apis/git/repositories/${encodeURIComponent(repoName)}?api-version=7.0`;
const repoUrl = buildGitApiUrl(repoName);
const response = await managedAuth.makeAuthenticatedRequest(repoUrl);

if (!response.ok) {
Expand Down
Loading