Skip to content

Commit 5b5725b

Browse files
committed
fix(tests): improve ReDoS protection in NavigationProvider tests and update string interpolation
1 parent ecf6e17 commit 5b5725b

10 files changed

Lines changed: 60 additions & 22 deletions

File tree

packages/modules/navigation/src/__tests__/NavigationProvider.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* biome-ignore lint/suspicious/noExplicitAny: Allow usage of 'any' in test files for mocking purposes */
2+
13
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
24
import { NavigationProvider } from '../NavigationProvider';
35
import { MemoryHistory } from '../lib/MemoryHistory';
@@ -99,7 +101,7 @@ describe('NavigationProvider', () => {
99101
it('should handle pathological input efficiently (ReDoS protection)', () => {
100102
// Create a string with many consecutive slashes to test performance
101103
// This would cause ReDoS with certain regex patterns
102-
const manySlashes = '/apps' + '/'.repeat(10000) + 'my-app';
104+
const manySlashes = `/apps/${'/'.repeat(10000)}my-app`;
103105

104106
const startTime = Date.now();
105107
const provider = new NavigationProvider({
@@ -117,7 +119,7 @@ describe('NavigationProvider', () => {
117119
it('should handle pathological trailing slashes efficiently (ReDoS protection)', () => {
118120
// Create a string with many trailing slashes
119121
// The /\/+$/ regex pattern would cause ReDoS with this input
120-
const manyTrailingSlashes = '/apps/my-app' + '/'.repeat(10000);
122+
const manyTrailingSlashes = `/apps/my-app${'/'.repeat(10000)}`;
121123

122124
const startTime = Date.now();
123125
const provider = new NavigationProvider({

packages/plugins/context-navigation/src/apply-navigation.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
ContextNavigationSkippedDetail,
1212
} from './types';
1313
import type { ContextNavigationEventSource } from './create-context-navigation-plugin';
14-
import { normalizePath } from './helpers';
14+
import { normalizePathFromURL } from './helpers';
1515

1616
/**
1717
* Set of normalized paths that the plugin itself has navigated to.
@@ -118,7 +118,7 @@ export async function applyNavigation(
118118
// Step 2: Compare normalized paths to avoid redundant navigations.
119119
// This prevents a replace() call when the URL is already correct, which
120120
// would otherwise create a spurious history entry in some browsers.
121-
if (normalizePath(targetURL) === normalizePath(currentURL)) {
121+
if (normalizePathFromURL(targetURL) === normalizePathFromURL(currentURL)) {
122122
event.dispatchEvent('onContextNavigationSkipped', {
123123
detail: { appKey, reason: 'url-matches' } as ContextNavigationSkippedDetail,
124124
source: eventSource,
@@ -156,7 +156,7 @@ export async function applyNavigation(
156156

157157
// Step 4: Record the token BEFORE navigating so the guard sees it
158158
// on the resulting state$ emission and skips re-processing.
159-
ownNavTokens.add(normalizePath(targetURL));
159+
ownNavTokens.add(normalizePathFromURL(targetURL));
160160
navigation.navigate(targetURL, navOptionsOverride ?? config.navigationOptions);
161161

162162
// Post-navigation bookkeeping: notify listeners and invoke callback.

packages/plugins/context-navigation/src/guard-handlers/consume-own-nav-token.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { normalizePath } from '../helpers';
1+
import { normalizePathFromURL } from '../helpers';
22
import type { OwnNavigationTokens } from '../apply-navigation';
33

44
/**
@@ -20,7 +20,7 @@ import type { OwnNavigationTokens } from '../apply-navigation';
2020
* @returns `true` if this navigation was plugin-initiated (caller should bail).
2121
*/
2222
export function consumeOwnNavToken(currentURL: URL, ownNavTokens: OwnNavigationTokens): boolean {
23-
const normalized = normalizePath(currentURL);
23+
const normalized = normalizePathFromURL(currentURL);
2424

2525
// Token present — this navigation was plugin-initiated; consume it and signal the caller to bail.
2626
if (ownNavTokens.has(normalized)) {

packages/plugins/context-navigation/src/helpers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { normalizePathFromURL } from './normalize-path-from-url';
12
export { normalizePath } from './normalize-path';
23
export { getCurrentURL } from './get-current-url';
34
export { resolveAdapter } from './resolve-adapter';
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { normalizePath } from './normalize-path';
2+
/**
3+
* Normalize a URL to its path + search representation for comparison.
4+
*
5+
* Strips trailing slashes from the pathname so that `/apps/foo/` and
6+
* `/apps/foo` compare as equal.
7+
*
8+
* @param url - The URL to normalize.
9+
* @returns A string of the form `pathname + search` with trailing slash removed.
10+
*/
11+
export function normalizePathFromURL(url: URL): string {
12+
return `${normalizePath(url.pathname)}${url.search}`;
13+
}
Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,33 @@
11
/**
2-
* Normalize a URL to its path + search representation for comparison.
2+
* Normalizes a path string to ensure it is in a consistent format for comparison and processing.
33
*
4-
* Strips trailing slashes from the pathname so that `/apps/foo/` and
5-
* `/apps/foo` compare as equal.
4+
* This function performs the following normalizations:
5+
* - Ensures the path starts with a leading slash.
6+
* - Removes any trailing slashes, except for the root path ("/").
7+
* - Converts empty paths to the root path ("/").
8+
* - Fixing multiple consecutive slashes to a single slash.
69
*
7-
* @param url - The URL to normalize.
8-
* @returns A string of the form `pathname + search` with trailing slash removed.
10+
* @param path - The path string to normalize.
11+
* @returns The normalized path string.
912
*/
10-
export function normalizePath(url: URL): string {
11-
return `${url.pathname.replace(/\/$/, '') || '/'}${url.search}`;
13+
export function normalizePath(path: string): string {
14+
// Replace multiple consecutive slashes with a single slash
15+
path = path.replace(/\/+/g, '/');
16+
17+
// Ensure the path starts with a leading slash
18+
if (!path.startsWith('/')) {
19+
path = `/${path}`;
20+
}
21+
22+
// Remove trailing slashes, except for the root path
23+
if (path.length > 1 && path.endsWith('/')) {
24+
path = path.slice(0, -1);
25+
}
26+
27+
// Convert empty paths to the root path
28+
if (path === '') {
29+
return '/';
30+
}
31+
32+
return path;
1233
}

packages/plugins/context-navigation/src/reconcile.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
} from './types';
99
import type { ContextNavigationEventSource } from './create-context-navigation-plugin';
1010
import type { ReconcilerSourceEntry } from './sources/types';
11-
import { getCurrentURL, normalizePath, resolveAdapter, stripQueryParams } from './helpers';
11+
import { getCurrentURL, normalizePathFromURL, resolveAdapter, stripQueryParams } from './helpers';
1212
import { applyNavigation, type ApplyNavigationDeps } from './apply-navigation';
1313

1414
/** Dependencies required by {@link reconcile}. */
@@ -74,8 +74,8 @@ export function reconcile(
7474
const targetURL = new URL(targetPath, config.origin);
7575

7676
// Only navigate if the target path differs from current path
77-
if (normalizePath(targetURL) !== normalizePath(currentURL)) {
78-
ownNavTokens.add(normalizePath(targetURL));
77+
if (normalizePathFromURL(targetURL) !== normalizePathFromURL(currentURL)) {
78+
ownNavTokens.add(normalizePathFromURL(targetURL));
7979
navigation.navigate(targetURL, config.navigationOptions);
8080
log(`Null context → navigated to [${targetPath}] for [${appKey}]`);
8181
}

packages/plugins/context-navigation/src/utils/url/build-app-route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* @returns The constructed app route path string.
99
*/
1010
export const buildAppRoute = (appKey: string, contextId?: string, rest?: string): string => {
11-
let path = [`/apps/${appKey}`];
11+
const path = [`/apps/${appKey}`];
1212

1313
// Context segment is optional — omitted when no context is actively selected
1414
if (contextId) path.push(`${contextId}`);

packages/plugins/context-navigation/src/utils/url/build-context-url-for-strategy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export const buildContextUrlForStrategy = (
4343
// Path is not a recognised app route — return it unchanged
4444
if (!match) {
4545
return path;
46-
}
47-
46+
}
47+
4848
return buildAppRoute(match.appKey, contextId ?? undefined);
4949
};

packages/plugins/context-navigation/src/utils/url/parse-app-route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { normalizePath } from '../../helpers';
12
/**
23
* URLPattern for the standard Fusion portal app route:
34
*
@@ -37,9 +38,9 @@ export const parseAppRoute = (pathname: string): AppRouteMatch | undefined => {
3738
pathname = `/${pathname}`;
3839
}
3940

40-
const normalized = pathname.replace(/\/+$/, '') || '/';
41+
const normalized = normalizePath(pathname);
4142
const result = APP_ROUTE_PATTERN.exec({ pathname: normalized });
42-
43+
4344
// Pathname does not match the /apps/:appKey pattern
4445
if (!result) {
4546
return undefined;

0 commit comments

Comments
 (0)