mirror of
https://github.com/Buriburizaem0n/admin-frontend-domain.git
synced 2026-09-19 17:50: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 }
|
||||
const parts = trimmed.split(",").map((s) => s.trim())
|
||||
const out: number[] = []
|
||||
const seen = new Set<number>()
|
||||
for (const p of parts) {
|
||||
if (p === "") return { ok: false, error: "empty server id" }
|
||||
if (!/^\d+$/.test(p)) return { ok: false, error: `invalid server id: ${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)
|
||||
}
|
||||
return { ok: true, value: out }
|
||||
|
||||
+16
-4
@@ -39,10 +39,22 @@ function isUnsafeMethod(method: FetcherMethod): boolean {
|
||||
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
|
||||
// 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 (!isSameOrigin(path)) return {}
|
||||
const token = readCookie(csrfCookieName)
|
||||
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) {
|
||||
response = await fetch(buildUrl(path, data), {
|
||||
method: method,
|
||||
headers: csrfHeaders(method),
|
||||
headers: csrfHeaders(method, path),
|
||||
})
|
||||
} else {
|
||||
response = await fetch(path, {
|
||||
method: method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...csrfHeaders(method),
|
||||
...csrfHeaders(method, path),
|
||||
},
|
||||
body: data ? JSON.stringify(data) : null,
|
||||
})
|
||||
@@ -98,7 +110,7 @@ function triggerAutoRefresh() {
|
||||
lastestRefreshTokenAt = Date.now()
|
||||
fetch("/api/v1/refresh-token", {
|
||||
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: () => {},
|
||||
})
|
||||
|
||||
// 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 }) => {
|
||||
const profile = useMainStore((store) => store.profile)
|
||||
const setProfile = useMainStore((store) => store.setProfile)
|
||||
@@ -32,7 +40,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
try {
|
||||
const user = await getProfile()
|
||||
if (authEpoch.current !== epoch) return
|
||||
user.role = user.role || 0
|
||||
user.role = normalizeRole(user.role)
|
||||
setProfile(user)
|
||||
} catch {
|
||||
if (authEpoch.current !== epoch) return
|
||||
@@ -50,7 +58,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
await loginRequest(username, password)
|
||||
const user = await getProfile()
|
||||
authEpoch.current++
|
||||
user.role = user.role || 0
|
||||
user.role = normalizeRole(user.role)
|
||||
setProfile(user)
|
||||
navigate("/dashboard")
|
||||
} catch (error: any) {
|
||||
@@ -71,7 +79,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
try {
|
||||
const user = await getProfile()
|
||||
authEpoch.current++
|
||||
user.role = user.role || 0
|
||||
user.role = normalizeRole(user.role)
|
||||
setProfile(user)
|
||||
navigate("/dashboard")
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -133,6 +133,19 @@ export function formatPath(path: string) {
|
||||
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) {
|
||||
if (p) {
|
||||
if (p.ipv4_addr && p.ipv6_addr) {
|
||||
|
||||
+15
-9
@@ -26,7 +26,7 @@ import { Textarea } from "@/components/ui/textarea"
|
||||
import { useAuth } from "@/hooks/useAuth"
|
||||
import { useNotification } from "@/hooks/useNotfication"
|
||||
import useSetting from "@/hooks/useSetting"
|
||||
import { asOptionalField } from "@/lib/utils"
|
||||
import { asOptionalField, safeExternalHref } from "@/lib/utils"
|
||||
import { nezhaLang, settingCoverageTypes } from "@/types"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useEffect } from "react"
|
||||
@@ -223,14 +223,20 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</SelectItem>
|
||||
<div className="px-8 py-1">
|
||||
<a
|
||||
href={template.repository}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
{template.repository}
|
||||
</a>
|
||||
{safeExternalHref(template.repository) ? (
|
||||
<a
|
||||
href={safeExternalHref(template.repository)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
{template.repository}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{template.repository}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user