fix(dataverse): strip the bearer token when a request redirects

The host allowlist added alongside the connector work only constrains the
initial destination. `secureFetchWithPinnedIP` follows redirects and keeps the
`Authorization` header unless a tool opts out, so a redirect away from an
allowed Dataverse origin would forward the caller's OAuth token to whatever
host answers. Dataverse redirects in normal operation — file downloads hand
back a signed storage URL, and environment hosts move between regional
origins — so this is reachable without a compromised environment URL.

Sets `stripAuthOnRedirect` on all 18 Dataverse tools, matching the existing
GitHub job-logs and Windchill precedent.
This commit is contained in:
Waleed Latif
2026-08-14 12:59:11 -07:00
parent fba0f1393b
commit 903c94e913
18 changed files with 108 additions and 0 deletions
@@ -80,6 +80,12 @@ export const dataverseAssociateTool: ToolConfig<
return `${baseUrl}/api/data/v9.2/${params.entitySetName.trim()}(${params.recordId.trim()})/${params.navigationProperty.trim()}/$ref`
},
method: (params) => (params.navigationType === 'single' ? 'PUT' : 'POST'),
/**
* Dataverse endpoints redirect (file downloads issue a signed storage URL,
* and environment hosts redirect between regional origins), so drop the
* bearer token rather than forward it to whatever origin answers.
*/
stripAuthOnRedirect: true,
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
@@ -62,6 +62,12 @@ export const dataverseCreateMultipleTool: ToolConfig<
return `${baseUrl}/api/data/v9.2/${params.entitySetName.trim()}/Microsoft.Dynamics.CRM.CreateMultiple`
},
method: 'POST',
/**
* Dataverse endpoints redirect (file downloads issue a signed storage URL,
* and environment hosts redirect between regional origins), so drop the
* bearer token rather than forward it to whatever origin answers.
*/
stripAuthOnRedirect: true,
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
@@ -55,6 +55,12 @@ export const dataverseCreateRecordTool: ToolConfig<
return `${baseUrl}/api/data/v9.2/${params.entitySetName.trim()}`
},
method: 'POST',
/**
* Dataverse endpoints redirect (file downloads issue a signed storage URL,
* and environment hosts redirect between regional origins), so drop the
* bearer token rather than forward it to whatever origin answers.
*/
stripAuthOnRedirect: true,
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
@@ -53,6 +53,12 @@ export const dataverseDeleteRecordTool: ToolConfig<
return `${baseUrl}/api/data/v9.2/${params.entitySetName.trim()}(${params.recordId.trim()})`
},
method: 'DELETE',
/**
* Dataverse endpoints redirect (file downloads issue a signed storage URL,
* and environment hosts redirect between regional origins), so drop the
* bearer token rather than forward it to whatever origin answers.
*/
stripAuthOnRedirect: true,
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'OData-MaxVersion': '4.0',
@@ -74,6 +74,12 @@ export const dataverseDisassociateTool: ToolConfig<
return `${baseUrl}/api/data/v9.2/${entitySetName}(${recordId})/${navigationProperty}/$ref`
},
method: 'DELETE',
/**
* Dataverse endpoints redirect (file downloads issue a signed storage URL,
* and environment hosts redirect between regional origins), so drop the
* bearer token rather than forward it to whatever origin answers.
*/
stripAuthOnRedirect: true,
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'OData-MaxVersion': '4.0',
@@ -60,6 +60,12 @@ export const dataverseDownloadFileTool: ToolConfig<
return `${baseUrl}/api/data/v9.2/${params.entitySetName.trim()}(${params.recordId.trim()})/${params.fileColumn.trim()}/$value`
},
method: 'GET',
/**
* Dataverse endpoints redirect (file downloads issue a signed storage URL,
* and environment hosts redirect between regional origins), so drop the
* bearer token rather than forward it to whatever origin answers.
*/
stripAuthOnRedirect: true,
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'OData-MaxVersion': '4.0',
@@ -78,6 +78,12 @@ export const dataverseExecuteActionTool: ToolConfig<
return `${baseUrl}/api/data/v9.2/${actionName}`
},
method: 'POST',
/**
* Dataverse endpoints redirect (file downloads issue a signed storage URL,
* and environment hosts redirect between regional origins), so drop the
* bearer token rather than forward it to whatever origin answers.
*/
stripAuthOnRedirect: true,
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
@@ -83,6 +83,12 @@ export const dataverseExecuteFunctionTool: ToolConfig<
return `${baseUrl}/api/data/v9.2/${functionName}${paramStr}${querySuffix}`
},
method: 'GET',
/**
* Dataverse endpoints redirect (file downloads issue a signed storage URL,
* and environment hosts redirect between regional origins), so drop the
* bearer token rather than forward it to whatever origin answers.
*/
stripAuthOnRedirect: true,
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'OData-MaxVersion': '4.0',
@@ -57,6 +57,12 @@ export const dataverseFetchXmlQueryTool: ToolConfig<
return `${baseUrl}/api/data/v9.2/${params.entitySetName.trim()}?fetchXml=${encodedFetchXml}`
},
method: 'GET',
/**
* Dataverse endpoints redirect (file downloads issue a signed storage URL,
* and environment hosts redirect between regional origins), so drop the
* bearer token rather than forward it to whatever origin answers.
*/
stripAuthOnRedirect: true,
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'OData-MaxVersion': '4.0',
@@ -76,6 +76,12 @@ export const dataverseGetEntityMetadataTool: ToolConfig<
return `${baseUrl}/api/data/v9.2/EntityDefinitions(LogicalName='${entityLogicalName}')${query}`
},
method: 'GET',
/**
* Dataverse endpoints redirect (file downloads issue a signed storage URL,
* and environment hosts redirect between regional origins), so drop the
* bearer token rather than forward it to whatever origin answers.
*/
stripAuthOnRedirect: true,
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'OData-MaxVersion': '4.0',
@@ -71,6 +71,12 @@ export const dataverseGetRecordTool: ToolConfig<
return `${baseUrl}/api/data/v9.2/${params.entitySetName.trim()}(${params.recordId.trim()})${query}`
},
method: 'GET',
/**
* Dataverse endpoints redirect (file downloads issue a signed storage URL,
* and environment hosts redirect between regional origins), so drop the
* bearer token rather than forward it to whatever origin answers.
*/
stripAuthOnRedirect: true,
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'OData-MaxVersion': '4.0',
@@ -93,6 +93,12 @@ export const dataverseListRecordsTool: ToolConfig<
return `${baseUrl}/api/data/v9.2/${params.entitySetName.trim()}${query}`
},
method: 'GET',
/**
* Dataverse endpoints redirect (file downloads issue a signed storage URL,
* and environment hosts redirect between regional origins), so drop the
* bearer token rather than forward it to whatever origin answers.
*/
stripAuthOnRedirect: true,
headers: (params) => {
// Dataverse ignores $top entirely when Prefer: odata.maxpagesize is also sent, so the
// page-size preference is only applied when the caller hasn't requested an explicit $top.
@@ -98,6 +98,12 @@ export const dataverseSearchTool: ToolConfig<DataverseSearchParams, DataverseSea
return `${baseUrl}/api/data/v9.2/searchquery`
},
method: 'POST',
/**
* Dataverse endpoints redirect (file downloads issue a signed storage URL,
* and environment hosts redirect between regional origins), so drop the
* bearer token rather than forward it to whatever origin answers.
*/
stripAuthOnRedirect: true,
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
@@ -62,6 +62,12 @@ export const dataverseUpdateMultipleTool: ToolConfig<
return `${baseUrl}/api/data/v9.2/${params.entitySetName.trim()}/Microsoft.Dynamics.CRM.UpdateMultiple`
},
method: 'POST',
/**
* Dataverse endpoints redirect (file downloads issue a signed storage URL,
* and environment hosts redirect between regional origins), so drop the
* bearer token rather than forward it to whatever origin answers.
*/
stripAuthOnRedirect: true,
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
@@ -60,6 +60,12 @@ export const dataverseUpdateRecordTool: ToolConfig<
return `${baseUrl}/api/data/v9.2/${params.entitySetName.trim()}(${params.recordId.trim()})`
},
method: 'PATCH',
/**
* Dataverse endpoints redirect (file downloads issue a signed storage URL,
* and environment hosts redirect between regional origins), so drop the
* bearer token rather than forward it to whatever origin answers.
*/
stripAuthOnRedirect: true,
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
@@ -70,6 +70,12 @@ export const dataverseUploadFileTool: ToolConfig<
request: {
url: '/api/tools/microsoft-dataverse/upload-file',
method: 'POST',
/**
* Dataverse endpoints redirect (file downloads issue a signed storage URL,
* and environment hosts redirect between regional origins), so drop the
* bearer token rather than forward it to whatever origin answers.
*/
stripAuthOnRedirect: true,
headers: () => ({
'Content-Type': 'application/json',
}),
@@ -61,6 +61,12 @@ export const dataverseUpsertRecordTool: ToolConfig<
return `${baseUrl}/api/data/v9.2/${params.entitySetName.trim()}(${params.recordId.trim()})`
},
method: 'PATCH',
/**
* Dataverse endpoints redirect (file downloads issue a signed storage URL,
* and environment hosts redirect between regional origins), so drop the
* bearer token rather than forward it to whatever origin answers.
*/
stripAuthOnRedirect: true,
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
@@ -39,6 +39,12 @@ export const dataverseWhoAmITool: ToolConfig<DataverseWhoAmIParams, DataverseWho
return `${baseUrl}/api/data/v9.2/WhoAmI()`
},
method: 'GET',
/**
* Dataverse endpoints redirect (file downloads issue a signed storage URL,
* and environment hosts redirect between regional origins), so drop the
* bearer token rather than forward it to whatever origin answers.
*/
stripAuthOnRedirect: true,
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'OData-MaxVersion': '4.0',