@controleonline/ui-common 1.2.100 → 1.2.102
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {readStoredSessionToken} from '../../utils/storedSessionToken';
|
|
2
|
+
import {isTrustedApiUrl, filterApiHeaders} from '../../utils/trustedApiUrl';
|
|
2
3
|
import React, {useEffect, useMemo, useState} from 'react';
|
|
3
4
|
import {Image, Platform, StyleSheet, Text, View} from 'react-native';
|
|
4
5
|
import {env as APP_ENV} from '@env';
|
|
@@ -78,7 +79,8 @@ const useDisplayUri = uri => {
|
|
|
78
79
|
const token = readStoredSessionToken();
|
|
79
80
|
|
|
80
81
|
// Authenticated backend download on web
|
|
81
|
-
if (isBackendDownloadUrl(next) &&
|
|
82
|
+
if (isBackendDownloadUrl(next) && isTrustedApiUrl(next, APP_ENV?.API_ENTRYPOINT)
|
|
83
|
+
&& Platform.OS === 'web' && token) {
|
|
82
84
|
try {
|
|
83
85
|
const headers = {
|
|
84
86
|
Accept: '*/*',
|
|
@@ -91,7 +93,7 @@ const useDisplayUri = uri => {
|
|
|
91
93
|
headers['App-Domain'] = host;
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
const response = await fetch(next, {method: 'GET', headers});
|
|
96
|
+
const response = await fetch(next, {method: 'GET', headers, redirect: 'error'});
|
|
95
97
|
if (!response.ok) {
|
|
96
98
|
if (!cancelled) {
|
|
97
99
|
setDisplayUri('');
|
|
@@ -206,7 +208,7 @@ const UserAvatar = ({
|
|
|
206
208
|
{displayUri ? (
|
|
207
209
|
<Image
|
|
208
210
|
source={imageHeaders && Platform.OS !== 'web' && displayUri === imageUrl
|
|
209
|
-
? {uri: displayUri, headers: imageHeaders} : {uri: displayUri}}
|
|
211
|
+
? {uri: displayUri, headers: filterApiHeaders(displayUri, imageHeaders, APP_ENV?.API_ENTRYPOINT)} : {uri: displayUri}}
|
|
210
212
|
style={styles.image}
|
|
211
213
|
onError={() => setSourceIndex(index => index + 1)}
|
|
212
214
|
/>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {env as APP_ENV} from '@env';
|
|
2
|
+
import {filterApiHeaders} from '../../utils/trustedApiUrl';
|
|
2
3
|
import {buildAssetUrl} from '@controleonline/../../src/styles/branding';
|
|
3
4
|
import {
|
|
4
5
|
resolveAppDomain,
|
|
@@ -227,7 +228,7 @@ export const extractFileId = file => {
|
|
|
227
228
|
return null;
|
|
228
229
|
};
|
|
229
230
|
|
|
230
|
-
|
|
231
|
+
const resolveFileSource = (
|
|
231
232
|
file,
|
|
232
233
|
{company = null, appDomain = '', headers = {}} = {},
|
|
233
234
|
) => {
|
|
@@ -329,6 +330,11 @@ export const resolveDefaultFileSource = (
|
|
|
329
330
|
return null;
|
|
330
331
|
};
|
|
331
332
|
|
|
333
|
+
export const resolveDefaultFileSource = (file, options = {}) => {
|
|
334
|
+
const source = resolveFileSource(file, options);
|
|
335
|
+
return source ? {...source, headers: filterApiHeaders(source.uri, source.headers, APP_ENV?.API_ENTRYPOINT)} : null;
|
|
336
|
+
};
|
|
337
|
+
|
|
332
338
|
export const resolveDefaultFileUrl = (file, options = {}) =>
|
|
333
339
|
resolveDefaultFileSource(file, options)?.uri || '';
|
|
334
340
|
|
|
@@ -12,6 +12,8 @@ jest.mock('react-native', () => ({
|
|
|
12
12
|
View: props => React.createElement('View', props, props.children),
|
|
13
13
|
}));
|
|
14
14
|
|
|
15
|
+
jest.mock('@env', () => ({env: {API_ENTRYPOINT: 'https://api.controleonline.com', DOMAIN: 'manager.controleonline.com'}}));
|
|
16
|
+
|
|
15
17
|
const UserAvatar = require('../../../react/components/UserAvatar').default;
|
|
16
18
|
|
|
17
19
|
const {describe, expect, it} = global;
|
|
@@ -54,3 +56,31 @@ describe('UserAvatar', () => {
|
|
|
54
56
|
expect(image.props.source.uri).toContain('d=404');
|
|
55
57
|
});
|
|
56
58
|
});
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
describe('avatar credential boundary', () => {
|
|
62
|
+
it('does not authenticate an external download-shaped image on web', async () => {
|
|
63
|
+
const {Platform} = require('react-native');
|
|
64
|
+
const previousStorage = global.localStorage;
|
|
65
|
+
const previousFetch = global.fetch;
|
|
66
|
+
Platform.OS = 'web';
|
|
67
|
+
global.localStorage = {getItem: () => JSON.stringify({token: 'session-secret'})};
|
|
68
|
+
global.fetch = jest.fn();
|
|
69
|
+
let tree;
|
|
70
|
+
try {
|
|
71
|
+
await renderer.act(async () => { tree = renderer.create(React.createElement(UserAvatar, {imageUrl: 'https://images.example.test/files/1/download', useGravatar: false})); });
|
|
72
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
73
|
+
expect(tree.root.findByType('Image').props.source.uri).toBe('https://images.example.test/files/1/download');
|
|
74
|
+
} finally {
|
|
75
|
+
if (tree) await renderer.act(async () => tree.unmount());
|
|
76
|
+
Platform.OS = 'ios'; global.localStorage = previousStorage; global.fetch = previousFetch;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
it('removes session headers from external native avatars', async () => {
|
|
80
|
+
let tree;
|
|
81
|
+
try {
|
|
82
|
+
await renderer.act(async () => { tree = renderer.create(React.createElement(UserAvatar, {imageUrl: 'https://images.example.test/avatar.png', imageHeaders: {'API-TOKEN': 'session-secret', Accept: 'image/*'}, useGravatar: false})); });
|
|
83
|
+
expect(tree.root.findByType('Image').props.source.headers).toEqual({Accept: 'image/*'});
|
|
84
|
+
} finally { if (tree) await renderer.act(async () => tree.unmount()); }
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -60,7 +60,7 @@ describe('fileUrl helpers', () => {
|
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
it('
|
|
63
|
+
it('keeps the explicit environment domain on the registered download route', () => {
|
|
64
64
|
const source = resolveDefaultFileSource(
|
|
65
65
|
{
|
|
66
66
|
id: 8444,
|
|
@@ -77,24 +77,41 @@ describe('fileUrl helpers', () => {
|
|
|
77
77
|
);
|
|
78
78
|
|
|
79
79
|
expect(source).toEqual({
|
|
80
|
-
uri: 'https://api.controleonline.com/files/8444/download',
|
|
80
|
+
uri: 'https://api.controleonline.com/staging.controleonline.com/files/8444/download',
|
|
81
81
|
headers: {
|
|
82
82
|
Authorization: 'Bearer token',
|
|
83
83
|
'app-domain': 'staging.controleonline.com',
|
|
84
84
|
},
|
|
85
85
|
});
|
|
86
|
-
expect(source.uri).
|
|
86
|
+
expect(source.uri).toContain('staging.controleonline.com/files');
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
it('
|
|
89
|
+
it('uses the configured platform domain on the registered download route', () => {
|
|
90
90
|
// DOMAIN mock is manager.controleonline.com; no company → environment-like
|
|
91
91
|
const source = resolveDefaultFileSource(
|
|
92
92
|
{id: 9, url: '/files/9/download'},
|
|
93
93
|
{headers: {Authorization: 'Bearer t'}},
|
|
94
94
|
);
|
|
95
95
|
|
|
96
|
-
expect(source.uri).toBe('https://api.controleonline.com/files/9/download');
|
|
96
|
+
expect(source.uri).toBe('https://api.controleonline.com/manager.controleonline.com/files/9/download');
|
|
97
97
|
expect(source.headers['app-domain']).toBe('manager.controleonline.com');
|
|
98
|
-
expect(source.uri).
|
|
98
|
+
expect(source.uri).toMatch(/manager\.controleonline\.com\/files/);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
describe('file authentication origin boundary', () => {
|
|
104
|
+
it('removes ERP credentials from external images and download-shaped URLs', () => {
|
|
105
|
+
for (const uri of ['https://images.example.test/image.png', 'https://images.example.test/files/1/download', '//images.example.test/image.png']) {
|
|
106
|
+
const source = resolveDefaultFileSource(uri, {headers: {'API-TOKEN': 'session-secret', Authorization: 'Bearer session-secret', 'App-Domain': 'tenant.test', Accept: 'image/*'}});
|
|
107
|
+
expect(source.headers).not.toHaveProperty('API-TOKEN');
|
|
108
|
+
expect(source.headers).not.toHaveProperty('Authorization');
|
|
109
|
+
expect(source.headers).not.toHaveProperty('App-Domain');
|
|
110
|
+
expect(source.headers.Accept).toBe('image/*');
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
it('preserves authentication on the configured API download', () => {
|
|
114
|
+
const source = resolveDefaultFileSource(1, {headers: {'API-TOKEN': 'session-secret'}});
|
|
115
|
+
expect(source.headers['API-TOKEN']).toBe('session-secret');
|
|
99
116
|
});
|
|
100
117
|
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const isTrustedApiUrl = (uri, apiEntryPoint) => {
|
|
2
|
+
try {
|
|
3
|
+
const base = new URL(String(apiEntryPoint || ''));
|
|
4
|
+
const target = new URL(String(uri || ''), `${base.href.replace(/\/$/, '')}/`);
|
|
5
|
+
const prefix = base.pathname.replace(/\/$/, '');
|
|
6
|
+
return ['http:', 'https:'].includes(base.protocol)
|
|
7
|
+
&& target.origin === base.origin && !target.username && !target.password
|
|
8
|
+
&& (!prefix || target.pathname === prefix || target.pathname.startsWith(`${prefix}/`));
|
|
9
|
+
} catch { return false; }
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// ERP credentials belong only to the configured API, never an image/CDN origin.
|
|
13
|
+
const filterApiHeaders = (uri, headers = {}, apiEntryPoint) => {
|
|
14
|
+
if (isTrustedApiUrl(uri, apiEntryPoint)) return {...headers};
|
|
15
|
+
return Object.fromEntries(Object.entries(headers).filter(([key]) =>
|
|
16
|
+
!/^(api[-_]?(token|key)|authorization|proxy-authorization|cookie|app-domain|device)$/i.test(key)));
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
module.exports = {isTrustedApiUrl, filterApiHeaders};
|