@celestia-island/hikari 0.41.5 → 0.41.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.
- package/package.json +1 -1
- package/src/runtime/createAuthGuard.permissions.test.ts +141 -0
- package/src/runtime/createAuthGuard.test.ts +128 -0
- package/src/runtime/createAuthGuard.ts +171 -0
- package/src/runtime/historySafetyNet.test.ts +139 -0
- package/src/runtime/historySafetyNet.ts +145 -0
- package/src/runtime/index.ts +14 -0
- package/src/runtime/navigationGuard.test.ts +107 -0
- package/src/runtime/navigationGuard.ts +61 -0
package/package.json
CHANGED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { createAuthGuard } from "./createAuthGuard";
|
|
4
|
+
|
|
5
|
+
/** Shape the guard's router parameter accepts (a slim slice of
|
|
6
|
+
* vue-router's Router — the guard only calls beforeEach/onError). */
|
|
7
|
+
interface SlimRouter {
|
|
8
|
+
beforeEach: (fn: (to: GuardTo) => unknown) => void;
|
|
9
|
+
onError: (fn: (error: unknown, to: GuardTo) => void) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface GuardTo {
|
|
13
|
+
name?: string | symbol | null;
|
|
14
|
+
fullPath?: string;
|
|
15
|
+
meta?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Minimal router double: records guards, lets tests drive them. */
|
|
19
|
+
function makeRouter() {
|
|
20
|
+
const guards: Array<(to: GuardTo) => unknown> = [];
|
|
21
|
+
const router: SlimRouter = {
|
|
22
|
+
beforeEach: (fn) => { guards.push(fn); },
|
|
23
|
+
onError: () => {},
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
router,
|
|
27
|
+
run: (to: GuardTo) => {
|
|
28
|
+
expect(guards.length).toBeGreaterThan(0);
|
|
29
|
+
return guards[guards.length - 1](to);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeAuthStore(overrides: Partial<Record<string, unknown>> = {}) {
|
|
35
|
+
return {
|
|
36
|
+
isAuthenticated: false,
|
|
37
|
+
user: null,
|
|
38
|
+
tryRestoreSession: vi.fn().mockResolvedValue(undefined),
|
|
39
|
+
checkSetup: vi.fn().mockResolvedValue({ needs_setup: false }),
|
|
40
|
+
fetchUser: vi.fn().mockResolvedValue(undefined),
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Permissions-store double mirroring the real store's semantics:
|
|
46
|
+
* `fetch()` resolves the set and flips loaded=true; a failing fetch
|
|
47
|
+
* leaves loaded=false (fail-open). */
|
|
48
|
+
function makePermStore(init: { loaded?: boolean; perms?: string[]; failFetch?: boolean } = {}) {
|
|
49
|
+
const state = { loaded: init.loaded ?? false, perms: init.perms ?? [] };
|
|
50
|
+
return {
|
|
51
|
+
get loaded() { return state.loaded; },
|
|
52
|
+
fetch: vi.fn().mockImplementation(async () => {
|
|
53
|
+
if (init.failFetch) return;
|
|
54
|
+
state.perms = init.perms ?? [];
|
|
55
|
+
state.loaded = true;
|
|
56
|
+
}),
|
|
57
|
+
has: (perm: string) => state.perms.includes(perm),
|
|
58
|
+
hasAny: (perms: string[]) => perms.some((p) => state.perms.includes(p)),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const baseOpts = {
|
|
63
|
+
loginRoute: "login",
|
|
64
|
+
homeRoute: "demiurge",
|
|
65
|
+
setupRoute: "setup-redirect",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
describe("createAuthGuard requiresPermission enforcement", () => {
|
|
69
|
+
// Contract: routes declare `requiresPermission` (e.g. /backend with
|
|
70
|
+
// "system.read") and the guard is the enforcement point. The meta used
|
|
71
|
+
// to be declared but never consumed — any authenticated user could
|
|
72
|
+
// open the admin console route and only AdminLayout's own nav filtering
|
|
73
|
+
// hid the views (field report 2026-09).
|
|
74
|
+
it("bounces a user lacking the required permission to the home route", async () => {
|
|
75
|
+
const auth = makeAuthStore({ isAuthenticated: true, user: { username: "momoi" } });
|
|
76
|
+
const perms = makePermStore({ perms: [] });
|
|
77
|
+
const { router, run } = makeRouter();
|
|
78
|
+
createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth, permissions: () => perms });
|
|
79
|
+
|
|
80
|
+
const verdict = await run({
|
|
81
|
+
name: "admin",
|
|
82
|
+
fullPath: "/backend",
|
|
83
|
+
meta: { requiresAuth: true, requiresPermission: "system.read" },
|
|
84
|
+
});
|
|
85
|
+
expect(verdict).toEqual({ name: "demiurge" });
|
|
86
|
+
expect(perms.fetch).toHaveBeenCalledOnce();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("admits a user holding the required permission", async () => {
|
|
90
|
+
const auth = makeAuthStore({ isAuthenticated: true, user: { username: "demiurge" } });
|
|
91
|
+
const perms = makePermStore({ loaded: true, perms: ["system.read"] });
|
|
92
|
+
const { router, run } = makeRouter();
|
|
93
|
+
createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth, permissions: () => perms });
|
|
94
|
+
|
|
95
|
+
const verdict = await run({
|
|
96
|
+
name: "admin",
|
|
97
|
+
fullPath: "/backend",
|
|
98
|
+
meta: { requiresAuth: true, requiresPermission: "system.read" },
|
|
99
|
+
});
|
|
100
|
+
expect(verdict).toBeUndefined();
|
|
101
|
+
// Already-loaded set must not be refetched on every navigation.
|
|
102
|
+
expect(perms.fetch).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("accepts any-of lists via hasAny", async () => {
|
|
106
|
+
const auth = makeAuthStore({ isAuthenticated: true, user: { username: "u" } });
|
|
107
|
+
const perms = makePermStore({ loaded: true, perms: ["industrial.write"] });
|
|
108
|
+
const { router, run } = makeRouter();
|
|
109
|
+
createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth, permissions: () => perms });
|
|
110
|
+
|
|
111
|
+
const verdict = await run({
|
|
112
|
+
name: "writer",
|
|
113
|
+
fullPath: "/somewhere",
|
|
114
|
+
meta: { requiresPermission: ["system.read", "industrial.write"] },
|
|
115
|
+
});
|
|
116
|
+
expect(verdict).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("fails open while the permission set is unloaded (store outage must not brick navigation)", async () => {
|
|
120
|
+
const auth = makeAuthStore({ isAuthenticated: true, user: { username: "u" } });
|
|
121
|
+
const perms = makePermStore({ failFetch: true });
|
|
122
|
+
const { router, run } = makeRouter();
|
|
123
|
+
createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth, permissions: () => perms });
|
|
124
|
+
|
|
125
|
+
const verdict = await run({
|
|
126
|
+
name: "admin",
|
|
127
|
+
fullPath: "/backend",
|
|
128
|
+
meta: { requiresAuth: true, requiresPermission: "system.read" },
|
|
129
|
+
});
|
|
130
|
+
expect(verdict).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("ignores routes without the meta and unwired permission stores", async () => {
|
|
134
|
+
const auth = makeAuthStore({ isAuthenticated: true, user: { username: "u" } });
|
|
135
|
+
const { router, run } = makeRouter();
|
|
136
|
+
createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth });
|
|
137
|
+
|
|
138
|
+
const verdict = await run({ name: "demiurge", fullPath: "/@ws", meta: { requiresAuth: true } });
|
|
139
|
+
expect(verdict).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { createAuthGuard } from "./createAuthGuard";
|
|
4
|
+
|
|
5
|
+
/** Shape the guard's router parameter accepts (a slim slice of
|
|
6
|
+
* vue-router's Router — the guard only calls beforeEach/onError). */
|
|
7
|
+
interface SlimRouter {
|
|
8
|
+
beforeEach: (fn: (to: GuardTo) => unknown) => void;
|
|
9
|
+
onError: (fn: (error: unknown, to: GuardTo) => void) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface GuardTo {
|
|
13
|
+
name?: string | symbol | null;
|
|
14
|
+
fullPath?: string;
|
|
15
|
+
meta?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Minimal router double: records guards, lets tests drive them. */
|
|
19
|
+
function makeRouter() {
|
|
20
|
+
const guards: Array<(to: GuardTo) => unknown> = [];
|
|
21
|
+
const router: SlimRouter = {
|
|
22
|
+
beforeEach: (fn) => { guards.push(fn); },
|
|
23
|
+
onError: () => {},
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
router,
|
|
27
|
+
run: (to: GuardTo) => {
|
|
28
|
+
expect(guards.length).toBeGreaterThan(0);
|
|
29
|
+
return guards[guards.length - 1](to);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeAuthStore(overrides: Partial<Record<string, unknown>> = {}) {
|
|
35
|
+
return {
|
|
36
|
+
isAuthenticated: false,
|
|
37
|
+
user: null,
|
|
38
|
+
tryRestoreSession: vi.fn().mockResolvedValue(undefined),
|
|
39
|
+
checkSetup: vi.fn().mockResolvedValue({ needs_setup: false }),
|
|
40
|
+
fetchUser: vi.fn().mockResolvedValue(undefined),
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const baseOpts = {
|
|
46
|
+
loginRoute: "login",
|
|
47
|
+
homeRoute: "demiurge",
|
|
48
|
+
setupRoute: "setup-redirect",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The lagged-login contract: an identity fetch that fails verification
|
|
52
|
+
* must redirect to the login route (preserving the redirect target),
|
|
53
|
+
* never abort the navigation with an unhandled rejection — the old
|
|
54
|
+
* behavior fed the rejection into router.onError where the lazy-load
|
|
55
|
+
* retry handler misclassified it as a chunk failure. */
|
|
56
|
+
describe("createAuthGuard identity hydration", () => {
|
|
57
|
+
it("redirects to login when the identity fetch fails verification", async () => {
|
|
58
|
+
const auth = makeAuthStore({ isAuthenticated: true });
|
|
59
|
+
// A failed verification logs out inside the real fetchUser — the
|
|
60
|
+
// guard reads the post-fetch flag, so flip it in the mock too.
|
|
61
|
+
auth.fetchUser = vi.fn().mockImplementation(async () => {
|
|
62
|
+
auth.isAuthenticated = false;
|
|
63
|
+
throw new Error("no usable identity");
|
|
64
|
+
});
|
|
65
|
+
const { router, run } = makeRouter();
|
|
66
|
+
createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth });
|
|
67
|
+
|
|
68
|
+
const verdict = await run({ name: "demiurge", fullPath: "/@ws123", meta: { requiresAuth: true } });
|
|
69
|
+
expect(verdict).toEqual({ name: "login", query: { redirect: "/@ws123" } });
|
|
70
|
+
expect(auth.fetchUser).toHaveBeenCalledOnce();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("keeps the route when the fetch fails but the session stays authenticated", async () => {
|
|
74
|
+
// Transient transport failure: the shell's own safety net retries —
|
|
75
|
+
// the guard must not bounce the user on a blip.
|
|
76
|
+
const auth = makeAuthStore({
|
|
77
|
+
isAuthenticated: true,
|
|
78
|
+
fetchUser: vi.fn().mockRejectedValue(new Error("timed out")),
|
|
79
|
+
});
|
|
80
|
+
const { router, run } = makeRouter();
|
|
81
|
+
createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth });
|
|
82
|
+
|
|
83
|
+
const verdict = await run({ name: "demiurge", fullPath: "/@ws123", meta: { requiresAuth: true } });
|
|
84
|
+
expect(verdict).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("lets a hydrated identity through without refetching", async () => {
|
|
88
|
+
const auth = makeAuthStore({
|
|
89
|
+
isAuthenticated: true,
|
|
90
|
+
user: { username: "demiurge" },
|
|
91
|
+
fetchUser: vi.fn(),
|
|
92
|
+
});
|
|
93
|
+
const { router, run } = makeRouter();
|
|
94
|
+
createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth });
|
|
95
|
+
|
|
96
|
+
const verdict = await run({ name: "demiurge", fullPath: "/@ws123", meta: { requiresAuth: true } });
|
|
97
|
+
expect(verdict).toBeUndefined();
|
|
98
|
+
expect(auth.fetchUser).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("retries session restore after a failed attempt instead of memoizing it", async () => {
|
|
102
|
+
// Regression (user report 2026-09-07): a failed restore used to stay
|
|
103
|
+
// memoized for the SPA lifetime, so once the guard's first
|
|
104
|
+
// tryRestoreSession raced a network blip, the only way back in was a
|
|
105
|
+
// manual reload. The memo must reset on failure so the next
|
|
106
|
+
// navigation retries — with valid cookies the retry recovers.
|
|
107
|
+
const auth = makeAuthStore();
|
|
108
|
+
auth.tryRestoreSession = vi
|
|
109
|
+
.fn<() => Promise<void>>()
|
|
110
|
+
.mockImplementationOnce(async () => {
|
|
111
|
+
throw new Error("restore raced a blip");
|
|
112
|
+
})
|
|
113
|
+
.mockImplementationOnce(async () => {
|
|
114
|
+
auth.isAuthenticated = true;
|
|
115
|
+
});
|
|
116
|
+
const { router, run } = makeRouter();
|
|
117
|
+
createAuthGuard(router, { ...baseOpts, useAuthStore: () => auth });
|
|
118
|
+
|
|
119
|
+
// First navigation: restore fails → bounced to the login route.
|
|
120
|
+
const first = await run({ name: "demiurge", fullPath: "/@ws123", meta: { requiresAuth: true } });
|
|
121
|
+
expect(first).toEqual({ name: "login", query: { redirect: "/@ws123" } });
|
|
122
|
+
|
|
123
|
+
// Second navigation: the restore retries and succeeds → route passes.
|
|
124
|
+
const second = await run({ name: "demiurge", fullPath: "/@ws123", meta: { requiresAuth: true } });
|
|
125
|
+
expect(auth.tryRestoreSession).toHaveBeenCalledTimes(2);
|
|
126
|
+
expect(second).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared auth route guard (family runtime).
|
|
3
|
+
*
|
|
4
|
+
* Upstreamed from shittim-chest (its copy was the most evolved form:
|
|
5
|
+
* setupWizardRoute split, permissions-store factory, session-restore
|
|
6
|
+
* retry, fetchUser failure semantics) to replace the per-repo forks —
|
|
7
|
+
* easy-hydro-erp carried an older vendored copy under
|
|
8
|
+
* `src/vendor/plana-ui/`. Structurally typed: no vue-router or pinia
|
|
9
|
+
* dependency, so hikari stays UI-library-scoped and any router-shaped
|
|
10
|
+
* object works.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface AuthGuardOptions {
|
|
14
|
+
loginRoute: string;
|
|
15
|
+
homeRoute: string;
|
|
16
|
+
setupRoute: string;
|
|
17
|
+
/**
|
|
18
|
+
* Optional distinct wizard route that actually performs initial setup.
|
|
19
|
+
* Some apps split the flow into a redirect/landing page (`setupRoute`,
|
|
20
|
+
* e.g. locale detection) and the wizard itself (`setupWizardRoute`). When
|
|
21
|
+
* `needs_setup` is true the guard must allow BOTH, otherwise the landing
|
|
22
|
+
* page can never navigate into the wizard (infinite redirect loop).
|
|
23
|
+
*/
|
|
24
|
+
setupWizardRoute?: string;
|
|
25
|
+
registerRoute?: string;
|
|
26
|
+
useAuthStore: () => {
|
|
27
|
+
isAuthenticated: boolean;
|
|
28
|
+
user: unknown;
|
|
29
|
+
tryRestoreSession: () => Promise<void>;
|
|
30
|
+
checkSetup: () => Promise<{ needs_setup: boolean; registration_enabled?: boolean }>;
|
|
31
|
+
// The guard only awaits the call — the store's `User` result is unused.
|
|
32
|
+
fetchUser: () => Promise<unknown>;
|
|
33
|
+
};
|
|
34
|
+
onLazyLoadError?: (error: Error, target: string) => void;
|
|
35
|
+
/**
|
|
36
|
+
* Permissions store factory; optional — skipped when omitted. A factory
|
|
37
|
+
* (not an instance) because pinia is installed after this module
|
|
38
|
+
* evaluates: the router is created at module scope, so an eager
|
|
39
|
+
* `usePermissionsStore()` here would run without an active pinia.
|
|
40
|
+
*/
|
|
41
|
+
permissions?: () => {
|
|
42
|
+
loaded: boolean;
|
|
43
|
+
fetch: () => Promise<void>;
|
|
44
|
+
has: (perm: string) => boolean;
|
|
45
|
+
hasAny: (perms: string[]) => boolean;
|
|
46
|
+
} | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface GuardRoute {
|
|
50
|
+
name?: string | symbol | null;
|
|
51
|
+
fullPath?: string;
|
|
52
|
+
meta?: Record<string, unknown>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface GuardRouter {
|
|
56
|
+
// Accepts any guard signature (vue-router's NavigationGuard takes extra
|
|
57
|
+
// args; slimmer routers take fewer) — the callback just inspects `to`.
|
|
58
|
+
// The guard callback receives the route; vue-router also passes from/next
|
|
59
|
+
// which we ignore. `never`-parameter variance keeps both sides assignable.
|
|
60
|
+
beforeEach: (fn: (to: GuardRoute, ...rest: unknown[]) => unknown) => void;
|
|
61
|
+
onError?: (fn: (error: unknown, to: GuardRoute) => void) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createAuthGuard(router: GuardRouter, opts: AuthGuardOptions) {
|
|
65
|
+
let sessionRestorePromise: Promise<void> | null = null;
|
|
66
|
+
let setupChecked = false;
|
|
67
|
+
|
|
68
|
+
router.beforeEach(async (to: GuardRoute) => {
|
|
69
|
+
const auth = opts.useAuthStore();
|
|
70
|
+
const permStore = opts.permissions?.();
|
|
71
|
+
|
|
72
|
+
if (!auth.isAuthenticated) {
|
|
73
|
+
if (!sessionRestorePromise) {
|
|
74
|
+
sessionRestorePromise = auth.tryRestoreSession().catch(() => {
|
|
75
|
+
// A failed restore must not stay memoized for the SPA
|
|
76
|
+
// lifetime: the cookies may be perfectly valid and the first
|
|
77
|
+
// attempt may just have raced a network blip. Nulling here
|
|
78
|
+
// lets the next navigation (e.g. the login page's silent
|
|
79
|
+
// restore, or simply clicking another route) retry.
|
|
80
|
+
sessionRestorePromise = null;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
await sessionRestorePromise;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!setupChecked && !auth.isAuthenticated) {
|
|
87
|
+
setupChecked = true;
|
|
88
|
+
try {
|
|
89
|
+
const result = await auth.checkSetup();
|
|
90
|
+
const setupAllowed = [opts.setupRoute, opts.setupWizardRoute].filter(Boolean);
|
|
91
|
+
if (result.needs_setup && !setupAllowed.includes(to.name as string)) {
|
|
92
|
+
return { name: opts.setupRoute };
|
|
93
|
+
}
|
|
94
|
+
if (!result.needs_setup && to.name === opts.setupRoute) {
|
|
95
|
+
return { name: opts.loginRoute };
|
|
96
|
+
}
|
|
97
|
+
if (
|
|
98
|
+
!result.registration_enabled &&
|
|
99
|
+
opts.registerRoute &&
|
|
100
|
+
to.name === opts.registerRoute
|
|
101
|
+
) {
|
|
102
|
+
return { name: opts.loginRoute };
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// ignore
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (to.meta?.requiresAuth !== false && !auth.isAuthenticated) {
|
|
110
|
+
const redirect = to.fullPath && to.fullPath !== "/" ? to.fullPath : undefined;
|
|
111
|
+
return { name: opts.loginRoute, query: redirect ? { redirect } : undefined };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (auth.isAuthenticated && permStore && !permStore.loaded) {
|
|
115
|
+
await permStore.fetch();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const publicRoutes = [opts.loginRoute, opts.setupRoute];
|
|
119
|
+
if (opts.registerRoute) publicRoutes.push(opts.registerRoute);
|
|
120
|
+
if (publicRoutes.includes(to.name as string) && auth.isAuthenticated) {
|
|
121
|
+
return "/";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (auth.isAuthenticated && !auth.user) {
|
|
125
|
+
// Identity hydration on a session-restored client. The fetch's
|
|
126
|
+
// own failure path already clears the local session (see
|
|
127
|
+
// useAuthBase — deliberately NOT a cookie-clearing logout), so the
|
|
128
|
+
// only job here is to not let the rejection kill the navigation
|
|
129
|
+
// — an aborted guard is reported to router.onError, where it was
|
|
130
|
+
// misclassified as a lazy-load chunk failure and retried in a
|
|
131
|
+
// loop. Redirect to the login page instead; if the fetch
|
|
132
|
+
// succeeded the route renders with a real identity.
|
|
133
|
+
try {
|
|
134
|
+
await auth.fetchUser();
|
|
135
|
+
} catch {
|
|
136
|
+
if (!auth.isAuthenticated) {
|
|
137
|
+
const redirect = to.fullPath && to.fullPath !== "/" ? to.fullPath : undefined;
|
|
138
|
+
return { name: opts.loginRoute, query: redirect ? { redirect } : undefined };
|
|
139
|
+
}
|
|
140
|
+
// Transient failure while still authenticated: let the route
|
|
141
|
+
// through — the shell's own authed-watch retries hydration.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Enforce meta-declared route permissions (`requiresPermission`). The
|
|
146
|
+
// meta has been declared since the admin console landed (/backend with
|
|
147
|
+
// "system.read") but was never consumed — AdminLayout hides its own
|
|
148
|
+
// nav for callers lacking the permission, yet the route itself rendered
|
|
149
|
+
// for ANY authenticated user (field report 2026-09: a non-admin opened
|
|
150
|
+
// the console route from the user menu and got a broken half-page).
|
|
151
|
+
// Deny only when a permission set is actually loaded; a store outage
|
|
152
|
+
// (fetch failed, loaded=false) fails open exactly like before instead
|
|
153
|
+
// of bricking navigation.
|
|
154
|
+
const required = to.meta?.requiresPermission as string | string[] | undefined;
|
|
155
|
+
if (
|
|
156
|
+
auth.isAuthenticated &&
|
|
157
|
+
permStore &&
|
|
158
|
+
required !== undefined &&
|
|
159
|
+
permStore.loaded &&
|
|
160
|
+
!(Array.isArray(required) ? permStore.hasAny(required) : permStore.has(required))
|
|
161
|
+
) {
|
|
162
|
+
return { name: opts.homeRoute };
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
if (opts.onLazyLoadError) {
|
|
167
|
+
router.onError?.((error: unknown, to: GuardRoute) => {
|
|
168
|
+
opts.onLazyLoadError!(error as Error, to.fullPath ?? "");
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { installHistorySafetyNet, sanitizeHistoryUrl } from "./historySafetyNet";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The field bug this net closes (chest #754 lineage): some producer
|
|
7
|
+
* hands the history layer a URL that resolves off-origin — classically
|
|
8
|
+
* `origin + "undefined"` — pushState throws SecurityError, and
|
|
9
|
+
* vue-router's fallback performs a full-page location.assign onto
|
|
10
|
+
* "https://demo.dev.celestia.worldundefined/". The net must fold every
|
|
11
|
+
* off-origin / poisoned target while leaving legitimate same-origin
|
|
12
|
+
* URLs bit-for-bit untouched.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
describe("sanitizeHistoryUrl", () => {
|
|
16
|
+
it("passes through nullish and empty (no-op calls stay no-ops)", () => {
|
|
17
|
+
expect(sanitizeHistoryUrl(null)).toBe(null);
|
|
18
|
+
expect(sanitizeHistoryUrl(undefined)).toBe(undefined);
|
|
19
|
+
expect(sanitizeHistoryUrl("")).toBe("");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("passes through same-origin absolute URLs untouched", () => {
|
|
23
|
+
const origin = window.location.origin;
|
|
24
|
+
expect(sanitizeHistoryUrl(`${origin}/backend#providers`)).toBe(`${origin}/backend#providers`);
|
|
25
|
+
expect(sanitizeHistoryUrl(`${origin}/@ws?box=b#think`)).toBe(`${origin}/@ws?box=b#think`);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("passes through in-app relative targets untouched", () => {
|
|
29
|
+
expect(sanitizeHistoryUrl("/backend")).toBe("/backend");
|
|
30
|
+
expect(sanitizeHistoryUrl("/@uuid?mode=reports#think")).toBe("/@uuid?mode=reports#think");
|
|
31
|
+
expect(sanitizeHistoryUrl("#think")).toBe("#think");
|
|
32
|
+
expect(sanitizeHistoryUrl("?redirect=/backend")).toBe("?redirect=/backend");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("folds the cross-origin concatenated-undefined class onto the fallback", () => {
|
|
36
|
+
expect(sanitizeHistoryUrl("https://demo.dev.celestia.worldundefined")).toBe("/");
|
|
37
|
+
expect(sanitizeHistoryUrl("https://demo.dev.celestia.worldundefined/")).toBe("/");
|
|
38
|
+
expect(sanitizeHistoryUrl("https://evil.example/steal")).toBe("/");
|
|
39
|
+
expect(sanitizeHistoryUrl("//evil.example/x")).toBe("/");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("honors a custom fallback (apps whose landing route is not /)", () => {
|
|
43
|
+
expect(sanitizeHistoryUrl("https://evil.example/x", "/panel/home")).toBe("/panel/home");
|
|
44
|
+
expect(sanitizeHistoryUrl("undefined", "/panel/home")).toBe("/panel/home");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("folds bare poisoned literals onto the fallback", () => {
|
|
48
|
+
expect(sanitizeHistoryUrl("undefined")).toBe("/");
|
|
49
|
+
expect(sanitizeHistoryUrl("null")).toBe("/");
|
|
50
|
+
expect(sanitizeHistoryUrl("NaN")).toBe("/");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("folds unparseable garbage onto the fallback", () => {
|
|
54
|
+
expect(sanitizeHistoryUrl("http://")).toBe("/");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("installHistorySafetyNet", () => {
|
|
59
|
+
const nativePush = History.prototype.pushState;
|
|
60
|
+
const nativeReplace = History.prototype.replaceState;
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
History.prototype.pushState = nativePush;
|
|
64
|
+
History.prototype.replaceState = nativeReplace;
|
|
65
|
+
sessionStorage.clear();
|
|
66
|
+
// The installer detects the restored (unpatched) prototype and
|
|
67
|
+
// re-patches on the next call — that self-healing path is exercised
|
|
68
|
+
// by every test after the first restore.
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("coerces a cross-origin pushState target instead of throwing", () => {
|
|
72
|
+
installHistorySafetyNet();
|
|
73
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
74
|
+
expect(() => history.pushState(null, "", "https://demo.dev.celestia.worldundefined/")).not.toThrow();
|
|
75
|
+
expect(window.location.pathname).toBe("/");
|
|
76
|
+
expect(warn).toHaveBeenCalled();
|
|
77
|
+
warn.mockRestore();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("leaves same-origin pushState untouched", () => {
|
|
81
|
+
installHistorySafetyNet();
|
|
82
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
83
|
+
history.pushState(null, "", "/backend");
|
|
84
|
+
expect(window.location.pathname).toBe("/backend");
|
|
85
|
+
expect(warn).not.toHaveBeenCalled();
|
|
86
|
+
warn.mockRestore();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("passes the 2-argument call shape through untouched (backStack / vue-router beforeUnload)", () => {
|
|
90
|
+
// Production shape: window.history.pushState(state, "") — no url
|
|
91
|
+
// argument at all. A wrapper-level regression that folded undefined
|
|
92
|
+
// here would break every modal/back-guard push, so pin it at the
|
|
93
|
+
// wrapper level, not just the pure function.
|
|
94
|
+
installHistorySafetyNet();
|
|
95
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
96
|
+
expect(() => window.history.pushState({ marker: 1 }, "")).not.toThrow();
|
|
97
|
+
expect(window.history.state).toEqual({ marker: 1 });
|
|
98
|
+
expect(() => window.history.replaceState(null, "")).not.toThrow();
|
|
99
|
+
expect(window.history.state).toBeNull();
|
|
100
|
+
expect(warn).not.toHaveBeenCalled();
|
|
101
|
+
warn.mockRestore();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("passes vue-router's real production shape (same-origin ABSOLUTE url) untouched", () => {
|
|
105
|
+
// changeLocation always composes protocol+'//'+host+base+to — pin
|
|
106
|
+
// that exact shape so an over-eager origin comparison cannot ever
|
|
107
|
+
// fold a legitimate router push.
|
|
108
|
+
installHistorySafetyNet();
|
|
109
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
110
|
+
const abs = `${window.location.origin}/@ws?mode=reports#think`;
|
|
111
|
+
history.pushState(null, "", abs);
|
|
112
|
+
expect(window.location.href).toBe(abs);
|
|
113
|
+
expect(warn).not.toHaveBeenCalled();
|
|
114
|
+
warn.mockRestore();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("records a bounded sessionStorage evidence trail", () => {
|
|
118
|
+
installHistorySafetyNet();
|
|
119
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
120
|
+
for (let i = 0; i < 12; i++) {
|
|
121
|
+
history.replaceState(null, "", `https://evil.example/${i}`);
|
|
122
|
+
}
|
|
123
|
+
const trail = JSON.parse(sessionStorage.getItem("hikari:historyNet") || "[]") as string[];
|
|
124
|
+
expect(trail.length).toBeLessThanOrEqual(8);
|
|
125
|
+
expect(trail[trail.length - 1]).toContain("evil.example/11");
|
|
126
|
+
vi.restoreAllMocks();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("reports through the onCoercion sink and honors evidenceKey: false", () => {
|
|
130
|
+
const seen: string[] = [];
|
|
131
|
+
installHistorySafetyNet({ evidenceKey: false, onCoercion: (info) => seen.push(info.raw) });
|
|
132
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
133
|
+
history.pushState(null, "", "https://evil.example/a");
|
|
134
|
+
history.pushState(null, "", "https://evil.example/b");
|
|
135
|
+
expect(seen).toEqual(["https://evil.example/a", "https://evil.example/b"]);
|
|
136
|
+
expect(sessionStorage.getItem("hikari:historyNet")).toBeNull();
|
|
137
|
+
vi.restoreAllMocks();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Last-line safety net over the History API (family runtime).
|
|
3
|
+
*
|
|
4
|
+
* Upstreamed from shittim-chest #754, where the field class was first
|
|
5
|
+
* sealed app-locally: a navigation producer whose target stringifies
|
|
6
|
+
* without a leading "/" (the classic being an interpolated `undefined`)
|
|
7
|
+
* makes vue-router's HTML5 history compose `origin + base + to` into a
|
|
8
|
+
* CROSS-ORIGIN URL; the native pushState then throws a SecurityError
|
|
9
|
+
* and vue-router's catch branch performs a FULL-PAGE
|
|
10
|
+
* `location.assign(url)` onto the broken host — observed in the field
|
|
11
|
+
* as `https://<host>undefined/`. Guards ABOVE the router only protect
|
|
12
|
+
* navigations that flow through them; a stale-tab mixed module graph,
|
|
13
|
+
* a future call site, or any direct `history.pushState(garbage)` all
|
|
14
|
+
* bypass them. This net sits BELOW everything: it patches
|
|
15
|
+
* `History.prototype.pushState/replaceState` so an off-origin URL (or
|
|
16
|
+
* a bare poisoned literal, or unparseable garbage) is folded onto the
|
|
17
|
+
* app landing BEFORE the native call — with the throw gone, the
|
|
18
|
+
* full-page fallback for this class is structurally unreachable.
|
|
19
|
+
*
|
|
20
|
+
* Every coercion logs a console warning carrying the producer stack
|
|
21
|
+
* trace and appends to a bounded sessionStorage evidence trail so the
|
|
22
|
+
* next field occurrence can be attributed.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Bare literals that read as a producer bug, never a real target. */
|
|
26
|
+
const POISONED_LITERALS = new Set(["undefined", "null", "nan"]);
|
|
27
|
+
|
|
28
|
+
/** sessionStorage evidence budget: keep the newest N coerced targets. */
|
|
29
|
+
const EVIDENCE_MAX = 8;
|
|
30
|
+
const DEFAULT_EVIDENCE_KEY = "hikari:historyNet";
|
|
31
|
+
|
|
32
|
+
export interface HistorySafetyNetOptions {
|
|
33
|
+
/** Where coerced targets fold to. Must be an in-app absolute path.
|
|
34
|
+
* Defaults to `"/"`. */
|
|
35
|
+
fallback?: string;
|
|
36
|
+
/** sessionStorage key for the evidence trail. Pass `false` to
|
|
37
|
+
* disable persistence (the console trail always stays). */
|
|
38
|
+
evidenceKey?: string | false;
|
|
39
|
+
/** Extra sink for coercions (app telemetry, tests). */
|
|
40
|
+
onCoercion?: (info: HistoryCoercion) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface HistoryCoercion {
|
|
44
|
+
method: "pushState" | "replaceState";
|
|
45
|
+
raw: string;
|
|
46
|
+
foldedTo: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Coerce a would-be history URL onto a safe same-origin target.
|
|
50
|
+
*
|
|
51
|
+
* - `null`-ish / empty passes through untouched (no-ops stay no-ops);
|
|
52
|
+
* - same-origin resolutions pass through untouched;
|
|
53
|
+
* - anything that resolves off-origin (the `origin + "undefined"`
|
|
54
|
+
* concatenation class, absolute foreign URLs) folds to the
|
|
55
|
+
* fallback;
|
|
56
|
+
* - bare poisoned literals ("undefined") fold too — as relative URLs
|
|
57
|
+
* they would stay same-origin, but they are always producer bugs
|
|
58
|
+
* and deserve the same hard stop.
|
|
59
|
+
*/
|
|
60
|
+
export function sanitizeHistoryUrl(
|
|
61
|
+
raw: string | URL | null | undefined,
|
|
62
|
+
fallback = "/",
|
|
63
|
+
): string | URL | null {
|
|
64
|
+
if (raw == null || raw === "") return raw as string | URL | null;
|
|
65
|
+
const asUrl = raw instanceof URL ? raw : null;
|
|
66
|
+
const s = asUrl ? asUrl.href : String(raw);
|
|
67
|
+
const trimmed = s.trim();
|
|
68
|
+
if (POISONED_LITERALS.has(trimmed.toLowerCase())) return fallback;
|
|
69
|
+
let resolved: URL;
|
|
70
|
+
try {
|
|
71
|
+
resolved = new URL(s, window.location.href);
|
|
72
|
+
} catch {
|
|
73
|
+
return fallback;
|
|
74
|
+
}
|
|
75
|
+
return resolved.origin === window.location.origin ? (raw as string | URL) : fallback;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Marker stamped on patched methods so re-install stays a no-op while
|
|
79
|
+
* a genuine overwrite (another lib restoring the prototype, or a test
|
|
80
|
+
* harness) is detected and re-patched. */
|
|
81
|
+
const PATCH_MARKER = "__hikariHistoryNet";
|
|
82
|
+
|
|
83
|
+
/** Patch History.prototype. Safe to call multiple times: already-
|
|
84
|
+
* patched methods are left alone, but a method overwritten back to a
|
|
85
|
+
* foreign implementation is patched again (self-healing). */
|
|
86
|
+
export function installHistorySafetyNet(options: HistorySafetyNetOptions = {}): void {
|
|
87
|
+
const fallback = options.fallback ?? "/";
|
|
88
|
+
const evidenceKey = options.evidenceKey === undefined ? DEFAULT_EVIDENCE_KEY : options.evidenceKey;
|
|
89
|
+
for (const method of ["pushState", "replaceState"] as const) {
|
|
90
|
+
const current: unknown = (History.prototype as unknown as Record<string, unknown>)[method];
|
|
91
|
+
if (
|
|
92
|
+
typeof current === "function" &&
|
|
93
|
+
(current as { [PATCH_MARKER]?: boolean })[PATCH_MARKER] === true
|
|
94
|
+
) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const native = current as (this: History, state: unknown, title: string, url?: string | URL | null) => unknown;
|
|
98
|
+
if (typeof native !== "function") continue;
|
|
99
|
+
const patched = function (this: History, state: unknown, title: string, url?: string | URL | null) {
|
|
100
|
+
const safe = sanitizeHistoryUrl(url, fallback);
|
|
101
|
+
if (safe !== url) {
|
|
102
|
+
const info: HistoryCoercion = { method, raw: String(url ?? ""), foldedTo: fallback };
|
|
103
|
+
reportCoercion(info, evidenceKey, options);
|
|
104
|
+
}
|
|
105
|
+
return native.call(this, state as never, title, safe as never);
|
|
106
|
+
};
|
|
107
|
+
(patched as { [PATCH_MARKER]?: boolean })[PATCH_MARKER] = true;
|
|
108
|
+
try {
|
|
109
|
+
Object.defineProperty(History.prototype, method, {
|
|
110
|
+
value: patched,
|
|
111
|
+
writable: true,
|
|
112
|
+
// Match the WebIDL shape of native prototype operations
|
|
113
|
+
// (enumerable) so Object.keys/for...in keep seeing the methods.
|
|
114
|
+
enumerable: true,
|
|
115
|
+
configurable: true,
|
|
116
|
+
});
|
|
117
|
+
} catch {
|
|
118
|
+
// Non-configurable prototype (locked-down environment) — the
|
|
119
|
+
// layers above (router guard, sanitized producers) still hold.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Log loudly and keep a bounded sessionStorage trail for attribution. */
|
|
125
|
+
function reportCoercion(
|
|
126
|
+
info: HistoryCoercion,
|
|
127
|
+
evidenceKey: string | false,
|
|
128
|
+
options: HistorySafetyNetOptions,
|
|
129
|
+
): void {
|
|
130
|
+
console.warn(
|
|
131
|
+
`[HistoryNet] Blocked off-origin history ${info.method} target ${JSON.stringify(info.raw)} — folded to ${JSON.stringify(info.foldedTo)}`,
|
|
132
|
+
new Error("producer stack").stack,
|
|
133
|
+
);
|
|
134
|
+
options.onCoercion?.(info);
|
|
135
|
+
if (evidenceKey === false) return;
|
|
136
|
+
const entry = `${new Date().toISOString()} ${info.method}: ${JSON.stringify(info.raw)} -> ${info.foldedTo}`;
|
|
137
|
+
try {
|
|
138
|
+
const prev = JSON.parse(sessionStorage.getItem(evidenceKey) || "[]");
|
|
139
|
+
// A corrupted/non-array payload must not kill the trail forever —
|
|
140
|
+
// start a fresh list instead of skipping every future write.
|
|
141
|
+
const list = Array.isArray(prev) ? prev : [];
|
|
142
|
+
list.push(entry);
|
|
143
|
+
sessionStorage.setItem(evidenceKey, JSON.stringify(list.slice(-EVIDENCE_MAX)));
|
|
144
|
+
} catch { /* storage unavailable — the console trail is enough */ }
|
|
145
|
+
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -3,6 +3,20 @@ export * from "./cronBus";
|
|
|
3
3
|
export * from "./intervalBus";
|
|
4
4
|
export * from "./pageLifecycle";
|
|
5
5
|
export * from "./mobileViewport";
|
|
6
|
+
export {
|
|
7
|
+
sanitizeHistoryUrl,
|
|
8
|
+
installHistorySafetyNet,
|
|
9
|
+
type HistorySafetyNetOptions,
|
|
10
|
+
type HistoryCoercion,
|
|
11
|
+
} from "./historySafetyNet";
|
|
12
|
+
export {
|
|
13
|
+
createPoisonedLocationGuard,
|
|
14
|
+
installNavigationSafetyNet,
|
|
15
|
+
type NavigationSafetyNetOptions,
|
|
16
|
+
type GuardRouter,
|
|
17
|
+
type GuardRoute,
|
|
18
|
+
} from "./navigationGuard";
|
|
19
|
+
export { createAuthGuard, type AuthGuardOptions } from "./createAuthGuard";
|
|
6
20
|
export {
|
|
7
21
|
createBackGuard,
|
|
8
22
|
BACK_GUARD_MARKER,
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createPoisonedLocationGuard,
|
|
5
|
+
installNavigationSafetyNet,
|
|
6
|
+
type GuardRoute,
|
|
7
|
+
} from "./navigationGuard";
|
|
8
|
+
|
|
9
|
+
/** Minimal router double: records guards, lets tests drive them. */
|
|
10
|
+
function makeRouter() {
|
|
11
|
+
const guards: Array<(to: GuardRoute) => unknown> = [];
|
|
12
|
+
const router = {
|
|
13
|
+
beforeEach: (fn: (to: GuardRoute) => unknown) => { guards.push(fn); },
|
|
14
|
+
};
|
|
15
|
+
return {
|
|
16
|
+
router,
|
|
17
|
+
run: (to: GuardRoute) => {
|
|
18
|
+
expect(guards.length).toBeGreaterThan(0);
|
|
19
|
+
return guards[guards.length - 1](to);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("createPoisonedLocationGuard", () => {
|
|
25
|
+
it("allows in-app absolute paths through", () => {
|
|
26
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
27
|
+
const r = makeRouter();
|
|
28
|
+
createPoisonedLocationGuard(r.router);
|
|
29
|
+
expect(r.run({ path: "/backend", fullPath: "/backend#x" })).toBe(true);
|
|
30
|
+
expect(r.run({ path: "/@ws", fullPath: "/@ws" })).toBe(true);
|
|
31
|
+
expect(warn).not.toHaveBeenCalled();
|
|
32
|
+
warn.mockRestore();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("folds non-slash-rooted paths (the stringified-undefined class)", () => {
|
|
36
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
37
|
+
const r = makeRouter();
|
|
38
|
+
createPoisonedLocationGuard(r.router);
|
|
39
|
+
expect(r.run({ path: "undefined", fullPath: "undefined" })).toBe("/");
|
|
40
|
+
expect(r.run({ path: "https://evil.example/x", fullPath: "https://evil.example/x" })).toBe("/");
|
|
41
|
+
// A "//evil" PATH is not the open-redirect form once the router
|
|
42
|
+
// composes it (origin + "//evil…" = a same-origin double-slash
|
|
43
|
+
// path); the open-redirect threat lives at the location.assign
|
|
44
|
+
// layer, where safeRedirect-style producers reject it.
|
|
45
|
+
expect(r.run({ path: "//evil.example/x", fullPath: "//evil.example/x" })).toBe(true);
|
|
46
|
+
expect(warn).toHaveBeenCalled();
|
|
47
|
+
warn.mockRestore();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("folds the bare poisoned literals but keeps deeper paths", () => {
|
|
51
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
52
|
+
const r = makeRouter();
|
|
53
|
+
createPoisonedLocationGuard(r.router);
|
|
54
|
+
expect(r.run({ path: "undefined" })).toBe("/");
|
|
55
|
+
expect(r.run({ path: "null" })).toBe("/");
|
|
56
|
+
expect(r.run({ path: "nan" })).toBe("/");
|
|
57
|
+
// "/undefined" stays a valid (matched-later) in-app path — the
|
|
58
|
+
// catch-all route owns it, same contract as the chest guard.
|
|
59
|
+
expect(r.run({ path: "/undefined" })).toBe(true);
|
|
60
|
+
warn.mockRestore();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("honors a custom fallback", () => {
|
|
64
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
65
|
+
const r = makeRouter();
|
|
66
|
+
createPoisonedLocationGuard(r.router, "/panel/home");
|
|
67
|
+
expect(r.run({ path: "undefined" })).toBe("/panel/home");
|
|
68
|
+
warn.mockRestore();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("treats a missing path as poisoned (never trust undefined)", () => {
|
|
72
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
73
|
+
const r = makeRouter();
|
|
74
|
+
createPoisonedLocationGuard(r.router);
|
|
75
|
+
expect(r.run({ path: undefined as unknown as string, fullPath: "x" })).toBe("/");
|
|
76
|
+
warn.mockRestore();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("installNavigationSafetyNet", () => {
|
|
81
|
+
const nativePush = History.prototype.pushState;
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
History.prototype.pushState = nativePush;
|
|
85
|
+
sessionStorage.clear();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("installs both layers: history patch + router guard", () => {
|
|
89
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
90
|
+
const r = makeRouter();
|
|
91
|
+
installNavigationSafetyNet({ router: r.router });
|
|
92
|
+
// Router layer folds the poisoned target before history sees it.
|
|
93
|
+
expect(r.run({ path: "undefined" })).toBe("/");
|
|
94
|
+
// History layer still coerces direct off-origin producers.
|
|
95
|
+
expect(() => history.pushState(null, "", "https://evil.example/x")).not.toThrow();
|
|
96
|
+
expect(window.location.pathname).toBe("/");
|
|
97
|
+
warn.mockRestore();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("works without a router (history-only install)", () => {
|
|
101
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
102
|
+
expect(() => installNavigationSafetyNet({})).not.toThrow();
|
|
103
|
+
expect(() => history.pushState(null, "", "https://evil.example/y")).not.toThrow();
|
|
104
|
+
expect(window.location.pathname).toBe("/");
|
|
105
|
+
warn.mockRestore();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poisoned-location router guard (family runtime).
|
|
3
|
+
*
|
|
4
|
+
* Companion to {@link installHistorySafetyNet}: that net folds what
|
|
5
|
+
* REACHES the history layer; this guard folds what reaches the ROUTER
|
|
6
|
+
* first, so a poisoned target dies as an in-app redirect (landing
|
|
7
|
+
* route) instead of ever composing a URL at all. Both layers share the
|
|
8
|
+
* same literal set — defense in depth, with the guard giving the nicer
|
|
9
|
+
* UX (no address-bar flicker) and the net giving the structural
|
|
10
|
+
* guarantee (covers producers that bypass the router entirely).
|
|
11
|
+
*
|
|
12
|
+
* Upstreamed from shittim-chest #622/#754.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { installHistorySafetyNet, type HistorySafetyNetOptions } from "./historySafetyNet";
|
|
16
|
+
|
|
17
|
+
/** Bare literals that read as a producer bug, never a real target. */
|
|
18
|
+
const POISONED_LITERALS = new Set(["undefined", "null", "nan"]);
|
|
19
|
+
|
|
20
|
+
/** Structural slice of vue-router's Router — the guard only hooks
|
|
21
|
+
* beforeEach, so any router-shaped object works (no vue-router dep:
|
|
22
|
+
* hikari stays UI-library-scoped). */
|
|
23
|
+
export interface GuardRouter {
|
|
24
|
+
beforeEach: (fn: (to: GuardRoute, ...rest: unknown[]) => unknown) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface GuardRoute {
|
|
28
|
+
path?: string | null;
|
|
29
|
+
fullPath?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface NavigationSafetyNetOptions extends HistorySafetyNetOptions {
|
|
33
|
+
/** Route shape the guard registers against. The History patch
|
|
34
|
+
* installs even without a router (direct pushState producers). */
|
|
35
|
+
router?: GuardRouter;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Register the beforeEach poisoned-target fold on a router. */
|
|
39
|
+
export function createPoisonedLocationGuard(
|
|
40
|
+
router: GuardRouter,
|
|
41
|
+
fallback = "/",
|
|
42
|
+
): void {
|
|
43
|
+
router.beforeEach((to: GuardRoute) => {
|
|
44
|
+
const path = to.path ?? "";
|
|
45
|
+
if (!path.startsWith("/") || POISONED_LITERALS.has(path)) {
|
|
46
|
+
console.warn(
|
|
47
|
+
`[Router] Blocked poisoned navigation target "${path}" (fullPath "${to.fullPath}") — redirecting to "${fallback}"`,
|
|
48
|
+
);
|
|
49
|
+
return fallback;
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One-call navigation safety: the History-prototype net plus the
|
|
56
|
+
* router-level poisoned-target guard. Install before the first
|
|
57
|
+
* navigation (module scope of the app's router setup is ideal). */
|
|
58
|
+
export function installNavigationSafetyNet(options: NavigationSafetyNetOptions = {}): void {
|
|
59
|
+
installHistorySafetyNet(options);
|
|
60
|
+
if (options.router) createPoisonedLocationGuard(options.router, options.fallback ?? "/");
|
|
61
|
+
}
|