fix(security): refine dynamic config URL trust policy and document trusted-origin behavior (#5973)
* fix(security): refine dynamic config URL trust policy and document trusted-origin behavior * Update documentation for Authorization and Authentication. * Add isSameOrigin to resolveConfigFetchPolicy return value.
This commit is contained in:
parent
5ba8339037
commit
f3cca21e49
@ -61,6 +61,7 @@ function resolveConfigFetchPolicy(rawUrl, policy = {}) {
|
|||||||
const { allowedOrigins = [], userAuthenticationService } = policy;
|
const { allowedOrigins = [], userAuthenticationService } = policy;
|
||||||
const parsedUrl = resolveConfigUrl(rawUrl);
|
const parsedUrl = resolveConfigUrl(rawUrl);
|
||||||
const protocol = parsedUrl.protocol.toLowerCase();
|
const protocol = parsedUrl.protocol.toLowerCase();
|
||||||
|
const isSameOrigin = parsedUrl.origin === window.location.origin;
|
||||||
|
|
||||||
if (!['http:', 'https:'].includes(protocol)) {
|
if (!['http:', 'https:'].includes(protocol)) {
|
||||||
throw new Error('Only HTTP(S) URLs are allowed for dynamic datasource configuration');
|
throw new Error('Only HTTP(S) URLs are allowed for dynamic datasource configuration');
|
||||||
@ -78,7 +79,7 @@ function resolveConfigFetchPolicy(rawUrl, policy = {}) {
|
|||||||
userAuthenticationService?.getAuthorizationHeader?.()?.Authorization
|
userAuthenticationService?.getAuthorizationHeader?.()?.Authorization
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated && !isSameOrigin) {
|
||||||
const normalizedAllowedOrigins = normalizeAllowedOrigins(allowedOrigins);
|
const normalizedAllowedOrigins = normalizeAllowedOrigins(allowedOrigins);
|
||||||
if (!normalizedAllowedOrigins.length || !normalizedAllowedOrigins.includes(parsedUrl.origin)) {
|
if (!normalizedAllowedOrigins.length || !normalizedAllowedOrigins.includes(parsedUrl.origin)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@ -91,12 +92,13 @@ function resolveConfigFetchPolicy(rawUrl, policy = {}) {
|
|||||||
parsedUrl,
|
parsedUrl,
|
||||||
normalizedUrl: parsedUrl.toString(),
|
normalizedUrl: parsedUrl.toString(),
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
|
isSameOrigin,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchConfigJson(normalizedPolicy) {
|
async function fetchConfigJson(normalizedPolicy) {
|
||||||
const { normalizedUrl, isAuthenticated } = normalizedPolicy;
|
const { normalizedUrl, isAuthenticated, isSameOrigin } = normalizedPolicy;
|
||||||
const response = isAuthenticated
|
const response = isAuthenticated || isSameOrigin
|
||||||
? await fetch(normalizedUrl)
|
? await fetch(normalizedUrl)
|
||||||
: await fetch(normalizedUrl, {
|
: await fetch(normalizedUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|||||||
@ -12,6 +12,7 @@ describe('secureConfigFetch', () => {
|
|||||||
|
|
||||||
expect(result.normalizedUrl).toBe('https://untrusted.example.com/config.json');
|
expect(result.normalizedUrl).toBe('https://untrusted.example.com/config.json');
|
||||||
expect(result.isAuthenticated).toBe(false);
|
expect(result.isAuthenticated).toBe(false);
|
||||||
|
expect(result.isSameOrigin).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blocks non-allowlisted origins in authenticated environments', () => {
|
it('blocks non-allowlisted origins in authenticated environments', () => {
|
||||||
@ -35,6 +36,7 @@ describe('secureConfigFetch', () => {
|
|||||||
|
|
||||||
expect(result.normalizedUrl).toBe('http://localhost:5000/config.json');
|
expect(result.normalizedUrl).toBe('http://localhost:5000/config.json');
|
||||||
expect(result.isAuthenticated).toBe(true);
|
expect(result.isAuthenticated).toBe(true);
|
||||||
|
expect(result.isSameOrigin).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blocks authenticated fetch when allowlist is missing', () => {
|
it('blocks authenticated fetch when allowlist is missing', () => {
|
||||||
@ -47,6 +49,18 @@ describe('secureConfigFetch', () => {
|
|||||||
).toThrow('Blocked remote configuration origin');
|
).toThrow('Blocked remote configuration origin');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('allows same-origin in authenticated environments without allowlist', () => {
|
||||||
|
const result = resolveConfigFetchPolicy('/protected/config.json', {
|
||||||
|
userAuthenticationService: {
|
||||||
|
getAuthorizationHeader: () => ({ Authorization: 'Bearer token123' }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.normalizedUrl).toBe(`${window.location.origin}/protected/config.json`);
|
||||||
|
expect(result.isAuthenticated).toBe(true);
|
||||||
|
expect(result.isSameOrigin).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects embedded userinfo in config URLs', () => {
|
it('rejects embedded userinfo in config URLs', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
resolveConfigFetchPolicy('https://user:pass@trusted.example.com/config.json', {
|
resolveConfigFetchPolicy('https://user:pass@trusted.example.com/config.json', {
|
||||||
@ -71,7 +85,7 @@ describe('secureConfigFetch', () => {
|
|||||||
global.fetch = originalFetch;
|
global.fetch = originalFetch;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses hardened fetch options in unauthenticated environments', async () => {
|
it('uses hardened fetch options for unauthenticated cross-origin requests', async () => {
|
||||||
global.fetch.mockResolvedValue({
|
global.fetch.mockResolvedValue({
|
||||||
status: 200,
|
status: 200,
|
||||||
ok: true,
|
ok: true,
|
||||||
@ -81,6 +95,7 @@ describe('secureConfigFetch', () => {
|
|||||||
await fetchConfigJson({
|
await fetchConfigJson({
|
||||||
normalizedUrl: 'https://example.com/config.json',
|
normalizedUrl: 'https://example.com/config.json',
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
isSameOrigin: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
@ -95,6 +110,24 @@ describe('secureConfigFetch', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses simple fetch for unauthenticated same-origin requests', async () => {
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ ok: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetchConfigJson({
|
||||||
|
normalizedUrl: `${window.location.origin}/protected/config.json`,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isSameOrigin: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
`${window.location.origin}/protected/config.json`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('uses simple fetch in authenticated environments', async () => {
|
it('uses simple fetch in authenticated environments', async () => {
|
||||||
global.fetch.mockResolvedValue({
|
global.fetch.mockResolvedValue({
|
||||||
status: 200,
|
status: 200,
|
||||||
@ -105,6 +138,7 @@ describe('secureConfigFetch', () => {
|
|||||||
await fetchConfigJson({
|
await fetchConfigJson({
|
||||||
normalizedUrl: 'https://trusted.example.com/config.json',
|
normalizedUrl: 'https://trusted.example.com/config.json',
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
|
isSameOrigin: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(global.fetch).toHaveBeenCalledWith('https://trusted.example.com/config.json');
|
expect(global.fetch).toHaveBeenCalledWith('https://trusted.example.com/config.json');
|
||||||
|
|||||||
@ -85,14 +85,17 @@ Allowed entry format for `dangerouslyAllowedOriginsForAuthenticatedEnvironments`
|
|||||||
Policy summary:
|
Policy summary:
|
||||||
|
|
||||||
- In unauthenticated environments, any HTTP(S) `?url=` origin is allowed.
|
- In unauthenticated environments, any HTTP(S) `?url=` origin is allowed.
|
||||||
- In authenticated environments, `?url=` origins must be present in `dangerouslyAllowedOriginsForAuthenticatedEnvironments`, otherwise loading fails closed.
|
- In authenticated environments, same-origin `?url=` values are allowed by default.
|
||||||
- In unauthenticated environments, config URLs are fetched with:
|
- In authenticated environments, cross-origin `?url=` values must be present in `dangerouslyAllowedOriginsForAuthenticatedEnvironments`, otherwise loading fails closed.
|
||||||
|
- In unauthenticated environments, cross-origin config URLs are fetched with:
|
||||||
- `method: 'GET'`
|
- `method: 'GET'`
|
||||||
- `mode: 'cors'`
|
- `mode: 'cors'`
|
||||||
- `credentials: 'omit'`
|
- `credentials: 'omit'`
|
||||||
- `redirect: 'error'`
|
- `redirect: 'error'`
|
||||||
- `referrerPolicy: 'no-referrer'`
|
- `referrerPolicy: 'no-referrer'`
|
||||||
- In authenticated environments, allowlisted config URLs are fetched using simple fetch behavior.
|
- In unauthenticated environments, same-origin config URLs use a plain `fetch()` call (browser default `credentials: 'same-origin'`).
|
||||||
|
- Same-origin config URLs are fetched using simple fetch behavior (so same-origin session/cookie auth is preserved).
|
||||||
|
- In authenticated environments, allowlisted cross-origin config URLs are fetched using simple fetch behavior.
|
||||||
- Returned datasource configuration payloads are consumed as-is (no additional URL/config scrubbing).
|
- Returned datasource configuration payloads are consumed as-is (no additional URL/config scrubbing).
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user