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:
naiba
2026-05-31 05:51:39 +00:00
co-authored by cloudcode
parent 472174640a
commit c9bed85a91
5 changed files with 62 additions and 17 deletions
+7 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+13
View File
@@ -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) {
+8 -2
View File
@@ -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>
))} ))}