Skip to content

Commit 67e1c53

Browse files
KavithaSivabrodmartsap-cloud-sdk-bot[bot]dependabot[bot]
authored
chore: Add warning logs for cross-host CSRF and iss-based destination fetch (#6618)
--------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: brodmart <brodmart@users.noreply.github.com> Co-authored-by: sap-cloud-sdk-bot[bot] <274190970+sap-cloud-sdk-bot[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1 parent cf09f2c commit 67e1c53

9 files changed

Lines changed: 108 additions & 29 deletions

File tree

.changeset/warn-cross-host-csrf.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sap-cloud-sdk/http-client': patch
3+
---
4+
5+
[Fixed Issue] Warn when the CSRF token fetch URL has a different host than the request URL, as sensitive headers would be forwarded to the cross-host endpoint.

.github/actions/check-public-api/index.js

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/connectivity/src/scp-cf/destination/destination-accessor-provider-subscriber-lookup.spec.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ describe('JWT type and selection strategies', () => {
287287
package: 'connectivity',
288288
messageContext: 'destination-accessor-service'
289289
});
290-
const debugSpy = jest.spyOn(logger, 'debug');
290+
const warnSpy = jest.spyOn(logger, 'warn');
291291

292292
expect(
293293
await getDestinationFromDestinationService({
@@ -297,8 +297,9 @@ describe('JWT type and selection strategies', () => {
297297
})
298298
).toMatchObject(parseDestination(certificateSingleResponse));
299299

300-
expect(debugSpy).toHaveBeenCalledWith(
301-
'Using `iss` option instead of a full JWT to fetch a destination. No validation is performed.'
300+
expect(warnSpy).toHaveBeenCalledWith(
301+
'Using `iss` option instead of a full JWT to fetch a destination. No validation is performed.' +
302+
'Passing a user-supplied value without verifying it may lead to unintended cross-tenant destination access.'
302303
);
303304
});
304305

packages/connectivity/src/scp-cf/destination/destination-accessor-types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@ export interface DestinationAccessorOptions {
7474
* The value for `iss` is the issuer field of a JWT e.g. https://<your-subdomain>.localhost:8080/uaa/oauth/token.
7575
*
7676
* ATTENTION: If this property is used, no validation of the provided subdomain value is done.
77-
* This is differs from how the `jwt` is handled.
77+
* This differs from how the `jwt` is handled.
78+
* Never pass a user-supplied or request-derived value directly to this field without verification.
79+
* An unvalidated `iss` value controls which BTP tenant's destinations (including embedded credentials and
80+
* OAuth tokens) are returned, enabling cross-tenant data access if an attacker can influence this value.
7881
* So be careful that the used value is not manipulated and breaks the tenant isolation of your application.
7982
*/
8083
iss?: string;

packages/connectivity/src/scp-cf/destination/get-subscriber-token.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,9 @@ async function retrieveServiceToken(
7676

7777
function getJwtForServiceToken(iss?: string, decodedUserJwt?: JwtPayload) {
7878
if (iss) {
79-
logger.debug(
80-
'Using `iss` option instead of a full JWT to fetch a destination. No validation is performed.'
79+
logger.warn(
80+
'Using `iss` option instead of a full JWT to fetch a destination. No validation is performed.' +
81+
'Passing a user-supplied value without verifying it may lead to unintended cross-tenant destination access.'
8182
);
8283

8384
return { ext_attr: { zdn: getIssuerSubdomain({ iss }) } };

packages/connectivity/src/scp-cf/subdomain-replacer.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { URL } from 'url';
2-
import { removeTrailingSlashes } from '@sap-cloud-sdk/util';
2+
import { isValidUrl, removeTrailingSlashes } from '@sap-cloud-sdk/util';
33
import type { JwtPayload } from './jsonwebtoken-type';
44

55
/**
@@ -29,15 +29,6 @@ function getHost(url: URL): string {
2929
return host;
3030
}
3131

32-
function isValidUrl(url: string): boolean {
33-
try {
34-
new URL(url);
35-
return true;
36-
} catch {
37-
return false;
38-
}
39-
}
40-
4132
/**
4233
* @internal
4334
* Replaces the first part of the hostname (subdomain) in a URL.

packages/http-client/src/csrf-token-middleware.spec.ts

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import nock from 'nock';
22
import axios from 'axios';
3+
import { createLogger } from '@sap-cloud-sdk/util';
34
import { circuitBreaker, timeout } from '@sap-cloud-sdk/resilience';
45
import { circuitBreakers } from '@sap-cloud-sdk/resilience/internal';
56
import { csrf } from './csrf-token-middleware';
@@ -250,19 +251,42 @@ describe('CSRF middleware', () => {
250251
).resolves.not.toThrow();
251252
});
252253

253-
it('fetches the token with custom method', async () => {
254-
nock(host).get('/some/path/').reply(200, {}, csrfResponseHeaders);
254+
it('logs a warning when the CSRF token URL has a different host than the request URL', async () => {
255+
const csrfHost = 'http://other.example.com';
256+
nock(csrfHost).head('/csrf').reply(200, {}, csrfResponseHeaders);
255257
nock(host).post('/some/path').reply(200, {});
256-
await expect(
257-
executeHttpRequest(
258-
{ url: host },
259-
{
260-
method: 'POST',
261-
url: 'some/path',
262-
middleware: [csrf({ method: 'GET' })]
263-
},
264-
{ fetchCsrfToken: false }
265-
)
266-
).resolves.not.toThrow();
258+
const logger = createLogger('csrf-middleware');
259+
const warnSpy = jest.spyOn(logger, 'warn');
260+
await executeHttpRequest(
261+
{ url: host },
262+
{
263+
method: 'POST',
264+
url: 'some/path',
265+
middleware: [csrf({ url: `${csrfHost}/csrf` })]
266+
},
267+
{ fetchCsrfToken: false }
268+
);
269+
expect(warnSpy).toHaveBeenCalledWith(
270+
expect.stringContaining('different host')
271+
);
272+
});
273+
274+
it('does not log a warning when the CSRF token URL has the same host as the request URL', async () => {
275+
nock(host).head('/alternative/path').reply(200, {}, csrfResponseHeaders);
276+
nock(host).post('/some/path').reply(200, {});
277+
const logger = createLogger('csrf-middleware');
278+
const warnSpy = jest.spyOn(logger, 'warn');
279+
await executeHttpRequest(
280+
{ url: host },
281+
{
282+
method: 'POST',
283+
url: 'some/path',
284+
middleware: [csrf({ url: `${host}/alternative/path` })]
285+
},
286+
{ fetchCsrfToken: false }
287+
);
288+
expect(warnSpy).not.toHaveBeenCalledWith(
289+
expect.stringContaining('different host')
290+
);
267291
});
268292
});

packages/http-client/src/csrf-token-middleware.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { URL } from 'url';
12
import {
23
createLogger,
34
ErrorWithCause,
45
first,
56
flatten,
7+
isValidUrl,
68
pickIgnoreCase,
79
pickValueIgnoreCase,
810
removeTrailingSlashes
@@ -166,13 +168,34 @@ function findCsrfHeader(
166168
return { 'x-csrf-token': csrfHeader, ...cookieHeader };
167169
}
168170

171+
function isCrossHost(
172+
csrfUrl: string | undefined,
173+
requestUrl: string | undefined
174+
): boolean {
175+
if (!csrfUrl || !requestUrl) {
176+
return false;
177+
}
178+
if (!isValidUrl(csrfUrl) || !isValidUrl(requestUrl)) {
179+
return false;
180+
}
181+
return new URL(csrfUrl).hostname !== new URL(requestUrl).hostname;
182+
}
183+
169184
async function makeCsrfRequests(
170185
requestConfig: HttpRequestConfig,
171186
options: CsrfMiddlewareOptions & { context: HttpMiddlewareContext }
172187
): Promise<CsrfHeaderWithCookie | undefined> {
173188
// eslint-disable-next-line @typescript-eslint/no-unused-vars
174189
const { data, params, parameterEncoder, ...requestConfigWithoutData } =
175190
requestConfig;
191+
192+
// TODO: In v5, make cross-host CSRF token fetching opt-in instead of just warning.
193+
if (isCrossHost(options.url, requestConfig.baseURL)) {
194+
logger.warn(
195+
`The CSRF token fetch URL (${options.url}) has a different host than the request URL (${requestConfig.baseURL}). Sensitive headers will be forwarded to the CSRF token endpoint.`
196+
);
197+
}
198+
176199
const axiosConfig: HttpRequestConfigWithOrigin = {
177200
...requestConfigWithoutData,
178201
method: options.method || 'head',

packages/util/src/url.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
import axios from 'axios';
22

3+
/**
4+
* Checks whether a string is a valid URL.
5+
* @param url - String to check.
6+
* @returns True if the string is a valid URL, false otherwise.
7+
* @internal
8+
*/
9+
export function isValidUrl(url: string): boolean {
10+
try {
11+
new URL(url);
12+
return true;
13+
} catch {
14+
return false;
15+
}
16+
}
17+
318
/**
419
* Checks whether a URL is existing via a head request.
520
* @param url - URL to be checked.

0 commit comments

Comments
 (0)