Skip to content

Commit 0d094ad

Browse files
committed
GH-247 Fix history state navigation security issue
1 parent 8d27e5c commit 0d094ad

File tree

1 file changed

+72
-26
lines changed

1 file changed

+72
-26
lines changed

packages/core/src/router.ts

+72-26
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { hrefToUrl, mergeDataIntoQueryString, urlWithoutHash } from './url'
3333

3434
const isServer = typeof window === 'undefined'
3535
const isChromeIOS = !isServer && /CriOS/.test(window.navigator.userAgent)
36-
const cloneSerializable = <T>(obj: T): T => JSON.parse(JSON.stringify(obj))
36+
const allStateIdsSessionStorageKey = 'inertia_state_ids';
3737
const nextFrame = (callback: () => void) => {
3838
requestAnimationFrame(() => {
3939
requestAnimationFrame(callback)
@@ -85,8 +85,9 @@ export class Router {
8585
}
8686

8787
protected clearRememberedStateOnReload(): void {
88-
if (this.navigationType === 'reload' && window.history.state?.rememberedState) {
89-
delete window.history.state.rememberedState
88+
if (this.navigationType === 'reload' && this.getHistoryState()?.rememberedState) {
89+
const { rememberedState, ...newHistoryState} = this.getHistoryState() ?? {};
90+
rememberedState && this.replaceState(newHistoryState);
9091
}
9192
}
9293

@@ -165,17 +166,27 @@ export class Router {
165166
}
166167

167168
protected isBackForwardVisit(): boolean {
168-
return window.history.state && this.navigationType === 'back_forward'
169+
return this.getHistoryState() && this.navigationType === 'back_forward'
169170
}
170171

171172
protected handleBackForwardVisit(page: Page): void {
172-
window.history.state.version = page.version
173-
this.setPage(window.history.state, { preserveScroll: true, preserveState: true }).then(() => {
173+
this.setPage({ ...this.getHistoryState(), version: page.version }, { preserveScroll: true, preserveState: true }).then(() => {
174174
this.restoreScrollPositions()
175175
fireNavigateEvent(page)
176176
})
177177
}
178178

179+
protected getHistoryState(stateId: string = null): Page|null {
180+
const currentId: string|undefined = stateId ?? window.history.state?._id;
181+
if (currentId) {
182+
const currentState = window.sessionStorage.getItem(currentId);
183+
if (currentState) {
184+
return JSON.parse(currentState);
185+
}
186+
}
187+
return null;
188+
}
189+
179190
protected locationVisit(url: URL, preserveScroll: LocationVisit['preserveScroll']): boolean | void {
180191
try {
181192
const locationVisit: LocationVisit = { preserveScroll }
@@ -201,8 +212,8 @@ export class Router {
201212
const locationVisit: LocationVisit = JSON.parse(window.sessionStorage.getItem('inertiaLocationVisit') || '')
202213
window.sessionStorage.removeItem('inertiaLocationVisit')
203214
page.url += window.location.hash
204-
page.rememberedState = window.history.state?.rememberedState ?? {}
205-
page.scrollRegions = window.history.state?.scrollRegions ?? []
215+
page.rememberedState = this.getHistoryState()?.rememberedState ?? {}
216+
page.scrollRegions = this.getHistoryState()?.scrollRegions ?? []
206217
this.setPage(page, { preserveScroll: locationVisit.preserveScroll, preserveState: true }).then(() => {
207218
if (locationVisit.preserveScroll) {
208219
this.restoreScrollPositions()
@@ -405,8 +416,8 @@ export class Router {
405416
}
406417
preserveScroll = this.resolvePreserveOption(preserveScroll, pageResponse) as boolean
407418
preserveState = this.resolvePreserveOption(preserveState, pageResponse)
408-
if (preserveState && window.history.state?.rememberedState && pageResponse.component === this.page.component) {
409-
pageResponse.rememberedState = window.history.state.rememberedState
419+
if (preserveState && this.getHistoryState()?.rememberedState && pageResponse.component === this.page.component) {
420+
pageResponse.rememberedState = this.getHistoryState().rememberedState
410421
}
411422
const requestUrl = url
412423
const responseUrl = hrefToUrl(pageResponse.url)
@@ -493,14 +504,45 @@ export class Router {
493504
})
494505
}
495506

507+
private _pushState(page: Page): void {
508+
const uniqueId = this.getStateId();
509+
window.sessionStorage.setItem(uniqueId, JSON.stringify(page));
510+
window.history.pushState({_id: uniqueId}, '', page.url);
511+
}
512+
513+
protected getAllStates() {
514+
return JSON.parse(window.sessionStorage.getItem(allStateIdsSessionStorageKey) ?? '[]');
515+
}
516+
517+
private getStateId() {
518+
const newId = `inertia_${crypto.randomUUID()}`;
519+
window.sessionStorage.setItem(allStateIdsSessionStorageKey, JSON.stringify([...this.getAllStates(), newId]));
520+
return newId;
521+
}
522+
523+
public clearHistory(): void {
524+
this.getAllStates().forEach((id) => {
525+
window.sessionStorage.removeItem(id);
526+
});
527+
window.sessionStorage.removeItem(allStateIdsSessionStorageKey);
528+
}
529+
496530
protected pushState(page: Page): void {
497531
this.page = page
498532
if (isChromeIOS) {
499533
// Defer history.pushState to the next event loop tick to prevent timing conflicts.
500534
// Ensure any previous history.replaceState completes before pushState is executed.
501-
setTimeout(() => window.history.pushState(cloneSerializable(page), '', page.url))
535+
setTimeout(() => this._pushState(page));
502536
} else {
503-
window.history.pushState(cloneSerializable(page), '', page.url)
537+
this._pushState(page);
538+
}
539+
}
540+
541+
private _replaceState(page: Page): void {
542+
const currentId = window.history.state?._id ?? this.getStateId();
543+
window.sessionStorage.setItem(currentId, JSON.stringify(page));
544+
if (!window.history.state?._id) {
545+
window.history.replaceState({_id: currentId}, '', page.url);
504546
}
505547
}
506548

@@ -509,25 +551,29 @@ export class Router {
509551
if (isChromeIOS) {
510552
// Defer history.replaceState to the next event loop tick to prevent timing conflicts.
511553
// Ensure any previous history.pushState completes before replaceState is executed.
512-
setTimeout(() => window.history.replaceState(cloneSerializable(page), '', page.url))
554+
setTimeout(() => this._replaceState(page))
513555
} else {
514-
window.history.replaceState(cloneSerializable(page), '', page.url)
556+
this._replaceState(page)
515557
}
516558
}
517559

518560
protected handlePopstateEvent(event: PopStateEvent): void {
519561
if (event.state !== null) {
520-
const page = event.state
521-
const visitId = this.createVisitId()
522-
Promise.resolve(this.resolveComponent(page.component)).then((component) => {
523-
if (visitId === this.visitId) {
524-
this.page = page
525-
this.swapComponent({ component, page, preserveState: false }).then(() => {
526-
this.restoreScrollPositions()
527-
fireNavigateEvent(page)
528-
})
529-
}
530-
})
562+
const page = this.getHistoryState(event.state?._id);
563+
if (page !== null) {
564+
const visitId = this.createVisitId()
565+
Promise.resolve(this.resolveComponent(page.component)).then((component) => {
566+
if (visitId === this.visitId) {
567+
this.page = page
568+
this.swapComponent({ component, page, preserveState: false }).then(() => {
569+
this.restoreScrollPositions()
570+
fireNavigateEvent(page)
571+
})
572+
}
573+
})
574+
} else {
575+
this.reload();
576+
}
531577
} else {
532578
const url = hrefToUrl(this.page.url)
533579
url.hash = window.location.hash
@@ -592,7 +638,7 @@ export class Router {
592638
return
593639
}
594640

595-
return window.history.state?.rememberedState?.[key]
641+
return this.getHistoryState()?.rememberedState?.[key]
596642
}
597643

598644
public on<TEventName extends GlobalEventNames>(

0 commit comments

Comments
 (0)