Skip to content

Commit 62d9a9c

Browse files
authored
Merge pull request #2 from StudioLambda/fix/medium-severity-audit-findings
Fix medium-severity audit findings across router, hooks, and matcher
2 parents 13b8115 + 56cf2e7 commit 62d9a9c

13 files changed

Lines changed: 566 additions & 32 deletions

src/react/components/Router.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { useNavigationEvents } from 'router/react:hooks/useNavigationEvents'
2222
import { MatcherContext } from 'router/react:context/MatcherContext'
2323
import { TransitionContext } from 'router/react:context/TransitionContext'
2424
import { PathnameContext } from 'router/react:context/PathnameContext'
25+
import { UrlContext } from 'router/react:context/UrlContext'
2526
import { extractPathname } from 'router/react:extractPathname'
2627

2728
/**
@@ -57,6 +58,14 @@ interface CurrentState {
5758
* active links and read the current location.
5859
*/
5960
pathname: string
61+
62+
/**
63+
* The full destination URL string for this navigation.
64+
* Used by `useSearchParams` to derive search parameters
65+
* from React state rather than reading the mutable
66+
* `navigation.currentEntry` during render.
67+
*/
68+
url: string | null
6069
}
6170

6271
/**
@@ -171,6 +180,7 @@ export function Router(options: RouterProps) {
171180
signal: null,
172181
navigationType: null,
173182
pathname: extractPathname(url),
183+
url,
174184
}
175185
})
176186

@@ -223,6 +233,7 @@ export function Router(options: RouterProps) {
223233
signal: event.signal,
224234
navigationType: event.navigationType,
225235
pathname: extractPathname(event.destination.url),
236+
url: event.destination.url,
226237
})
227238
})
228239

@@ -250,13 +261,15 @@ export function Router(options: RouterProps) {
250261
<NavigationTypeContext value={current.navigationType}>
251262
<NavigationSignalContext value={current.signal}>
252263
<PathnameContext value={current.pathname}>
253-
<ParamsContext value={current.match.params}>
254-
<Suspense fallback={options.fallback}>
255-
<Middlewares value={middlewares}>
256-
<CurrentComponent />
257-
</Middlewares>
258-
</Suspense>
259-
</ParamsContext>
264+
<UrlContext value={current.url}>
265+
<ParamsContext value={current.match.params}>
266+
<Suspense fallback={options.fallback}>
267+
<Middlewares value={middlewares}>
268+
<CurrentComponent />
269+
</Middlewares>
270+
</Suspense>
271+
</ParamsContext>
272+
</UrlContext>
260273
</PathnameContext>
261274
</NavigationSignalContext>
262275
</NavigationTypeContext>

src/react/context/NavigationContext.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ import { createContext } from 'react'
1010
* The `useNavigation` hook throws a descriptive error when consumed
1111
* without a provider.
1212
*/
13-
export const NavigationContext = createContext<Navigation>(null as unknown as Navigation)
13+
export const NavigationContext = createContext<Navigation | null>(null)

src/react/context/UrlContext.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { createContext } from 'react'
2+
3+
/**
4+
* Provides the full committed URL string to descendant
5+
* components. Updated by the Router on every navigation
6+
* with the destination URL. Defaults to `null` when no
7+
* Router is present in the tree.
8+
*
9+
* Consumed by `useSearchParams` to derive search parameters
10+
* from React state rather than reading the mutable
11+
* `navigation.currentEntry` during render — preventing
12+
* subscription tearing in concurrent mode.
13+
*/
14+
export const UrlContext = createContext<string | null>(null)

src/react/createRouter.test.ts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,4 +838,205 @@ describe('createRouter', { concurrent: true }, function () {
838838
expect(router.match('/dashboard/settings')?.handler.component).toBe(DashSettings)
839839
})
840840
})
841+
842+
describe('redirect cycle detection', { concurrent: true }, function () {
843+
it('throws on a self-redirect', function ({ expect }) {
844+
expect(function () {
845+
createRouter(function (route) {
846+
route('/loop').redirect('/loop')
847+
})
848+
}).toThrow('redirect cycle detected')
849+
})
850+
851+
it('throws on a two-hop redirect cycle', function ({ expect }) {
852+
expect(function () {
853+
createRouter(function (route) {
854+
route('/a').redirect('/b')
855+
route('/b').redirect('/a')
856+
})
857+
}).toThrow('redirect cycle detected')
858+
})
859+
860+
it('throws on a multi-hop redirect cycle', function ({ expect }) {
861+
expect(function () {
862+
createRouter(function (route) {
863+
route('/a').redirect('/b')
864+
route('/b').redirect('/c')
865+
route('/c').redirect('/a')
866+
})
867+
}).toThrow('redirect cycle detected')
868+
})
869+
870+
it('allows non-cyclic redirect chains', function ({ expect }) {
871+
expect(function () {
872+
createRouter(function (route) {
873+
route('/a').redirect('/b')
874+
route('/b').redirect('/c')
875+
route('/c').render(Stub)
876+
})
877+
}).not.toThrow()
878+
})
879+
880+
it('skips cycle detection for callback redirects', function ({ expect }) {
881+
expect(function () {
882+
createRouter(function (route) {
883+
route('/a').redirect(function () {
884+
return '/a'
885+
})
886+
})
887+
}).not.toThrow()
888+
})
889+
})
890+
891+
describe('builder consumed guard', { concurrent: true }, function () {
892+
it('throws when calling middleware after render', function ({ expect }) {
893+
expect(function () {
894+
createRouter(function (route) {
895+
const builder = route('/page')
896+
897+
builder.render(Stub)
898+
builder.middleware([createMiddleware()])
899+
})
900+
}).toThrow('cannot call .middleware() on a route builder')
901+
})
902+
903+
it('throws when calling prefetch after render', function ({ expect }) {
904+
expect(function () {
905+
createRouter(function (route) {
906+
const builder = route('/page')
907+
908+
builder.render(Stub)
909+
builder.prefetch(vi.fn())
910+
})
911+
}).toThrow('cannot call .prefetch() on a route builder')
912+
})
913+
914+
it('throws when calling scroll after render', function ({ expect }) {
915+
expect(function () {
916+
createRouter(function (route) {
917+
const builder = route('/page')
918+
919+
builder.render(Stub)
920+
builder.scroll('manual')
921+
})
922+
}).toThrow('cannot call .scroll() on a route builder')
923+
})
924+
925+
it('throws when calling focusReset after render', function ({ expect }) {
926+
expect(function () {
927+
createRouter(function (route) {
928+
const builder = route('/page')
929+
930+
builder.render(Stub)
931+
builder.focusReset('manual')
932+
})
933+
}).toThrow('cannot call .focusReset() on a route builder')
934+
})
935+
936+
it('throws when calling formHandler after render', function ({ expect }) {
937+
expect(function () {
938+
createRouter(function (route) {
939+
const builder = route('/page')
940+
941+
builder.render(Stub)
942+
builder.formHandler(vi.fn())
943+
})
944+
}).toThrow('cannot call .formHandler() on a route builder')
945+
})
946+
947+
it('throws when calling render twice', function ({ expect }) {
948+
expect(function () {
949+
createRouter(function (route) {
950+
const builder = route('/page')
951+
952+
builder.render(Stub)
953+
builder.render(createStub())
954+
})
955+
}).toThrow('cannot call .render() on a route builder')
956+
})
957+
958+
it('throws when calling redirect after render', function ({ expect }) {
959+
expect(function () {
960+
createRouter(function (route) {
961+
const builder = route('/page')
962+
963+
builder.render(Stub)
964+
builder.redirect('/other')
965+
})
966+
}).toThrow('cannot call .redirect() on a route builder')
967+
})
968+
969+
it('throws when calling group after render', function ({ expect }) {
970+
expect(function () {
971+
createRouter(function (route) {
972+
const builder = route('/page')
973+
974+
builder.render(Stub)
975+
builder.group()
976+
})
977+
}).toThrow('cannot call .group() on a route builder')
978+
})
979+
980+
it('throws when calling render after redirect', function ({ expect }) {
981+
expect(function () {
982+
createRouter(function (route) {
983+
const builder = route('/page')
984+
985+
builder.redirect('/other')
986+
builder.render(Stub)
987+
})
988+
}).toThrow('cannot call .render() on a route builder')
989+
})
990+
991+
it('throws when calling render after group', function ({ expect }) {
992+
expect(function () {
993+
createRouter(function (route) {
994+
const builder = route('/page')
995+
996+
builder.group()
997+
builder.render(Stub)
998+
})
999+
}).toThrow('cannot call .render() on a route builder')
1000+
})
1001+
})
1002+
1003+
describe('duplicate route registration', { concurrent: true }, function () {
1004+
it('throws when registering the same path twice with render', function ({ expect }) {
1005+
expect(function () {
1006+
createRouter(function (route) {
1007+
route('/page').render(Stub)
1008+
route('/page').render(createStub())
1009+
})
1010+
}).toThrow('duplicate route registration')
1011+
})
1012+
1013+
it('throws when registering the same path with render and redirect', function ({ expect }) {
1014+
expect(function () {
1015+
createRouter(function (route) {
1016+
route('/page').render(Stub)
1017+
route('/page').redirect('/other')
1018+
})
1019+
}).toThrow('duplicate route registration')
1020+
})
1021+
1022+
it('throws when registering the same nested path twice', function ({ expect }) {
1023+
expect(function () {
1024+
createRouter(function (route) {
1025+
const app = route('/app').group()
1026+
1027+
app('/page').render(Stub)
1028+
app('/page').render(createStub())
1029+
})
1030+
}).toThrow('duplicate route registration')
1031+
})
1032+
1033+
it('allows registering different paths', function ({ expect }) {
1034+
expect(function () {
1035+
createRouter(function (route) {
1036+
route('/a').render(Stub)
1037+
route('/b').render(createStub())
1038+
})
1039+
}).not.toThrow()
1040+
})
1041+
})
8411042
})

0 commit comments

Comments
 (0)