@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,6 +1,6 @@
1
1
  {
2
2
  "name": "@controleonline/ui-common",
3
- "version": "1.2.100",
3
+ "version": "1.2.102",
4
4
  "author": "ControleOnline",
5
5
  "description": "ControleOnline Common Quasar UI Component",
6
6
  "license": "MIT",
@@ -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) && Platform.OS === 'web' && token) {
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
- export const resolveDefaultFileSource = (
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('does not path-inject environment hosts like staging (app-community#432)', () => {
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).not.toContain('staging.controleonline.com/files');
86
+ expect(source.uri).toContain('staging.controleonline.com/files');
87
87
  });
88
88
 
89
- it('does not path-inject when fallback DOMAIN is manager platform host', () => {
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).not.toMatch(/manager\.controleonline\.com\/files/);
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};