@equinor/fusion-framework-module-navigation 7.0.5 → 7.0.7

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.
@@ -128,13 +128,21 @@ export declare class NavigationProvider extends BaseModuleProvider<INavigationCo
128
128
  /**
129
129
  * Checks whether a pathname falls within the configured basename scope.
130
130
  *
131
+ * Uses a path-boundary check to avoid false positives from apps with
132
+ * overlapping name prefixes (e.g. `/apps/my-app` must not match
133
+ * `/apps/my-app-other/foo`).
134
+ *
131
135
  * @param pathname - The pathname to check
132
- * @returns `true` if the pathname starts with the basename (or no basename is set)
136
+ * @returns `true` if the pathname matches the basename exactly or starts
137
+ * with the basename followed by `/` (or no basename is set)
133
138
  */
134
139
  protected _isWithinBasenameScope(pathname: string): boolean;
135
140
  /**
136
141
  * Localizes a path by stripping the basename prefix from the pathname.
137
142
  *
143
+ * Only removes the basename when it matches on a path boundary to avoid
144
+ * incorrectly stripping partial matches.
145
+ *
138
146
  * @param location - The full path to localize
139
147
  * @returns A new {@link Path} with the basename removed from the pathname
140
148
  */
@@ -1 +1 @@
1
- export declare const version = "7.0.5";
1
+ export declare const version = "7.0.7";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@equinor/fusion-framework-module-navigation",
3
- "version": "7.0.5",
3
+ "version": "7.0.7",
4
4
  "description": "Navigation module for Fusion Framework providing routing and navigation capabilities using React Router 7",
5
5
  "sideEffects": false,
6
6
  "main": "dist/esm/index.js",
@@ -15,8 +15,8 @@
15
15
  "types": "./dist/types/lib/index.d.ts"
16
16
  },
17
17
  "./utils": {
18
- "import": "./dist/esm/utils/index.js",
19
- "types": "./dist/types/utils/index.d.ts"
18
+ "import": "./dist/esm/lib/utils/index.js",
19
+ "types": "./dist/types/lib/utils/index.d.ts"
20
20
  }
21
21
  },
22
22
  "keywords": [],
@@ -36,19 +36,19 @@
36
36
  "zod": "^4.4.3"
37
37
  },
38
38
  "devDependencies": {
39
- "jsdom": "^29.0.2",
39
+ "jsdom": "^30.0.1",
40
40
  "rxjs": "^7.8.1",
41
41
  "typescript": "^7.0.2",
42
42
  "vitest": "^4.1.0",
43
- "@equinor/fusion-framework-module": "^6.1.1",
44
43
  "@equinor/fusion-framework-module-event": "^6.0.1",
45
- "@equinor/fusion-framework-module-telemetry": "^7.0.1",
46
- "@equinor/fusion-observable": "^9.1.1"
44
+ "@equinor/fusion-framework-module": "^6.1.2",
45
+ "@equinor/fusion-observable": "^9.1.1",
46
+ "@equinor/fusion-framework-module-telemetry": "^7.0.2"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@remix-run/router": "^1.0.0",
50
50
  "rxjs": "^7.0.0",
51
- "@equinor/fusion-framework-module": "^6.1.1",
51
+ "@equinor/fusion-framework-module": "^6.1.2",
52
52
  "@equinor/fusion-observable": "^9.1.1"
53
53
  },
54
54
  "scripts": {
@@ -24,16 +24,54 @@ import type { BaseHistory } from './lib';
24
24
  /**
25
25
  * Normalizes a pathname by:
26
26
  * - Collapsing multiple consecutive slashes into a single slash
27
- * - Removing trailing slashes
28
27
  *
29
28
  * @example
30
- * normalizePathname("/app//users///profile/") // returns "/app/users/profile"
31
- * normalizePathname("///multiple///slashes///") // returns "/multiple/slashes"
29
+ * normalizePathname("/app//users///profile/") // returns "/app/users/profile/"
30
+ * normalizePathname("///multiple///slashes///") // returns "/multiple/slashes/"
32
31
  *
33
32
  * @param path - The pathname to normalize
34
- * @returns The normalized pathname without consecutive or trailing slashes
33
+ * @returns The normalized pathname without consecutive slashes
35
34
  */
36
- const normalizePathname = (path: string) => path.replace(/\/+/g, '/').replace(/\/$/, '');
35
+ const normalizePathname = (path: string): string => {
36
+ // Use iterative approach instead of regex to avoid potential ReDoS with untrusted input
37
+ let result = '';
38
+ let lastWasSlash = false;
39
+
40
+ for (let i = 0; i < path.length; i++) {
41
+ const char = path[i];
42
+ if (char === '/') {
43
+ if (!lastWasSlash) {
44
+ result += char;
45
+ lastWasSlash = true;
46
+ }
47
+ // Skip consecutive slashes
48
+ } else {
49
+ result += char;
50
+ lastWasSlash = false;
51
+ }
52
+ }
53
+
54
+ return result;
55
+ };
56
+
57
+ /**
58
+ * Removes trailing slashes from a path string.
59
+ *
60
+ * @param path - The path to trim
61
+ * @returns The path without trailing slashes
62
+ *
63
+ * @example
64
+ * stripTrailingSlashes("/apps/my-app/") // returns "/apps/my-app"
65
+ * stripTrailingSlashes("/apps/my-app///") // returns "/apps/my-app"
66
+ */
67
+ const stripTrailingSlashes = (path: string): string => {
68
+ // Use iterative approach to avoid ReDoS vulnerability
69
+ let endIndex = path.length;
70
+ while (endIndex > 0 && path[endIndex - 1] === '/') {
71
+ endIndex--;
72
+ }
73
+ return path.substring(0, endIndex);
74
+ };
37
75
 
38
76
  /**
39
77
  * Navigation provider implementation.
@@ -132,13 +170,13 @@ export class NavigationProvider
132
170
  // Extract configuration values
133
171
  const { basename, history, telemetry, eventProvider } = args.config;
134
172
 
135
- // Normalize the basename to strip trailing slashes. React Router requires
136
- // the current URL to start with the exact basename string, so a basename
137
- // of "/apps/my-app/" would fail to match the URL "/apps/my-app" and
138
- // render nothing (blank page).
139
- // Preserve slash-only basenames (e.g. "/") by falling back to the
140
- // original input when normalization collapses to an empty string.
141
- this.#basename = basename ? normalizePathname(basename) || basename : basename;
173
+ // Normalize the basename to strip trailing slashes and collapse consecutive
174
+ // slashes. React Router requires the current URL to start with the exact
175
+ // basename string, so a basename of "/apps/my-app/" would fail to match
176
+ // the URL "/apps/my-app" and render nothing (blank page).
177
+ // Treat '/' as "no basename" (empty string) since all paths start with '/'.
178
+ const normalizedBasename = basename ? stripTrailingSlashes(normalizePathname(basename)) : '';
179
+ this.#basename = normalizedBasename || undefined;
142
180
  this.#event = eventProvider;
143
181
  this.#telemetry = telemetry;
144
182
 
@@ -316,23 +354,58 @@ export class NavigationProvider
316
354
  /**
317
355
  * Checks whether a pathname falls within the configured basename scope.
318
356
  *
357
+ * Uses a path-boundary check to avoid false positives from apps with
358
+ * overlapping name prefixes (e.g. `/apps/my-app` must not match
359
+ * `/apps/my-app-other/foo`).
360
+ *
319
361
  * @param pathname - The pathname to check
320
- * @returns `true` if the pathname starts with the basename (or no basename is set)
362
+ * @returns `true` if the pathname matches the basename exactly or starts
363
+ * with the basename followed by `/` (or no basename is set)
321
364
  */
322
365
  protected _isWithinBasenameScope(pathname: string): boolean {
323
- return this.#basename ? pathname.startsWith(this.#basename) : true;
366
+ // No basename means everything is in scope
367
+ if (!this.#basename) return true;
368
+
369
+ // Normalize the pathname for comparison (collapse consecutive slashes)
370
+ const normalized = normalizePathname(pathname);
371
+
372
+ // Check exact match or path-boundary prefix
373
+ return normalized === this.#basename || normalized.startsWith(`${this.#basename}/`);
324
374
  }
325
375
 
326
376
  /**
327
377
  * Localizes a path by stripping the basename prefix from the pathname.
328
378
  *
379
+ * Only removes the basename when it matches on a path boundary to avoid
380
+ * incorrectly stripping partial matches.
381
+ *
329
382
  * @param location - The full path to localize
330
383
  * @returns A new {@link Path} with the basename removed from the pathname
331
384
  */
332
385
  protected _localizePath(location: Path): Path {
333
386
  const { pathname, search, hash } = location;
387
+
388
+ // No basename - return normalized pathname as-is
389
+ if (!this.#basename) {
390
+ return {
391
+ pathname: normalizePathname(pathname) || '/',
392
+ search,
393
+ hash,
394
+ };
395
+ }
396
+
397
+ const normalized = normalizePathname(pathname);
398
+ let localized = normalized;
399
+
400
+ // Strip basename only if it matches at path boundary
401
+ if (normalized === this.#basename) {
402
+ localized = '/';
403
+ } else if (normalized.startsWith(`${this.#basename}/`)) {
404
+ localized = normalized.slice(this.#basename.length);
405
+ }
406
+
334
407
  return {
335
- pathname: normalizePathname(pathname.replace(this.#basename ?? '', '')),
408
+ pathname: localized || '/',
336
409
  search,
337
410
  hash,
338
411
  };
@@ -58,14 +58,245 @@ describe('NavigationProvider', () => {
58
58
  expect(disposeSpy).toHaveBeenCalled();
59
59
  });
60
60
 
61
- it('should preserve root basename when set to slash', () => {
61
+ it('should normalize root basename "/" to empty string', () => {
62
62
  const providerWithRootBasename = new NavigationProvider({
63
63
  version: '1.0.0',
64
64
  config: { history, basename: '/' },
65
65
  });
66
66
 
67
- expect(providerWithRootBasename.basename).toBe('/');
67
+ // Root basename '/' should be treated as "no basename"
68
+ expect(providerWithRootBasename.basename).toBe('');
68
69
 
69
70
  providerWithRootBasename.dispose();
70
71
  });
72
+
73
+ describe('basename normalization', () => {
74
+ it('should strip trailing slashes from basename', () => {
75
+ const provider1 = new NavigationProvider({
76
+ version: '1.0.0',
77
+ config: { history, basename: '/apps/my-app/' },
78
+ });
79
+ expect(provider1.basename).toBe('/apps/my-app');
80
+ provider1.dispose();
81
+
82
+ const provider2 = new NavigationProvider({
83
+ version: '1.0.0',
84
+ config: { history, basename: '/apps/my-app///' },
85
+ });
86
+ expect(provider2.basename).toBe('/apps/my-app');
87
+ provider2.dispose();
88
+ });
89
+
90
+ it('should collapse consecutive slashes in basename', () => {
91
+ const provider = new NavigationProvider({
92
+ version: '1.0.0',
93
+ config: { history, basename: '/apps//my-app' },
94
+ });
95
+ expect(provider.basename).toBe('/apps/my-app');
96
+ provider.dispose();
97
+ });
98
+
99
+ it('should handle pathological input efficiently (ReDoS protection)', () => {
100
+ // Create a string with many consecutive slashes to test performance
101
+ // This would cause ReDoS with certain regex patterns
102
+ const manySlashes = `/apps/${'/'.repeat(10000)}my-app`;
103
+
104
+ const startTime = Date.now();
105
+ const provider = new NavigationProvider({
106
+ version: '1.0.0',
107
+ config: { history, basename: manySlashes },
108
+ });
109
+ const endTime = Date.now();
110
+
111
+ // Should complete in reasonable time (< 100ms for 10k slashes)
112
+ expect(endTime - startTime).toBeLessThan(100);
113
+ expect(provider.basename).toBe('/apps/my-app');
114
+ provider.dispose();
115
+ });
116
+
117
+ it('should handle pathological trailing slashes efficiently (ReDoS protection)', () => {
118
+ // Create a string with many trailing slashes
119
+ // The /\/+$/ regex pattern would cause ReDoS with this input
120
+ const manyTrailingSlashes = `/apps/my-app${'/'.repeat(10000)}`;
121
+
122
+ const startTime = Date.now();
123
+ const provider = new NavigationProvider({
124
+ version: '1.0.0',
125
+ config: { history, basename: manyTrailingSlashes },
126
+ });
127
+ const endTime = Date.now();
128
+
129
+ // Should complete in reasonable time (< 100ms for 10k trailing slashes)
130
+ expect(endTime - startTime).toBeLessThan(100);
131
+ expect(provider.basename).toBe('/apps/my-app');
132
+ provider.dispose();
133
+ });
134
+
135
+ it('should handle undefined basename', () => {
136
+ const provider = new NavigationProvider({
137
+ version: '1.0.0',
138
+ config: { history, basename: undefined },
139
+ });
140
+ expect(provider.basename).toBe('');
141
+ provider.dispose();
142
+ });
143
+ });
144
+
145
+ describe('_isWithinBasenameScope', () => {
146
+ it('should allow all paths when basename is "/" (no basename)', () => {
147
+ const provider = new NavigationProvider({
148
+ version: '1.0.0',
149
+ config: { history, basename: '/' },
150
+ });
151
+
152
+ // All paths should be in scope
153
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
154
+ expect((provider as any)._isWithinBasenameScope('/')).toBe(true);
155
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
156
+ expect((provider as any)._isWithinBasenameScope('/apps')).toBe(true);
157
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
158
+ expect((provider as any)._isWithinBasenameScope('/apps/my-app')).toBe(true);
159
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
160
+ expect((provider as any)._isWithinBasenameScope('/apps/my-app/users')).toBe(true);
161
+
162
+ provider.dispose();
163
+ });
164
+
165
+ it('should check path boundaries correctly with basename', () => {
166
+ const provider = new NavigationProvider({
167
+ version: '1.0.0',
168
+ config: { history, basename: '/apps/my-app' },
169
+ });
170
+
171
+ // Exact match should be in scope
172
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
173
+ expect((provider as any)._isWithinBasenameScope('/apps/my-app')).toBe(true);
174
+
175
+ // Paths starting with basename/ should be in scope
176
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
177
+ expect((provider as any)._isWithinBasenameScope('/apps/my-app/')).toBe(true);
178
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
179
+ expect((provider as any)._isWithinBasenameScope('/apps/my-app/users')).toBe(true);
180
+
181
+ // Similar but different path should NOT be in scope (path boundary check)
182
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
183
+ expect((provider as any)._isWithinBasenameScope('/apps/my-app-other')).toBe(false);
184
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
185
+ expect((provider as any)._isWithinBasenameScope('/apps/my-app-other/users')).toBe(false);
186
+
187
+ // Completely different paths should NOT be in scope
188
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
189
+ expect((provider as any)._isWithinBasenameScope('/other')).toBe(false);
190
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
191
+ expect((provider as any)._isWithinBasenameScope('/')).toBe(false);
192
+
193
+ provider.dispose();
194
+ });
195
+
196
+ it('should handle consecutive slashes in pathname', () => {
197
+ const provider = new NavigationProvider({
198
+ version: '1.0.0',
199
+ config: { history, basename: '/apps/my-app' },
200
+ });
201
+
202
+ // Pathname with consecutive slashes should be normalized
203
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
204
+ expect((provider as any)._isWithinBasenameScope('/apps//my-app')).toBe(true);
205
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
206
+ expect((provider as any)._isWithinBasenameScope('/apps/my-app//users')).toBe(true);
207
+
208
+ provider.dispose();
209
+ });
210
+ });
211
+
212
+ describe('_localizePath', () => {
213
+ it('should strip basename from paths on boundary', () => {
214
+ const provider = new NavigationProvider({
215
+ version: '1.0.0',
216
+ config: { history, basename: '/apps/my-app' },
217
+ });
218
+
219
+ // Exact match should become '/'
220
+ expect(
221
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
222
+ (provider as any)._localizePath({ pathname: '/apps/my-app', search: '', hash: '' }),
223
+ ).toEqual({ pathname: '/', search: '', hash: '' });
224
+
225
+ // Path with basename prefix should have it stripped
226
+ expect(
227
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
228
+ (provider as any)._localizePath({
229
+ pathname: '/apps/my-app/users',
230
+ search: '?q=test',
231
+ hash: '#section',
232
+ }),
233
+ ).toEqual({ pathname: '/users', search: '?q=test', hash: '#section' });
234
+
235
+ provider.dispose();
236
+ });
237
+
238
+ it('should not strip similar paths without boundary match', () => {
239
+ const provider = new NavigationProvider({
240
+ version: '1.0.0',
241
+ config: { history, basename: '/apps/my-app' },
242
+ });
243
+
244
+ // Path that starts similarly but isn't a real match should not be stripped
245
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
246
+ const result = (provider as any)._localizePath({
247
+ pathname: '/apps/my-app-other/users',
248
+ search: '',
249
+ hash: '',
250
+ });
251
+
252
+ // Should not strip anything since it doesn't match on path boundary
253
+ expect(result.pathname).toBe('/apps/my-app-other/users');
254
+
255
+ provider.dispose();
256
+ });
257
+
258
+ it('should handle root basename "/" correctly', () => {
259
+ const provider = new NavigationProvider({
260
+ version: '1.0.0',
261
+ config: { history, basename: '/' },
262
+ });
263
+
264
+ // With no basename, paths should be returned normalized
265
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
266
+ expect((provider as any)._localizePath({ pathname: '/', search: '', hash: '' })).toEqual({
267
+ pathname: '/',
268
+ search: '',
269
+ hash: '',
270
+ });
271
+
272
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
273
+ expect((provider as any)._localizePath({ pathname: '/apps', search: '', hash: '' })).toEqual({
274
+ pathname: '/apps',
275
+ search: '',
276
+ hash: '',
277
+ });
278
+
279
+ expect(
280
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
281
+ (provider as any)._localizePath({ pathname: '/apps/my-app', search: '', hash: '' }),
282
+ ).toEqual({ pathname: '/apps/my-app', search: '', hash: '' });
283
+
284
+ provider.dispose();
285
+ });
286
+
287
+ it('should normalize consecutive slashes', () => {
288
+ const provider = new NavigationProvider({
289
+ version: '1.0.0',
290
+ config: { history, basename: '/apps/my-app' },
291
+ });
292
+
293
+ // Consecutive slashes should be collapsed
294
+ expect(
295
+ // biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes
296
+ (provider as any)._localizePath({ pathname: '/apps//my-app//users', search: '', hash: '' }),
297
+ ).toEqual({ pathname: '/users', search: '', hash: '' });
298
+
299
+ provider.dispose();
300
+ });
301
+ });
71
302
  });
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by genversion.
2
- export const version = '7.0.5';
2
+ export const version = '7.0.7';