mirror of
https://github.com/Buriburizaem0n/admin-frontend-domain.git
synced 2026-09-19 09:40:13 +00:00
fix(security): guard external hrefs and refine CSRF/auth-loading handling
- Add safeExternalHref(): only render http(s) URLs as clickable hrefs, blocking attacker-controlled template metadata from becoming javascript:/data: links. - Refine CSRF header attachment and auth-loading state in the fetcher, api-tokens client, useAuth, and settings route. Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
This commit is contained in:
@@ -119,11 +119,17 @@ export function parseServerIDsInput(raw: string): ParseServerIDsResult {
|
|||||||
if (trimmed === "") return { ok: true, value: undefined }
|
if (trimmed === "") return { ok: true, value: undefined }
|
||||||
const parts = trimmed.split(",").map((s) => s.trim())
|
const parts = trimmed.split(",").map((s) => s.trim())
|
||||||
const out: number[] = []
|
const out: number[] = []
|
||||||
|
const seen = new Set<number>()
|
||||||
for (const p of parts) {
|
for (const p of parts) {
|
||||||
if (p === "") return { ok: false, error: "empty server id" }
|
if (p === "") return { ok: false, error: "empty server id" }
|
||||||
if (!/^\d+$/.test(p)) return { ok: false, error: `invalid server id: ${p}` }
|
if (!/^\d+$/.test(p)) return { ok: false, error: `invalid server id: ${p}` }
|
||||||
const n = Number(p)
|
const n = Number(p)
|
||||||
if (!Number.isInteger(n) || n <= 0) return { ok: false, error: `invalid server id: ${p}` }
|
// Server IDs are uint64 on the backend; reject values that lose
|
||||||
|
// precision as a JS number, otherwise the PAT could bind to a
|
||||||
|
// different server than the operator typed.
|
||||||
|
if (!Number.isSafeInteger(n) || n <= 0) return { ok: false, error: `invalid server id: ${p}` }
|
||||||
|
if (seen.has(n)) continue
|
||||||
|
seen.add(n)
|
||||||
out.push(n)
|
out.push(n)
|
||||||
}
|
}
|
||||||
return { ok: true, value: out }
|
return { ok: true, value: out }
|
||||||
|
|||||||
+16
-4
@@ -39,10 +39,22 @@ function isUnsafeMethod(method: FetcherMethod): boolean {
|
|||||||
return method !== FetcherMethod.GET
|
return method !== FetcherMethod.GET
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only attach the CSRF token to same-origin requests. Keying on HTTP method
|
||||||
|
// alone would leak the nz-csrf value to any absolute cross-origin URL a caller
|
||||||
|
// passes in; the double-submit token is meaningful only to our own backend.
|
||||||
|
function isSameOrigin(path: string): boolean {
|
||||||
|
try {
|
||||||
|
return new URL(path, window.location.origin).origin === window.location.origin
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Double-submit CSRF: backend requires X-CSRF-Token == nz-csrf cookie on
|
// Double-submit CSRF: backend requires X-CSRF-Token == nz-csrf cookie on
|
||||||
// cookie-authenticated unsafe methods.
|
// cookie-authenticated unsafe methods.
|
||||||
function csrfHeaders(method: FetcherMethod): Record<string, string> {
|
function csrfHeaders(method: FetcherMethod, path: string): Record<string, string> {
|
||||||
if (!isUnsafeMethod(method)) return {}
|
if (!isUnsafeMethod(method)) return {}
|
||||||
|
if (!isSameOrigin(path)) return {}
|
||||||
const token = readCookie(csrfCookieName)
|
const token = readCookie(csrfCookieName)
|
||||||
return token ? { [csrfHeaderName]: token } : {}
|
return token ? { [csrfHeaderName]: token } : {}
|
||||||
}
|
}
|
||||||
@@ -52,14 +64,14 @@ export async function fetcher<T>(method: FetcherMethod, path: string, data?: any
|
|||||||
if (method === FetcherMethod.GET || method === FetcherMethod.DELETE) {
|
if (method === FetcherMethod.GET || method === FetcherMethod.DELETE) {
|
||||||
response = await fetch(buildUrl(path, data), {
|
response = await fetch(buildUrl(path, data), {
|
||||||
method: method,
|
method: method,
|
||||||
headers: csrfHeaders(method),
|
headers: csrfHeaders(method, path),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
response = await fetch(path, {
|
response = await fetch(path, {
|
||||||
method: method,
|
method: method,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...csrfHeaders(method),
|
...csrfHeaders(method, path),
|
||||||
},
|
},
|
||||||
body: data ? JSON.stringify(data) : null,
|
body: data ? JSON.stringify(data) : null,
|
||||||
})
|
})
|
||||||
@@ -98,7 +110,7 @@ function triggerAutoRefresh() {
|
|||||||
lastestRefreshTokenAt = Date.now()
|
lastestRefreshTokenAt = Date.now()
|
||||||
fetch("/api/v1/refresh-token", {
|
fetch("/api/v1/refresh-token", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: csrfHeaders(FetcherMethod.POST),
|
headers: csrfHeaders(FetcherMethod.POST, "/api/v1/refresh-token"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -15,6 +15,14 @@ const AuthContext = createContext<AuthContextProps>({
|
|||||||
logout: () => {},
|
logout: () => {},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Admin is role 0 on the backend. A missing/non-numeric role must never
|
||||||
|
// collapse to 0, or a malformed profile response would be treated as admin
|
||||||
|
// client-side; default unknown roles to a non-admin value instead.
|
||||||
|
const NON_ADMIN_ROLE = 1
|
||||||
|
function normalizeRole(role: unknown): number {
|
||||||
|
return typeof role === "number" && Number.isFinite(role) ? role : NON_ADMIN_ROLE
|
||||||
|
}
|
||||||
|
|
||||||
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
const profile = useMainStore((store) => store.profile)
|
const profile = useMainStore((store) => store.profile)
|
||||||
const setProfile = useMainStore((store) => store.setProfile)
|
const setProfile = useMainStore((store) => store.setProfile)
|
||||||
@@ -32,7 +40,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
try {
|
try {
|
||||||
const user = await getProfile()
|
const user = await getProfile()
|
||||||
if (authEpoch.current !== epoch) return
|
if (authEpoch.current !== epoch) return
|
||||||
user.role = user.role || 0
|
user.role = normalizeRole(user.role)
|
||||||
setProfile(user)
|
setProfile(user)
|
||||||
} catch {
|
} catch {
|
||||||
if (authEpoch.current !== epoch) return
|
if (authEpoch.current !== epoch) return
|
||||||
@@ -50,7 +58,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
await loginRequest(username, password)
|
await loginRequest(username, password)
|
||||||
const user = await getProfile()
|
const user = await getProfile()
|
||||||
authEpoch.current++
|
authEpoch.current++
|
||||||
user.role = user.role || 0
|
user.role = normalizeRole(user.role)
|
||||||
setProfile(user)
|
setProfile(user)
|
||||||
navigate("/dashboard")
|
navigate("/dashboard")
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -71,7 +79,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
try {
|
try {
|
||||||
const user = await getProfile()
|
const user = await getProfile()
|
||||||
authEpoch.current++
|
authEpoch.current++
|
||||||
user.role = user.role || 0
|
user.role = normalizeRole(user.role)
|
||||||
setProfile(user)
|
setProfile(user)
|
||||||
navigate("/dashboard")
|
navigate("/dashboard")
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -133,6 +133,19 @@ export function formatPath(path: string) {
|
|||||||
return path.replace(/\/{2,}/g, "/")
|
return path.replace(/\/{2,}/g, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns the URL only if it uses an http(s) scheme, else undefined. Guards
|
||||||
|
// against rendering attacker-controlled template metadata as a clickable
|
||||||
|
// javascript:/data: href.
|
||||||
|
export function safeExternalHref(url?: string): string | undefined {
|
||||||
|
if (!url) return undefined
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url, window.location.origin)
|
||||||
|
return parsed.protocol === "https:" || parsed.protocol === "http:" ? parsed.href : undefined
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function joinIP(p?: ModelIP) {
|
export function joinIP(p?: ModelIP) {
|
||||||
if (p) {
|
if (p) {
|
||||||
if (p.ipv4_addr && p.ipv6_addr) {
|
if (p.ipv4_addr && p.ipv6_addr) {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { Textarea } from "@/components/ui/textarea"
|
|||||||
import { useAuth } from "@/hooks/useAuth"
|
import { useAuth } from "@/hooks/useAuth"
|
||||||
import { useNotification } from "@/hooks/useNotfication"
|
import { useNotification } from "@/hooks/useNotfication"
|
||||||
import useSetting from "@/hooks/useSetting"
|
import useSetting from "@/hooks/useSetting"
|
||||||
import { asOptionalField } from "@/lib/utils"
|
import { asOptionalField, safeExternalHref } from "@/lib/utils"
|
||||||
import { nezhaLang, settingCoverageTypes } from "@/types"
|
import { nezhaLang, settingCoverageTypes } from "@/types"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
@@ -223,14 +223,20 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<div className="px-8 py-1">
|
<div className="px-8 py-1">
|
||||||
|
{safeExternalHref(template.repository) ? (
|
||||||
<a
|
<a
|
||||||
href={template.repository}
|
href={safeExternalHref(template.repository)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
||||||
>
|
>
|
||||||
{template.repository}
|
{template.repository}
|
||||||
</a>
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{template.repository}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user