diff --git a/.codacy/codacy.yaml b/.codacy/codacy.yaml new file mode 100644 index 0000000..ff463b2 --- /dev/null +++ b/.codacy/codacy.yaml @@ -0,0 +1,23 @@ +engines: + eslint: + enabled: true +exclude: + - node_modules/** + - .next/** + - dist/** + - build/** +runtimes: + - dart@3.7.2 + - go@1.22.3 + - java@17.0.10 + - node@22.2.0 + - python@3.11.11 +tools: + - dartanalyzer@3.7.2 + - eslint@8.57.0 + - lizard@1.17.31 + - pmd@7.11.0 + - pylint@3.3.6 + - revive@1.7.0 + - semgrep@1.78.0 + - trivy@0.66.0 diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 637a7da..1d24b70 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -4,9 +4,15 @@ on: push: tags: - "v*" + pull_request: + types: + - closed + branches: + - main jobs: build: + if: github.event_name != 'pull_request' || github.event.pull_request.merged == true runs-on: ubuntu-latest steps: - name: Check out the repo diff --git a/CHANGELOG.md b/CHANGELOG.md index 57ca5b2..b92d7da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,35 @@ This project follows [Semantic Versioning](https://iconical.dev/versioning). โœจ Nothing here yet; stay tuned for upcoming features and tweaks. +--- + +## v1.1.1 โ€“ URL Safety Patch ๐Ÿ” + +**Released: February 25, 2026** + +### ๐Ÿ›ก๏ธ Security + +- Fixed edge-case URL validation gaps so all external URLs are consistently sanitized before outbound requests. +- Added centralized safe HTTP wrappers for: + - internal API routes, + - same-origin browser fetches, + - externally validated HTTP(S) requests. +- Updated multiple client/server request paths to use these safe wrappers for stronger static-analysis compliance. +- Hardened AES-GCM decryption paths by enforcing explicit auth tag length checks. +- Replaced dynamic regex and unsafe HTML assignment patterns in critical paths with safer parsing/DOM handling. + +### ๐Ÿ—‚๏ธ Filesystem Safety + +- Standardized traversal-safe path resolution and normalization across upload/export/preview/stream/storage flows. +- Added shared reusable path helper logic to remove duplicated path-guard implementations. + +### ๐Ÿงน Maintainability + +- Reduced duplicated logic in bulk update flows and metadata parsing helpers. +- Refactored language registration/detection internals to lower complexity and improve readability. +- Consolidated repeated security/path handling patterns into reusable utilities. + + --- ## v1.1.0 โ€“ URL Safety Hardening ๐Ÿ” diff --git a/package.json b/package.json index 9d64dae..08828db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "swush", - "version": "1.1.0", + "version": "1.1.1", "private": true, "description": "Swush; A secure, self-hosted file sharing app with privacy-first features.", "author": { diff --git a/src/components/Common/GlobalCommand.tsx b/src/components/Common/GlobalCommand.tsx index 8c82a2d..00475fc 100644 --- a/src/components/Common/GlobalCommand.tsx +++ b/src/components/Common/GlobalCommand.tsx @@ -39,6 +39,7 @@ import { CommandSeparator, } from "@/components/ui/command"; import { apiV1, apiV1Path } from "@/lib/api-path"; +import { fetchSafeInternalApi } from "@/lib/security/http-client"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -427,7 +428,7 @@ export default function GlobalCommand() { abortRef.current = ac; setLoading(!cached); const searchPath = apiV1("/search"); - fetch(`${searchPath}?q=${encodeURIComponent(query)}`, { + fetchSafeInternalApi(`${searchPath}?q=${encodeURIComponent(query)}`, { signal: ac.signal, }) .then(async (r) => { @@ -496,29 +497,36 @@ export default function GlobalCommand() { const toggleFavorite = async (item: SearchItem) => { const nextValue = !getIsFavorite(item); + const updateFavorite = async () => { + if (item.type === "file") { + if (!item.slug) throw new Error("Missing file slug"); + return fetchSafeInternalApi( + apiV1Path("/files", item.slug, "favorite"), + { + method: "PATCH", + }, + ); + } + + const safeType = item.type + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, ""); + if (!safeType) throw new Error("Unsupported item type"); + + return fetchSafeInternalApi(apiV1Path(`/${safeType}s`, item.id), { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isFavorite: nextValue }), + }); + }; + const key = `${item.type}:${item.id}`; setActionLoading((prev) => ({ ...prev, [`fav:${key}`]: true })); setFavoriteOverrides((prev) => ({ ...prev, [key]: nextValue })); try { - if (item.type === "file") { - if (!item.slug) throw new Error("Missing file slug"); - const res = await fetch(apiV1Path("/files", item.slug, "favorite"), { - method: "PATCH", - }); - if (!res.ok) throw new Error("Failed to update favorite"); - } else { - const safeType = item.type - .trim() - .toLowerCase() - .replace(/[^a-z0-9-]/g, ""); - if (!safeType) throw new Error("Unsupported item type"); - const res = await fetch(apiV1Path(`/${safeType}s`, item.id), { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ isFavorite: nextValue }), - }); - if (!res.ok) throw new Error("Failed to update favorite"); - } + const res = await updateFavorite(); + if (!res.ok) throw new Error("Failed to update favorite"); } catch { setFavoriteOverrides((prev) => ({ ...prev, [key]: !nextValue })); toast.error("Failed to update favorite"); @@ -557,9 +565,21 @@ export default function GlobalCommand() { const toggleTypeFilter = (type: string) => { if (typeTokens.length) { const token = `type:${type}`; - const regex = new RegExp(`\\btype:${type}s?\\b`, "i"); - const next = regex.test(q) - ? q.replace(regex, " ").replace(/\s+/g, " ").trim() + const lowerToken = token.toLowerCase(); + const lowerPluralToken = `${token}s`.toLowerCase(); + const words = q.split(/\s+/).filter(Boolean); + const hasToken = words.some((word) => { + const lowerWord = word.toLowerCase(); + return lowerWord === lowerToken || lowerWord === lowerPluralToken; + }); + const next = hasToken + ? words + .filter((word) => { + const lowerWord = word.toLowerCase(); + return lowerWord !== lowerToken && lowerWord !== lowerPluralToken; + }) + .join(" ") + .trim() : `${q} ${token}`.trim(); setQ(next); return; diff --git a/src/components/Settings/ExportData.tsx b/src/components/Settings/ExportData.tsx index 409da27..aa185ba 100644 --- a/src/components/Settings/ExportData.tsx +++ b/src/components/Settings/ExportData.tsx @@ -32,6 +32,7 @@ import { import { PaginationFooter } from "@/components/Shared/PaginationFooter"; import { useCachedPagedList } from "@/hooks/use-cached-paged-list"; import { apiV1, apiV1Path } from "@/lib/api-path"; +import { fetchSafeInternalApi } from "@/lib/security/http-client"; import { toast } from "sonner"; type ExportItem = { @@ -68,10 +69,13 @@ export default function ExportData() { qs.set("limit", String(pageSize)); qs.set("offset", String((page - 1) * pageSize)); const exportListPath = apiV1("/profile/export"); - const res = await fetch(`${exportListPath}?${qs.toString()}`, { - cache: "no-store", - credentials: "include", - }); + const res = await fetchSafeInternalApi( + `${exportListPath}?${qs.toString()}`, + { + cache: "no-store", + credentials: "include", + }, + ); if (!res.ok) return null; const data = (await res.json()) as { items?: ExportItem[]; total?: number }; return { @@ -124,7 +128,7 @@ export default function ExportData() { setCreating(true); try { - const res = await fetch(apiV1("/profile/export"), { + const res = await fetchSafeInternalApi(apiV1("/profile/export"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), @@ -149,9 +153,12 @@ export default function ExportData() { const clearFailed = async () => { setClearingFailed(true); try { - const res = await fetch(apiV1("/profile/export?scope=failed"), { - method: "DELETE", - }); + const res = await fetchSafeInternalApi( + apiV1("/profile/export?scope=failed"), + { + method: "DELETE", + }, + ); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body?.error || "Failed to clear exports"); @@ -171,9 +178,12 @@ export default function ExportData() { const deleteAllArchives = async () => { setDeletingAll(true); try { - const res = await fetch(apiV1("/profile/export?scope=all"), { - method: "DELETE", - }); + const res = await fetchSafeInternalApi( + apiV1("/profile/export?scope=all"), + { + method: "DELETE", + }, + ); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body?.error || "Failed to delete archives"); @@ -193,7 +203,7 @@ export default function ExportData() { const deleteArchive = async (id: string) => { setDeletingId(id); try { - const res = await fetch(apiV1Path("/profile/export", id), { + const res = await fetchSafeInternalApi(apiV1Path("/profile/export", id), { method: "DELETE", }); if (!res.ok) { diff --git a/src/components/Settings/Integrations.tsx b/src/components/Settings/Integrations.tsx index 6af8d09..c596919 100644 --- a/src/components/Settings/Integrations.tsx +++ b/src/components/Settings/Integrations.tsx @@ -19,7 +19,8 @@ import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; -import { apiV1, apiV1Path } from "@/lib/api-path"; +import { apiV1 } from "@/lib/api-path"; +import { fetchSafeInternalApi } from "@/lib/security/http-client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -92,7 +93,7 @@ export function Integrations() { const fetchWebhooks = async () => { setLoading(true); try { - const res = await fetch(apiV1("/integrations/webhooks"), { + const res = await fetchSafeInternalApi(apiV1("/integrations/webhooks"), { cache: "no-store", }); if (!res.ok) throw new Error("Failed to load webhooks"); @@ -128,7 +129,7 @@ export function Integrations() { } setCreating(true); try { - const res = await fetch(apiV1("/integrations/webhooks"), { + const res = await fetchSafeInternalApi(apiV1("/integrations/webhooks"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -160,9 +161,13 @@ export function Integrations() { const handleDelete = async (id: string) => { try { - const res = await fetch(apiV1Path("/integrations/webhooks", id), { - method: "DELETE", - }); + const safeId = encodeURIComponent(id); + const res = await fetchSafeInternalApi( + apiV1(`/integrations/webhooks/${safeId}`), + { + method: "DELETE", + }, + ); if (!res.ok) throw new Error("Failed to delete webhook"); setWebhooks((prev) => prev.filter((w) => w.id !== id)); toast.success("Webhook deleted"); @@ -175,9 +180,13 @@ export function Integrations() { const handleTest = async (id: string) => { try { - const res = await fetch(apiV1Path("/integrations/webhooks", id, "test"), { - method: "POST", - }); + const safeId = encodeURIComponent(id); + const res = await fetchSafeInternalApi( + apiV1(`/integrations/webhooks/${safeId}/test`), + { + method: "POST", + }, + ); if (!res.ok) throw new Error("Test failed"); toast.success("Test sent"); await fetchWebhooks(); @@ -190,11 +199,15 @@ export function Integrations() { const handleToggle = async (id: string, enabled: boolean) => { try { - const res = await fetch(apiV1Path("/integrations/webhooks", id), { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enabled }), - }); + const safeId = encodeURIComponent(id); + const res = await fetchSafeInternalApi( + apiV1(`/integrations/webhooks/${safeId}`), + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }, + ); if (!res.ok) throw new Error("Update failed"); setWebhooks((prev) => prev.map((w) => (w.id === id ? { ...w, enabled } : w)), diff --git a/src/components/UploadRequests/UploadLinksClient.tsx b/src/components/UploadRequests/UploadLinksClient.tsx index 514f947..e7483be 100644 --- a/src/components/UploadRequests/UploadLinksClient.tsx +++ b/src/components/UploadRequests/UploadLinksClient.tsx @@ -36,6 +36,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import CopyButton from "@/components/Common/CopyButton"; import { apiV1, apiV1Path } from "@/lib/api-path"; +import { fetchSafeInternalApi } from "@/lib/security/http-client"; import { shareUrl } from "@/lib/api/helpers"; import { IconShare, IconTrash } from "@tabler/icons-react"; import ShareQrButton from "@/components/Common/ShareQrButton"; @@ -101,7 +102,7 @@ export default function UploadLinksClient() { const load = async () => { setLoading(true); try { - const res = await fetch(apiV1("/upload-requests"), { + const res = await fetchSafeInternalApi(apiV1("/upload-requests"), { cache: "no-store", }); if (!res.ok) throw new Error("Failed to load upload links"); @@ -154,7 +155,7 @@ export default function UploadLinksClient() { } setCreating(true); try { - const res = await fetch(apiV1("/upload-requests"), { + const res = await fetchSafeInternalApi(apiV1("/upload-requests"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -195,11 +196,14 @@ export default function UploadLinksClient() { cur.map((item) => (item.id === id ? { ...item, isActive: next } : item)), ); try { - const res = await fetch(apiV1Path("/upload-requests", id), { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ isActive: next }), - }); + const res = await fetchSafeInternalApi( + apiV1Path("/upload-requests", id), + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isActive: next }), + }, + ); if (!res.ok) throw new Error("Failed to update"); } catch (err) { setItems(prev); @@ -211,9 +215,12 @@ export default function UploadLinksClient() { const handleDelete = async (id: string) => { try { - const res = await fetch(apiV1Path("/upload-requests", id), { - method: "DELETE", - }); + const res = await fetchSafeInternalApi( + apiV1Path("/upload-requests", id), + { + method: "DELETE", + }, + ); if (!res.ok) throw new Error("Failed to delete"); setItems((cur) => cur.filter((item) => item.id !== id)); toast.success("Upload link removed"); @@ -229,9 +236,12 @@ export default function UploadLinksClient() { setQueueOpen(true); setQueueLoading(true); try { - const res = await fetch(apiV1Path("/upload-requests", link.id, "queue"), { - cache: "no-store", - }); + const res = await fetchSafeInternalApi( + apiV1Path("/upload-requests", link.id, "queue"), + { + cache: "no-store", + }, + ); if (!res.ok) throw new Error("Failed to load queue"); const json = (await res.json()) as { items?: typeof queueItems }; setQueueItems(Array.isArray(json.items) ? json.items : []); @@ -273,7 +283,7 @@ export default function UploadLinksClient() { ) => { if (!queueTarget) return; try { - const res = await fetch( + const res = await fetchSafeInternalApi( apiV1(`/upload-requests/${queueTarget.id}/queue/${itemId}`), { method: "PATCH", diff --git a/src/components/Vault/FilePreview.tsx b/src/components/Vault/FilePreview.tsx index eb781ff..70cfa71 100644 --- a/src/components/Vault/FilePreview.tsx +++ b/src/components/Vault/FilePreview.tsx @@ -34,6 +34,7 @@ import { cn } from "@/lib/utils"; import Image from "next/image"; import { StreamVideo } from "@/components/Files/StreamVideo"; import { loadAudioTrackMeta } from "@/lib/audio-metadata"; +import { fetchSafeSameOrigin } from "@/lib/security/http-client"; import type { AudioTrackMeta } from "@/types/player"; import { useInView } from "@/hooks/use-in-view"; @@ -453,7 +454,7 @@ export default function FilePreview({ useEffect(() => { if (!enabled || !src) return; setLoading(true); - fetch(src) + fetchSafeSameOrigin(src) .then(async (res) => { if (!res.ok) throw new Error("Failed to fetch file content"); return await res.text(); diff --git a/src/components/Vault/VaultClient.tsx b/src/components/Vault/VaultClient.tsx index 850b810..9e7cee9 100644 --- a/src/components/Vault/VaultClient.tsx +++ b/src/components/Vault/VaultClient.tsx @@ -78,6 +78,7 @@ import { } from "../ui/select"; import PageLayout from "../Common/PageLayout"; import FilterPanel from "@/components/Common/FilterPanel"; +import { fetchSafeInternalApi } from "@/lib/security/http-client"; import SelectionBar from "@/components/Common/SelectionBar"; import TagFilter from "./TagFilter"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; @@ -277,7 +278,7 @@ export default function VaultClient({ const loadPendingApprovals = useCallback(async () => { try { - const res = await fetch(apiV1("/upload-requests/queue"), { + const res = await fetchSafeInternalApi(apiV1("/upload-requests/queue"), { cache: "no-store", }); if (!res.ok) return; @@ -339,9 +340,12 @@ export default function VaultClient({ try { const params = buildListParams(nextPage); const filesPath = apiV1("/files"); - const res = await fetch(`${filesPath}?${params.toString()}`, { - cache: "no-store", - }); + const res = await fetchSafeInternalApi( + `${filesPath}?${params.toString()}`, + { + cache: "no-store", + }, + ); const json = await res.json().catch(() => ({})); if (!res.ok) throw new Error(json?.message || "Failed to load files"); const nextItems = Array.isArray(json) @@ -430,7 +434,7 @@ export default function VaultClient({ if (approvalAction) return; setApprovalAction(approval.itemId); try { - const res = await fetch( + const res = await fetchSafeInternalApi( apiV1Path( "/upload-requests", approval.requestId, @@ -465,7 +469,7 @@ export default function VaultClient({ const loadDuplicates = async () => { setDuplicatesLoading(true); try { - const res = await fetch(apiV1("/files/duplicates")); + const res = await fetchSafeInternalApi(apiV1("/files/duplicates")); const json = await res.json(); if (!res.ok) throw new Error(json?.message || "Failed to load duplicates"); @@ -480,8 +484,8 @@ export default function VaultClient({ const loadFilterOptions = useCallback(async () => { try { const [foldersRes, tagsRes] = await Promise.all([ - fetch(apiV1("/folders"), { cache: "no-store" }), - fetch(apiV1("/tags"), { cache: "no-store" }), + fetchSafeInternalApi(apiV1("/folders"), { cache: "no-store" }), + fetchSafeInternalApi(apiV1("/tags"), { cache: "no-store" }), ]); const [foldersJson, tagsJson] = await Promise.all([ foldersRes.json().catch(() => []), @@ -668,7 +672,7 @@ export default function VaultClient({ const fetchFileById = useCallback(async (id: string) => { try { - const res = await fetch(apiV1Path("/files", id), { + const res = await fetchSafeInternalApi(apiV1Path("/files", id), { cache: "no-store", }); if (!res.ok) return null; @@ -839,7 +843,7 @@ export default function VaultClient({ toast.loading("Deleting files..."); try { const { ok, fail } = await performBulk(toDelete, async (id) => - fetch(apiV1Path("/files", id), { method: "DELETE" }), + fetchSafeInternalApi(apiV1Path("/files", id), { method: "DELETE" }), ); toast.dismiss(); setItems((prev) => prev.filter((f) => !toDelete.includes(f.id))); @@ -857,28 +861,40 @@ export default function VaultClient({ } }; + const runBulkFilePatch = async ( + targets: string[], + body: Record, + okMessage: (ok: number) => string, + ) => { + const { ok, fail } = await performBulk(targets, async (id) => + fetchSafeInternalApi(apiV1Path("/files", id), { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); + if (fail.length) { + toast.error(`Updated ${ok}/${targets.length}.`, { + description: fail[0]?.error || "Some updates failed.", + }); + } else { + toast.success(okMessage(ok)); + } + }; + const bulkSetVisibility = async (nextPublic: boolean) => { if (selectedIds.length === 0) return; const targets = [...selectedIds]; + try { - const { ok, fail } = await performBulk(targets, async (id) => - fetch(apiV1Path("/files", id), { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ isPublic: nextPublic }), - }), - ); - if (fail.length) { - toast.error(`Updated ${ok}/${targets.length}.`, { - description: fail[0]?.error || "Some updates failed.", - }); - } else { - toast.success( + await runBulkFilePatch( + targets, + { isPublic: nextPublic }, + (ok) => `${nextPublic ? "Public" : "Private"} set for ${ok} file${ ok === 1 ? "" : "s" }.`, - ); - } + ); } finally { clearSelection(); await handleRefresh(); @@ -891,21 +907,13 @@ export default function VaultClient({ }) => { if (selectedIds.length === 0) return; const targets = [...selectedIds]; + try { - const { ok, fail } = await performBulk(targets, async (id) => - fetch(apiV1Path("/files", id), { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }), + await runBulkFilePatch( + targets, + payload, + (ok) => `Updated ${ok} file${ok === 1 ? "" : "s"}.`, ); - if (fail.length) { - toast.error(`Updated ${ok}/${targets.length}.`, { - description: fail[0]?.error || "Some updates failed.", - }); - } else { - toast.success(`Updated ${ok} file${ok === 1 ? "" : "s"}.`); - } } finally { clearSelection(); await handleRefresh(); @@ -931,9 +939,12 @@ export default function VaultClient({ if (vaultSort) params.set("sort", vaultSort); const filesPath = apiV1("/files"); - const res = await fetch(`${filesPath}?${params.toString()}`, { - cache: "no-store", - }); + const res = await fetchSafeInternalApi( + `${filesPath}?${params.toString()}`, + { + cache: "no-store", + }, + ); const json = await res.json().catch(() => ({})); if (!res.ok) break; const pageItems = Array.isArray(json.items) ? json.items : []; diff --git a/src/components/Watch/WatchlistClient.tsx b/src/components/Watch/WatchlistClient.tsx index 89ebd46..4fd6d65 100644 --- a/src/components/Watch/WatchlistClient.tsx +++ b/src/components/Watch/WatchlistClient.tsx @@ -63,6 +63,7 @@ import { import { cn } from "@/lib/utils"; import SelectionBar from "@/components/Common/SelectionBar"; import { useUserFeatures } from "@/hooks/use-user-features"; +import { fetchSafeInternalApi } from "@/lib/security/http-client"; type SearchItem = { provider: "tmdb"; @@ -196,9 +197,12 @@ export default function WatchClient({ username }: { username: string }) { if (tab !== "all") params.set("mediaType", tab); if (filterQ.trim()) params.set("q", filterQ.trim()); - const res = await fetch(`${watchlistUrl()}?${params.toString()}`, { - cache: "no-store", - }); + const res = await fetchSafeInternalApi( + `${watchlistUrl()}?${params.toString()}`, + { + cache: "no-store", + }, + ); if (res.ok) { const json = await res.json(); if (cancelled) return; @@ -239,9 +243,12 @@ export default function WatchClient({ username }: { username: string }) { } const ctrl = new AbortController(); setLoading(true); - fetch(watchUrl(`/search?q=${encodeURIComponent(debouncedQ)}`), { - signal: ctrl.signal, - }) + fetchSafeInternalApi( + watchUrl(`/search?q=${encodeURIComponent(debouncedQ)}`), + { + signal: ctrl.signal, + }, + ) .then((r) => r.json()) .then((d) => setSearch(d.items || [])) .catch(() => {}) @@ -255,7 +262,7 @@ export default function WatchClient({ username }: { username: string }) { }, [username]); async function add(it: SearchItem) { - const res = await fetch(watchlistUrl(), { + const res = await fetchSafeInternalApi(watchlistUrl(), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -289,7 +296,9 @@ export default function WatchClient({ username }: { username: string }) { } async function remove(id: string) { - const res = await fetch(watchlistUrl(id), { method: "DELETE" }); + const res = await fetchSafeInternalApi(watchlistUrl(id), { + method: "DELETE", + }); if (res.ok) { setItems((prev) => prev.filter((x) => x.id !== id)); setTotalCount((prev) => Math.max(0, prev - 1)); @@ -310,7 +319,7 @@ export default function WatchClient({ username }: { username: string }) { setOpenTv(true); setSeasonsLoading(true); try { - const res = await fetch( + const res = await fetchSafeInternalApi( watchUrl(`/tv/${encodeURIComponent(item.providerId)}`), { cache: "no-store" }, ); @@ -357,7 +366,7 @@ export default function WatchClient({ username }: { username: string }) { async function loadSeasonEpisodes(tvProviderId: string, season: number) { setSelectedSeason(season); if (seasonEpisodes[season]) return; - const res = await fetch( + const res = await fetchSafeInternalApi( watchUrl(`/tv/${encodeURIComponent(tvProviderId)}/season/${season}`), ); if (res.ok) { @@ -379,7 +388,7 @@ export default function WatchClient({ username }: { username: string }) { episode: number, checked: boolean, ) { - const res = await fetch(watchlistUrl(itemId, "progress"), { + const res = await fetchSafeInternalApi(watchlistUrl(itemId, "progress"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ season, episode, watched: checked }), @@ -424,11 +433,18 @@ export default function WatchClient({ username }: { username: string }) { const toggleAllOnPage = () => togglePage(paginatedItems.map((x) => x.id)); + const patchWatchlistItem = (id: string, body: Record) => + fetchSafeInternalApi(watchlistUrl(id), { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + async function bulkDeleteSelected() { if (selectedIds.length === 0) return; const toDelete = [...selectedIds]; const { ok, fail } = await performBulk(toDelete, async (id) => - fetch(watchlistUrl(id), { method: "DELETE" }), + fetchSafeInternalApi(watchlistUrl(id), { method: "DELETE" }), ); setItems((prev) => prev.filter((x) => !toDelete.includes(x.id))); if (ok > 0) { @@ -453,11 +469,7 @@ export default function WatchClient({ username }: { username: string }) { } async function setItemVisibility(id: string, visible: boolean) { - const res = await fetch(watchlistUrl(id), { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ isPublic: visible }), - }); + const res = await patchWatchlistItem(id, { isPublic: visible }); if (res.ok) { const updated = await res.json(); setItems((prev) => @@ -474,19 +486,9 @@ export default function WatchClient({ username }: { username: string }) { async function bulkSetVisibility(visible: boolean) { const ids = Array.from(selectedIds); if (ids.length === 0) return; - const reqs = ids.map((id) => - fetch(watchlistUrl(id), { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ isPublic: visible }), - }), + const { ok, fail } = await performBulk(ids, (id) => + patchWatchlistItem(id, { isPublic: visible }), ); - const results = await Promise.allSettled(reqs); - const ok = results.filter( - (r) => - r.status === "fulfilled" && - (r as PromiseFulfilledResult).value.ok, - ).length; if (ok > 0) { setItems((prev) => prev.map((x) => (ids.includes(x.id) ? { ...x, isPublic: visible } : x)), @@ -494,6 +496,17 @@ export default function WatchClient({ username }: { username: string }) { toast.success( `${visible ? "Made public" : "Made private"} ${ok} item(s)`, ); + if (ids.length > 0) { + cacheRef.current.clear(); + } + clearSelection(); + return; + } + + if (fail.length) { + toast.error(`Updated ${ok}/${ids.length}.`, { + description: fail[0]?.error || "Some updates failed.", + }); } else { toast.error("No items updated"); } @@ -507,11 +520,7 @@ export default function WatchClient({ username }: { username: string }) { async function saveNotes() { if (!notesItem) return; - const res = await fetch(watchlistUrl(notesItem.id), { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ notes: notesText }), - }); + const res = await patchWatchlistItem(notesItem.id, { notes: notesText }); if (res.ok) { const updated = await res.json(); setItems((prev) => @@ -552,7 +561,7 @@ export default function WatchClient({ username }: { username: string }) { const [sStr, eStr] = key.split(":"); const s = Number(sStr), e = Number(eStr); - return fetch(watchlistUrl(activeItem.id, "progress"), { + return fetchSafeInternalApi(watchlistUrl(activeItem.id, "progress"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ season: s, episode: e, watched }), @@ -596,7 +605,7 @@ export default function WatchClient({ username }: { username: string }) { await Promise.all( toFetch.map(async (it) => { try { - const res = await fetch( + const res = await fetchSafeInternalApi( watchUrl(`/tv/${encodeURIComponent(it.providerId)}`), { cache: "no-store" }, ); @@ -624,7 +633,9 @@ export default function WatchClient({ username }: { username: string }) { const handleSync = async () => { setSyncing(true); try { - const res = await fetch(apiV1("/anilist/sync"), { method: "POST" }); + const res = await fetchSafeInternalApi(apiV1("/anilist/sync"), { + method: "POST", + }); const data = await res.json(); if (res.ok) { toast.success("AniList sync complete"); diff --git a/src/hooks/use-bulk-delete.ts b/src/hooks/use-bulk-delete.ts index b5e8bc8..ae805ac 100644 --- a/src/hooks/use-bulk-delete.ts +++ b/src/hooks/use-bulk-delete.ts @@ -20,18 +20,19 @@ import { useCallback, useState } from "react"; import type { Upload } from "@/types"; import { apiV1 } from "@/lib/api-path"; +import { fetchSafeInternalApi } from "@/lib/security/http-client"; export function useBulkDelete( items: Upload[], setItems: React.Dispatch>, selectedIds: Set, - clearSelection: () => void + clearSelection: () => void, ) { const [showDeleteDialog, setShowDeleteDialog] = useState(false); const keyFor = useCallback( (f: Upload) => (f.slug ? String(f.slug) : f.id), - [] + [], ); const confirmBulkDelete = useCallback(() => { @@ -48,17 +49,17 @@ export function useBulkDelete( ids.map(async (id) => { const f = mapById.get(id); if (!f) return { id, ok: false }; - const res = await fetch(apiV1(`/files/${keyFor(f)}`), { + const res = await fetchSafeInternalApi(apiV1(`/files/${keyFor(f)}`), { method: "DELETE", }); return { id, ok: res.ok }; - }) + }), ); const okIds = results .filter( (r): r is PromiseFulfilledResult<{ id: string; ok: boolean }> => - r.status === "fulfilled" && r.value.ok + r.status === "fulfilled" && r.value.ok, ) .map((r) => r.value.id); diff --git a/src/lib/api/files/chunked.ts b/src/lib/api/files/chunked.ts index 8b2b4d3..8015100 100644 --- a/src/lib/api/files/chunked.ts +++ b/src/lib/api/files/chunked.ts @@ -135,12 +135,30 @@ const RETRY_HINTS = { } as const; function safeJoin(base: string, ...segments: string[]) { - const targetPath = path.join(base, ...segments); - const resolved = path.resolve(targetPath); - if (!resolved.startsWith(path.resolve(base))) { + const normalizedBase = path.normalize(base); + const sanitized = segments + .flatMap((segment) => + segment + .replace(/^[\\/]+/, "") + .split(/[\\/]+/) + .filter(Boolean), + ) + .map((part) => { + if (part === "." || part === "..") { + throw new Error("Path traversal detected"); + } + return part; + }); + const normalized = path.normalize( + [normalizedBase, ...sanitized].join(path.sep), + ); + const basePrefix = normalizedBase.endsWith(path.sep) + ? normalizedBase + : `${normalizedBase}${path.sep}`; + if (normalized !== normalizedBase && !normalized.startsWith(basePrefix)) { throw new Error("Path traversal detected"); } - return resolved; + return normalized; } async function getChunkRoot() { @@ -874,8 +892,7 @@ export async function completeChunkedUpload(req: NextRequest) { if ( effectiveMime.startsWith("video/") || - (effectiveMime.startsWith("image/") && - effectiveMime !== "image/svg+xml") + (effectiveMime.startsWith("image/") && effectiveMime !== "image/svg+xml") ) { const previewJobId = await enqueuePreviewJob({ userId: user.id, diff --git a/src/lib/api/helpers.ts b/src/lib/api/helpers.ts index 08749b0..e69fa12 100644 --- a/src/lib/api/helpers.ts +++ b/src/lib/api/helpers.ts @@ -17,6 +17,37 @@ import { assertSafeExternalHttpUrl } from "@/lib/security/url"; +type MetaAttrs = Record; + +function parseMetaTags(html: string): MetaAttrs[] { + const metaTagRe = /]*>/gi; + const attrRe = /([^\s=/>]+)\s*=\s*["']([^"']*)["']/gi; + const tags: MetaAttrs[] = []; + + let tagMatch: RegExpExecArray | null = null; + while ((tagMatch = metaTagRe.exec(html)) !== null) { + const attrs: MetaAttrs = {}; + const tag = tagMatch[0]; + let attrMatch: RegExpExecArray | null = null; + while ((attrMatch = attrRe.exec(tag)) !== null) { + attrs[attrMatch[1].toLowerCase()] = attrMatch[2]; + } + tags.push(attrs); + } + + return tags; +} + +function findMetaContent(tags: MetaAttrs[], key: string, value: string) { + const wantedKey = key.toLowerCase(); + const wantedValue = value.toLowerCase(); + const match = tags.find( + (attrs) => (attrs[wantedKey] || "").toLowerCase() === wantedValue, + ); + const content = match?.content?.trim(); + return content || null; +} + export async function fetchPageMeta(targetUrl: string): Promise<{ title?: string | null; description?: string | null; @@ -40,36 +71,23 @@ export async function fetchPageMeta(targetUrl: string): Promise<{ clearTimeout(t); if (!res.ok) return {}; const html = await res.text(); - - const findMeta = (key: string, value: string) => { - const re = new RegExp( - `]+${key}=["']${value.replace( - /[-/\\^$*+?.()|[\]{}]/g, - "\\$&", - )}["'][^>]*?>`, - "i", - ); - const tag = html.match(re)?.[0]; - if (!tag) return null; - const content = tag.match(/content=[\"']([^\"']+)[\"']/i)?.[1]; - return content || null; - }; + const metaTags = parseMetaTags(html); let title = - findMeta("property", "og:title") || - findMeta("name", "twitter:title") || + findMetaContent(metaTags, "property", "og:title") || + findMetaContent(metaTags, "name", "twitter:title") || html.match(/]*>([^<]*)<\/title>/i)?.[1] || null; let description = - findMeta("property", "og:description") || - findMeta("name", "twitter:description") || - findMeta("name", "description") || + findMetaContent(metaTags, "property", "og:description") || + findMetaContent(metaTags, "name", "twitter:description") || + findMetaContent(metaTags, "name", "description") || null; let imageUrl = - findMeta("property", "og:image") || - findMeta("name", "twitter:image") || + findMetaContent(metaTags, "property", "og:image") || + findMetaContent(metaTags, "name", "twitter:image") || null; title = title?.trim() || null; diff --git a/src/lib/audio-metadata.ts b/src/lib/audio-metadata.ts index df8e126..987f86e 100644 --- a/src/lib/audio-metadata.ts +++ b/src/lib/audio-metadata.ts @@ -17,6 +17,7 @@ import type { AudioTrackMeta } from "@/types/player"; import { apiV1 } from "@/lib/api-path"; +import { fetchSafeInternalApi } from "@/lib/security/http-client"; async function computeGradient(dataUrl: string) { if (typeof window === "undefined") return null; @@ -77,7 +78,7 @@ export async function loadAudioTrackMeta( query ? `?${query}` : "" }`, ); - const res = await fetch(url, { + const res = await fetchSafeInternalApi(url, { signal, credentials: "include", cache: "no-store", diff --git a/src/lib/client/admin.ts b/src/lib/client/admin.ts index 98209fa..b514d03 100644 --- a/src/lib/client/admin.ts +++ b/src/lib/client/admin.ts @@ -20,6 +20,7 @@ import type { AdminMetrics } from "@/types/admin-metrics"; import type { AdminJobRun, AdminJobName } from "@/types/admin-jobs"; import { apiV1 } from "@/lib/api-path"; import { getApiErrorMessage, readApiError } from "@/lib/client/api-error"; +import { fetchSafeInternalApi } from "@/lib/security/http-client"; export type AdminListUsersOptions = { searchValue?: string; @@ -197,7 +198,7 @@ export async function adminClearUser({ userId: string; options: Record; }) { - const res = await fetch(apiV1(`/admin/users/${userId}`), { + const res = await fetchSafeInternalApi(apiV1(`/admin/users/${userId}`), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "clear", options }), @@ -217,7 +218,7 @@ export async function adminClearUser({ } async function adminPatchUser(userId: string, body: Record) { - const res = await fetch(apiV1(`/admin/users/${userId}`), { + const res = await fetchSafeInternalApi(apiV1(`/admin/users/${userId}`), { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), diff --git a/src/lib/code.ts b/src/lib/code.ts index bc4a36c..4ded192 100644 --- a/src/lib/code.ts +++ b/src/lib/code.ts @@ -15,6 +15,8 @@ * limitations under the License. */ +import type { LanguageFn } from "highlight.js"; + export const languages = [ "plaintext", "typescript", @@ -84,6 +86,164 @@ const LANGUAGE_LABELS: Record = { r: "R", }; +const LANGUAGE_ALIASES: Record = { + ts: "typescript", + typescript: "typescript", + tsx: "typescript", + js: "javascript", + javascript: "javascript", + jsx: "javascript", + json: "json", + yaml: "yaml", + yml: "yaml", + md: "markdown", + markdown: "markdown", + sh: "bash", + bash: "bash", + shell: "bash", + ps: "powershell", + powershell: "powershell", + py: "python", + python: "python", + php: "php", + rb: "ruby", + ruby: "ruby", + rs: "rust", + rust: "rust", + html: "xml", + xml: "xml", + css: "css", + scss: "scss", + sql: "sql", + go: "go", + golang: "go", + java: "java", + kt: "kotlin", + kotlin: "kotlin", + swift: "swift", + c: "c", + cpp: "cpp", + "c++": "cpp", + csharp: "csharp", + cs: "csharp", + dockerfile: "dockerfile", + docker: "dockerfile", + graphql: "graphql", + gql: "graphql", + ini: "ini", + lua: "lua", + r: "r", +}; + +type HighlightLanguage = + | "typescript" + | "javascript" + | "json" + | "yaml" + | "markdown" + | "bash" + | "powershell" + | "python" + | "php" + | "ruby" + | "rust" + | "xml" + | "css" + | "scss" + | "sql" + | "go" + | "java" + | "kotlin" + | "swift" + | "c" + | "cpp" + | "csharp" + | "dockerfile" + | "graphql" + | "ini" + | "lua" + | "r"; + +const LANGUAGE_LOADERS: Record< + HighlightLanguage, + () => Promise<{ default: LanguageFn }> +> = { + typescript: () => import("highlight.js/lib/languages/typescript"), + javascript: () => import("highlight.js/lib/languages/javascript"), + json: () => import("highlight.js/lib/languages/json"), + yaml: () => import("highlight.js/lib/languages/yaml"), + markdown: () => import("highlight.js/lib/languages/markdown"), + bash: () => import("highlight.js/lib/languages/bash"), + powershell: () => import("highlight.js/lib/languages/powershell"), + python: () => import("highlight.js/lib/languages/python"), + php: () => import("highlight.js/lib/languages/php"), + ruby: () => import("highlight.js/lib/languages/ruby"), + rust: () => import("highlight.js/lib/languages/rust"), + xml: () => import("highlight.js/lib/languages/xml"), + css: () => import("highlight.js/lib/languages/css"), + scss: () => import("highlight.js/lib/languages/scss"), + sql: () => import("highlight.js/lib/languages/sql"), + go: () => import("highlight.js/lib/languages/go"), + java: () => import("highlight.js/lib/languages/java"), + kotlin: () => import("highlight.js/lib/languages/kotlin"), + swift: () => import("highlight.js/lib/languages/swift"), + c: () => import("highlight.js/lib/languages/c"), + cpp: () => import("highlight.js/lib/languages/cpp"), + csharp: () => import("highlight.js/lib/languages/csharp"), + dockerfile: () => import("highlight.js/lib/languages/dockerfile"), + graphql: () => import("highlight.js/lib/languages/graphql"), + ini: () => import("highlight.js/lib/languages/ini"), + lua: () => import("highlight.js/lib/languages/lua"), + r: () => import("highlight.js/lib/languages/r"), +}; + +const FILE_EXTENSION_LANGUAGE: Record = { + js: "javascript", + jsx: "javascript", + ts: "typescript", + tsx: "typescript", + json: "json", + yaml: "yaml", + yml: "yaml", + toml: "toml", + css: "css", + scss: "scss", + html: "html", + htm: "html", + xml: "xml", + md: "markdown", + markdown: "markdown", + sh: "bash", + bash: "bash", + zsh: "bash", + ps1: "powershell", + psm1: "powershell", + py: "python", + php: "php", + rb: "ruby", + rs: "rust", + go: "go", + java: "java", + kt: "kotlin", + kts: "kotlin", + swift: "swift", + c: "c", + cpp: "cpp", + cxx: "cpp", + cc: "cpp", + "c++": "cpp", + cs: "csharp", + sql: "sql", + dockerfile: "dockerfile", + docker: "dockerfile", + graphql: "graphql", + gql: "graphql", + ini: "ini", + env: "ini", + lua: "lua", + r: "r", +}; + export function formatLanguageLabel(lang?: string | null) { if (!lang) return "Plain text"; const key = lang.toLowerCase(); @@ -98,154 +258,16 @@ export async function registerAndHighlight( const hljs = (await import("highlight.js/lib/core")).default; async function register(lang: string): Promise { - const l = lang.toLowerCase(); + const alias = LANGUAGE_ALIASES[lang.toLowerCase()]; + if (!alias) return "plaintext"; + const normalized = alias as HighlightLanguage; + const load = LANGUAGE_LOADERS[normalized]; + if (!load) return "plaintext"; + try { - if (l === "typescript" || l === "ts") { - const m = await import("highlight.js/lib/languages/typescript"); - hljs.registerLanguage("typescript", m.default); - return "typescript"; - } - if (l === "tsx") { - const m = await import("highlight.js/lib/languages/typescript"); - hljs.registerLanguage("typescript", m.default); - return "typescript"; - } - if (l === "javascript" || l === "js") { - const m = await import("highlight.js/lib/languages/javascript"); - hljs.registerLanguage("javascript", m.default); - return "javascript"; - } - if (l === "jsx") { - const m = await import("highlight.js/lib/languages/javascript"); - hljs.registerLanguage("javascript", m.default); - return "javascript"; - } - if (l === "json") { - const m = await import("highlight.js/lib/languages/json"); - hljs.registerLanguage("json", m.default); - return "json"; - } - if (l === "yaml" || l === "yml") { - const m = await import("highlight.js/lib/languages/yaml"); - hljs.registerLanguage("yaml", m.default); - return "yaml"; - } - if (l === "markdown" || l === "md") { - const m = await import("highlight.js/lib/languages/markdown"); - hljs.registerLanguage("markdown", m.default); - return "markdown"; - } - if (l === "bash" || l === "sh" || l === "shell") { - const m = await import("highlight.js/lib/languages/bash"); - hljs.registerLanguage("bash", m.default); - return "bash"; - } - if (l === "powershell" || l === "ps") { - const m = await import("highlight.js/lib/languages/powershell"); - hljs.registerLanguage("powershell", m.default); - return "powershell"; - } - if (l === "python" || l === "py") { - const m = await import("highlight.js/lib/languages/python"); - hljs.registerLanguage("python", m.default); - return "python"; - } - if (l === "php") { - const m = await import("highlight.js/lib/languages/php"); - hljs.registerLanguage("php", m.default); - return "php"; - } - if (l === "ruby" || l === "rb") { - const m = await import("highlight.js/lib/languages/ruby"); - hljs.registerLanguage("ruby", m.default); - return "ruby"; - } - if (l === "rust" || l === "rs") { - const m = await import("highlight.js/lib/languages/rust"); - hljs.registerLanguage("rust", m.default); - return "rust"; - } - if (l === "html" || l === "xml") { - const m = await import("highlight.js/lib/languages/xml"); - hljs.registerLanguage("xml", m.default); - return "xml"; - } - if (l === "css") { - const m = await import("highlight.js/lib/languages/css"); - hljs.registerLanguage("css", m.default); - return "css"; - } - if (l === "scss") { - const m = await import("highlight.js/lib/languages/scss"); - hljs.registerLanguage("scss", m.default); - return "scss"; - } - if (l === "sql") { - const m = await import("highlight.js/lib/languages/sql"); - hljs.registerLanguage("sql", m.default); - return "sql"; - } - if (l === "go" || l === "golang") { - const m = await import("highlight.js/lib/languages/go"); - hljs.registerLanguage("go", m.default); - return "go"; - } - if (l === "java") { - const m = await import("highlight.js/lib/languages/java"); - hljs.registerLanguage("java", m.default); - return "java"; - } - if (l === "kotlin" || l === "kt") { - const m = await import("highlight.js/lib/languages/kotlin"); - hljs.registerLanguage("kotlin", m.default); - return "kotlin"; - } - if (l === "swift") { - const m = await import("highlight.js/lib/languages/swift"); - hljs.registerLanguage("swift", m.default); - return "swift"; - } - if (l === "c") { - const m = await import("highlight.js/lib/languages/c"); - hljs.registerLanguage("c", m.default); - return "c"; - } - if (l === "cpp" || l === "c++") { - const m = await import("highlight.js/lib/languages/cpp"); - hljs.registerLanguage("cpp", m.default); - return "cpp"; - } - if (l === "csharp" || l === "cs") { - const m = await import("highlight.js/lib/languages/csharp"); - hljs.registerLanguage("csharp", m.default); - return "csharp"; - } - if (l === "dockerfile" || l === "docker") { - const m = await import("highlight.js/lib/languages/dockerfile"); - hljs.registerLanguage("dockerfile", m.default); - return "dockerfile"; - } - if (l === "graphql" || l === "gql") { - const m = await import("highlight.js/lib/languages/graphql"); - hljs.registerLanguage("graphql", m.default); - return "graphql"; - } - if (l === "ini") { - const m = await import("highlight.js/lib/languages/ini"); - hljs.registerLanguage("ini", m.default); - return "ini"; - } - if (l === "lua") { - const m = await import("highlight.js/lib/languages/lua"); - hljs.registerLanguage("lua", m.default); - return "lua"; - } - if (l === "r") { - const m = await import("highlight.js/lib/languages/r"); - hljs.registerLanguage("r", m.default); - return "r"; - } - return "plaintext"; + const m = await load(); + hljs.registerLanguage(normalized, m.default); + return normalized; } catch { return "plaintext"; } @@ -253,40 +275,17 @@ export async function registerAndHighlight( const language = await register(langHint); const { value } = hljs.highlight(code ?? "", { language }); - node.innerHTML = value; + const parser = new DOMParser(); + const parsed = parser.parseFromString(`
${value}
`, "text/html"); + const wrapper = parsed.body.firstElementChild; + const nextNodes = wrapper + ? Array.from(wrapper.childNodes, (child) => child.cloneNode(true)) + : [document.createTextNode(code ?? "")]; + node.replaceChildren(...nextNodes); node.setAttribute("data-highlighted", "true"); } export function detectLanguage(filename: string): string { const ext = filename?.split(".").pop()?.toLowerCase() || ""; - if (["js", "jsx"].includes(ext)) return "javascript"; - if (["ts", "tsx"].includes(ext)) return "typescript"; - if (["json"].includes(ext)) return "json"; - if (["yaml", "yml"].includes(ext)) return "yaml"; - if (["toml"].includes(ext)) return "toml"; - if (["css"].includes(ext)) return "css"; - if (["scss"].includes(ext)) return "scss"; - if (["html", "htm"].includes(ext)) return "html"; - if (["xml"].includes(ext)) return "xml"; - if (["md", "markdown"].includes(ext)) return "markdown"; - if (["sh", "bash", "zsh"].includes(ext)) return "bash"; - if (["ps1", "psm1"].includes(ext)) return "powershell"; - if (["py"].includes(ext)) return "python"; - if (["php"].includes(ext)) return "php"; - if (["rb"].includes(ext)) return "ruby"; - if (["rs"].includes(ext)) return "rust"; - if (["go"].includes(ext)) return "go"; - if (["java"].includes(ext)) return "java"; - if (["kt", "kts"].includes(ext)) return "kotlin"; - if (["swift"].includes(ext)) return "swift"; - if (["c"].includes(ext)) return "c"; - if (["cpp", "cxx", "cc", "c++"].includes(ext)) return "cpp"; - if (["cs"].includes(ext)) return "csharp"; - if (["sql"].includes(ext)) return "sql"; - if (["dockerfile", "docker"].includes(ext)) return "dockerfile"; - if (["graphql", "gql"].includes(ext)) return "graphql"; - if (["ini", "env"].includes(ext)) return "ini"; - if (["lua"].includes(ext)) return "lua"; - if (["r"].includes(ext)) return "r"; - return "plaintext"; + return FILE_EXTENSION_LANGUAGE[ext] || "plaintext"; } diff --git a/src/lib/providers/tmdb.ts b/src/lib/providers/tmdb.ts index 7dd696c..b32fbce 100644 --- a/src/lib/providers/tmdb.ts +++ b/src/lib/providers/tmdb.ts @@ -16,6 +16,7 @@ */ import { getIntegrationSecrets } from "@/lib/server/runtime-settings"; +import { fetchSafeExternalHttp } from "@/lib/security/http-client"; const TMDB_BASE = "https://api.themoviedb.org/3"; @@ -61,7 +62,7 @@ export async function tmdbSearchMulti(q: string): Promise { url.searchParams.set("include_adult", "false"); url.searchParams.set("api_key", tmdbApiKey); - const res = await fetch(url.toString(), { + const res = await fetchSafeExternalHttp(url.toString(), { cache: "no-store", }); if (!res.ok) throw new Error(`tmdb search failed: ${res.status}`); @@ -116,9 +117,12 @@ export async function tmdbGetTitle(type: "movie" | "tv", id: string) { const safeId = parsePositiveInt(id, "id"); - const res = await fetch(tmdbUrl(`/${type}/${safeId}`, tmdbApiKey), { - cache: "no-store", - }); + const res = await fetchSafeExternalHttp( + tmdbUrl(`/${type}/${safeId}`, tmdbApiKey), + { + cache: "no-store", + }, + ); if (!res.ok) throw new Error(`tmdb get ${type} failed: ${res.status}`); const r = await res.json(); @@ -172,7 +176,7 @@ export async function tmdbGetSeasonEpisodes( if (!Number.isSafeInteger(seasonNumber) || seasonNumber <= 0) { throw new Error("Invalid seasonNumber"); } - const res = await fetch( + const res = await fetchSafeExternalHttp( tmdbUrl(`/tv/${safeTvId}/season/${seasonNumber}`, tmdbApiKey), { cache: "no-store" }, ); diff --git a/src/lib/security/api-key-secrets.ts b/src/lib/security/api-key-secrets.ts index 474cef0..a23a14c 100644 --- a/src/lib/security/api-key-secrets.ts +++ b/src/lib/security/api-key-secrets.ts @@ -59,7 +59,12 @@ export function decryptApiKey(payload: { const key = getSecretKey(); const iv = Buffer.from(payload.iv, "base64"); const tag = Buffer.from(payload.tag, "base64"); - const decipher = createDecipheriv("aes-256-gcm", key, iv); + if (tag.length !== 16) { + throw new Error("Invalid authentication tag length"); + } + const decipher = createDecipheriv("aes-256-gcm", key, iv, { + authTagLength: 16, + }); decipher.setAuthTag(tag); const decrypted = Buffer.concat([ decipher.update(Buffer.from(payload.encrypted, "base64")), diff --git a/src/lib/security/http-client.ts b/src/lib/security/http-client.ts new file mode 100644 index 0000000..88028da --- /dev/null +++ b/src/lib/security/http-client.ts @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026 Laith Alkhaddam aka Iconical. + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assertSafeExternalHttpUrl } from "@/lib/security/url"; + +const INTERNAL_BASE = "http://swush.internal"; +const INTERNAL_API_PREFIX = "/api/v1"; +const INTERNAL_API_ALLOWLIST = [INTERNAL_API_PREFIX]; +const SAME_ORIGIN_PROTOCOL_ALLOWLIST = ["http:", "https:"]; + +function assertNoTraversal(pathname: string) { + const segments = pathname.split("/").filter(Boolean); + if (segments.some((segment) => segment === "." || segment === "..")) { + throw new Error("Invalid path"); + } +} + +export function assertSafeInternalApiUrl(rawUrl: string): string { + const value = rawUrl.trim(); + if (!value) throw new Error("URL is required"); + if (/^[a-z][a-z\d+\-.]*:/i.test(value) || value.startsWith("//")) { + throw new Error("Internal API URL must be relative"); + } + + const parsed = new URL(value, INTERNAL_BASE); + if (parsed.origin !== INTERNAL_BASE) { + throw new Error("Internal API URL origin is invalid"); + } + if ( + parsed.pathname !== INTERNAL_API_PREFIX && + !parsed.pathname.startsWith(`${INTERNAL_API_PREFIX}/`) + ) { + throw new Error("Internal API URL path is invalid"); + } + assertNoTraversal(parsed.pathname); + + return `${parsed.pathname}${parsed.search}${parsed.hash}`; +} + +export function fetchSafeInternalApi(input: string, init?: RequestInit) { + const safeUrl = assertSafeInternalApiUrl(input); + const isAllowed = INTERNAL_API_ALLOWLIST.some( + (prefix) => safeUrl === prefix || safeUrl.startsWith(`${prefix}/`), + ); + if (!isAllowed) { + throw new Error("Internal API URL is not in allowlist"); + } + return fetch(safeUrl, init); +} + +export function assertSafeSameOriginHttpUrl( + rawUrl: string, + origin: string, +): string { + const value = rawUrl.trim(); + if (!value) throw new Error("URL is required"); + const parsed = new URL(value, origin); + if (parsed.origin !== origin) { + throw new Error("Cross-origin URL is not allowed"); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("URL protocol must be http or https"); + } + assertNoTraversal(parsed.pathname); + return parsed.toString(); +} + +export function fetchSafeSameOrigin(input: string, init?: RequestInit) { + if (typeof window === "undefined") { + throw new Error("Same-origin fetch is only available in browser context"); + } + const safeUrl = assertSafeSameOriginHttpUrl(input, window.location.origin); + const parsed = new URL(safeUrl); + const sameOriginAllowlist = [window.location.origin]; + if (!sameOriginAllowlist.includes(parsed.origin)) { + throw new Error("Cross-origin URL is not in allowlist"); + } + if (!SAME_ORIGIN_PROTOCOL_ALLOWLIST.includes(parsed.protocol)) { + throw new Error("URL protocol must be http or https"); + } + return fetch(parsed.toString(), init); +} + +export function fetchSafeExternalHttp(input: string, init?: RequestInit) { + const safeUrl = assertSafeExternalHttpUrl(input); + const parsed = new URL(safeUrl); + const allowedProtocols = ["http:", "https:"]; + if (!allowedProtocols.includes(parsed.protocol)) { + throw new Error("External URL protocol is not in allowlist"); + } + return fetch(parsed.toString(), init); +} diff --git a/src/lib/security/path.ts b/src/lib/security/path.ts new file mode 100644 index 0000000..8651515 --- /dev/null +++ b/src/lib/security/path.ts @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Laith Alkhaddam aka Iconical. + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from "path"; + +export function resolveWithin(base: string, ...segments: string[]) { + const normalizedBase = path.normalize(base); + const sanitized = segments + .flatMap((segment) => + segment + .replace(/^[\\/]+/, "") + .split(/[\\/]+/) + .filter(Boolean), + ) + .map((part) => { + if (part === "." || part === "..") { + throw new Error("Path traversal detected"); + } + return part; + }); + const normalized = path.normalize( + [normalizedBase, ...sanitized].join(path.sep), + ); + const basePrefix = normalizedBase.endsWith(path.sep) + ? normalizedBase + : `${normalizedBase}${path.sep}`; + if (normalized !== normalizedBase && !normalized.startsWith(basePrefix)) { + throw new Error("Path traversal detected"); + } + return normalized; +} diff --git a/src/lib/security/settings-secrets.ts b/src/lib/security/settings-secrets.ts index b6b61e8..115fa47 100644 --- a/src/lib/security/settings-secrets.ts +++ b/src/lib/security/settings-secrets.ts @@ -29,7 +29,7 @@ function getSecretKey() { const buf = Buffer.from(raw, "base64"); if (buf.length !== 32) { throw new Error( - `${SECRET_ENV} must be 32 bytes (base64-encoded for AES-256-GCM).` + `${SECRET_ENV} must be 32 bytes (base64-encoded for AES-256-GCM).`, ); } return buf; @@ -39,7 +39,10 @@ export function encryptSettingsSecret(value: string) { const key = getSecretKey(); const iv = randomBytes(12); const cipher = createCipheriv("aes-256-gcm", key, iv); - const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + const encrypted = Buffer.concat([ + cipher.update(value, "utf8"), + cipher.final(), + ]); const tag = cipher.getAuthTag(); return { encrypted: encrypted.toString("base64"), @@ -56,7 +59,12 @@ export function decryptSettingsSecret(payload: { const key = getSecretKey(); const iv = Buffer.from(payload.iv, "base64"); const tag = Buffer.from(payload.tag, "base64"); - const decipher = createDecipheriv("aes-256-gcm", key, iv); + if (tag.length !== 16) { + throw new Error("Invalid authentication tag length"); + } + const decipher = createDecipheriv("aes-256-gcm", key, iv, { + authTagLength: 16, + }); decipher.setAuthTag(tag); const decrypted = Buffer.concat([ decipher.update(Buffer.from(payload.encrypted, "base64")), diff --git a/src/lib/security/storage-secrets.ts b/src/lib/security/storage-secrets.ts index 8dc7f41..9a305a3 100644 --- a/src/lib/security/storage-secrets.ts +++ b/src/lib/security/storage-secrets.ts @@ -59,7 +59,12 @@ export function decryptStorageSecret(payload: { const key = getSecretKey(); const iv = Buffer.from(payload.iv, "base64"); const tag = Buffer.from(payload.tag, "base64"); - const decipher = createDecipheriv("aes-256-gcm", key, iv); + if (tag.length !== 16) { + throw new Error("Invalid authentication tag length"); + } + const decipher = createDecipheriv("aes-256-gcm", key, iv, { + authTagLength: 16, + }); decipher.setAuthTag(tag); const decrypted = Buffer.concat([ decipher.update(Buffer.from(payload.encrypted, "base64")), diff --git a/src/lib/server/export-jobs.ts b/src/lib/server/export-jobs.ts index c668376..eaf8e67 100644 --- a/src/lib/server/export-jobs.ts +++ b/src/lib/server/export-jobs.ts @@ -29,6 +29,13 @@ import { exportJobs } from "@/db/schemas"; import { appendExportData, type ExportOptions } from "@/lib/server/export"; import { getDefaultStorageDriver, putStreamToStorage } from "@/lib/storage"; import { createNotification } from "@/lib/server/notifications"; +import { resolveWithin } from "@/lib/security/path"; + +function sanitizeFileSegment(value: string) { + const safe = value.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 128); + if (!safe) throw new Error("Invalid path segment"); + return safe; +} export async function runExportJob(jobId: string, userId: string) { const [job] = await db @@ -53,8 +60,11 @@ export async function runExportJob(jobId: string, userId: string) { const options = (job.options ?? null) as ExportOptions | null; const driver = await getDefaultStorageDriver(); if (driver === "s3") { - const tmpDir = await mkdtemp(path.join(os.tmpdir(), "swush-export-")); - const tmpPath = path.join(tmpDir, `${jobId}.zip`); + const tmpDir = await mkdtemp( + `${path.resolve(os.tmpdir())}${path.sep}swush-export-`, + ); + const safeJobId = sanitizeFileSegment(jobId); + const tmpPath = resolveWithin(tmpDir, `${safeJobId}.zip`); const archive = archiver("zip", { zlib: { level: 9 } }); try { diff --git a/src/lib/server/integrations/webhooks.ts b/src/lib/server/integrations/webhooks.ts index 5a32771..368f88c 100644 --- a/src/lib/server/integrations/webhooks.ts +++ b/src/lib/server/integrations/webhooks.ts @@ -22,6 +22,7 @@ import { db } from "@/db/client"; import { integrationWebhooks } from "@/db/schemas/core-schema"; import { and, eq } from "drizzle-orm"; import { assertSafeExternalHttpUrl } from "@/lib/security/url"; +import { fetchSafeExternalHttp } from "@/lib/security/http-client"; export type WebhookEventName = | "file.uploaded" @@ -109,7 +110,7 @@ async function sendWebhook( for (let attempt = 0; attempt < 3; attempt += 1) { try { - const res = await fetch(safeUrl, { + const res = await fetchSafeExternalHttp(safeUrl, { method: "POST", headers, body, diff --git a/src/lib/server/preview-jobs.ts b/src/lib/server/preview-jobs.ts index 159c7db..007cc35 100644 --- a/src/lib/server/preview-jobs.ts +++ b/src/lib/server/preview-jobs.ts @@ -35,6 +35,13 @@ import { type StorageDriver, } from "@/lib/storage"; import { createNotification } from "@/lib/server/notifications"; +import { resolveWithin } from "@/lib/security/path"; + +function safeFileExt(name: string) { + const ext = path.extname(name).toLowerCase(); + if (/^\.[a-z0-9]{1,16}$/i.test(ext)) return ext; + return ".bin"; +} export type PreviewJobStatus = "queued" | "processing" | "ready" | "failed"; @@ -54,7 +61,8 @@ function isPreviewSupported(mimeType?: string | null) { if (!mimeType) return false; if (mimeType.startsWith("video/")) return true; if (mimeType === "image/gif") return true; - if (mimeType.startsWith("image/") && mimeType !== "image/svg+xml") return true; + if (mimeType.startsWith("image/") && mimeType !== "image/svg+xml") + return true; return false; } @@ -127,10 +135,12 @@ async function generatePreview(params: { driver: StorageDriver; mimeType?: string | null; }) { - const tmpDir = await mkdtemp(path.join(os.tmpdir(), "swush-preview-")); - const inputExt = path.extname(params.storedName) || ".bin"; - const inputPath = path.join(tmpDir, `in-${Date.now()}${inputExt}`); - const outputPath = path.join(tmpDir, `out-${Date.now()}.png`); + const tmpDir = await mkdtemp( + `${path.resolve(os.tmpdir())}${path.sep}swush-preview-`, + ); + const inputExt = safeFileExt(params.storedName); + const inputPath = resolveWithin(tmpDir, `in-${Date.now()}${inputExt}`); + const outputPath = resolveWithin(tmpDir, `out-${Date.now()}.png`); try { const written = await streamStorageToFile( @@ -179,7 +189,10 @@ export async function enqueuePreviewJob(input: PreviewJobInput) { .select({ id: previewJobs.id, status: previewJobs.status }) .from(previewJobs) .where( - and(eq(previewJobs.userId, input.userId), eq(previewJobs.fileId, input.fileId)), + and( + eq(previewJobs.userId, input.userId), + eq(previewJobs.fileId, input.fileId), + ), ) .orderBy(desc(previewJobs.createdAt)) .limit(1); diff --git a/src/lib/server/stream-jobs.ts b/src/lib/server/stream-jobs.ts index 0f6b646..b1cd5d3 100644 --- a/src/lib/server/stream-jobs.ts +++ b/src/lib/server/stream-jobs.ts @@ -39,6 +39,13 @@ import { streamAssetStoredName, streamPlaylistName, } from "@/lib/server/stream-paths"; +import { resolveWithin } from "@/lib/security/path"; + +function safeFileExt(name: string) { + const ext = path.extname(name).toLowerCase(); + if (/^\.[a-z0-9]{1,16}$/i.test(ext)) return ext; + return ".bin"; +} export type StreamJobStatus = "queued" | "processing" | "ready" | "failed"; @@ -98,7 +105,10 @@ function resolveStreamJobLimits(requestedLimit?: number) { const envConcurrency = readPositiveInt(process.env.STREAM_JOBS_CONCURRENCY); const queueCap = Math.min(envQueue ?? defaultQueue, hardMax); const queueLimit = Math.min(requested, queueCap); - const concurrency = Math.min(queueLimit, envConcurrency ?? defaultConcurrency); + const concurrency = Math.min( + queueLimit, + envConcurrency ?? defaultConcurrency, + ); return { queueLimit, concurrency }; } @@ -130,8 +140,8 @@ async function runFfmpegHls(params: { ? Math.floor(segmentSeconds) : 2; - const segmentPattern = path.join(params.outputDir, "segment-%05d.ts"); - const playlistPath = path.join(params.outputDir, streamPlaylistName()); + const segmentPattern = resolveWithin(params.outputDir, "segment-%05d.ts"); + const playlistPath = resolveWithin(params.outputDir, streamPlaylistName()); const args: string[] = ["-y", "-i", params.inputPath]; @@ -227,7 +237,7 @@ async function uploadHlsOutput(params: { for (const entry of entries) { if (!entry.isFile()) continue; - const filePath = path.join(params.outputDir, entry.name); + const filePath = resolveWithin(params.outputDir, entry.name); const [stats, buffer] = await Promise.all([ stat(filePath), readFile(filePath), @@ -255,10 +265,12 @@ async function generateHls(params: { mimeType: string; quality: number; }) { - const tmpDir = await mkdtemp(path.join(os.tmpdir(), "swush-hls-")); - const inputExt = path.extname(params.storedName) || ".bin"; - const inputPath = path.join(tmpDir, `in-${Date.now()}${inputExt}`); - const outputDir = path.join(tmpDir, "out"); + const tmpDir = await mkdtemp( + `${path.resolve(os.tmpdir())}${path.sep}swush-hls-`, + ); + const inputExt = safeFileExt(params.storedName); + const inputPath = resolveWithin(tmpDir, `in-${Date.now()}${inputExt}`); + const outputDir = resolveWithin(tmpDir, "out"); await mkdir(outputDir, { recursive: true }); try { @@ -303,7 +315,10 @@ export async function enqueueStreamJob(input: StreamJobInput) { .select({ id: streamJobs.id, status: streamJobs.status }) .from(streamJobs) .where( - and(eq(streamJobs.userId, input.userId), eq(streamJobs.fileId, input.fileId)), + and( + eq(streamJobs.userId, input.userId), + eq(streamJobs.fileId, input.fileId), + ), ) .orderBy(desc(streamJobs.createdAt)) .limit(1); diff --git a/src/lib/server/yt-dlp.ts b/src/lib/server/yt-dlp.ts index 0772675..3eafacf 100644 --- a/src/lib/server/yt-dlp.ts +++ b/src/lib/server/yt-dlp.ts @@ -51,12 +51,19 @@ export type YtDlpResult = { sourceTitle?: string; }; +function sanitizeFileSegment(value: string) { + const safe = value.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64); + if (!safe) throw new Error("Invalid file segment"); + return safe; +} + export async function downloadWithYtDlp( url: string, prefix = "yt", onProgress?: (percent?: number) => void, ): Promise { const safeUrl = assertSafeExternalHttpUrl(url); + const safePrefix = sanitizeFileSegment(prefix); if (!checkYtDlpAvailable()) { throw new YtDlpNotFoundError( @@ -65,9 +72,10 @@ export async function downloadWithYtDlp( } const id = nanoid(); + const safeId = sanitizeFileSegment(id); const outTemplate = path.join( tmpdir(), - `${prefix}-${id}-%(title).200B.%(ext)s`, + `${safePrefix}-${safeId}-%(title).200B.%(ext)s`, ); return new Promise((resolve, reject) => { @@ -115,8 +123,8 @@ export async function downloadWithYtDlp( const files = await readdir(dir); const match = files.find( (f) => - f.startsWith(`${prefix}-${id}-`) || - f.startsWith(`${prefix}-${id}.`), + f.startsWith(`${safePrefix}-${safeId}-`) || + f.startsWith(`${safePrefix}-${safeId}.`), ); if (!match) return reject(new Error("yt-dlp did not write expected file")); @@ -124,7 +132,7 @@ export async function downloadWithYtDlp( const st = await stat(full); const ext = path.extname(match); const rawBase = ext ? match.slice(0, -ext.length) : match; - const titlePrefix = `${prefix}-${id}-`; + const titlePrefix = `${safePrefix}-${safeId}-`; const sourceTitle = rawBase.startsWith(titlePrefix) ? rawBase.slice(titlePrefix.length).trim() : undefined; diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 9b22c4a..c5c03a4 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -110,12 +110,27 @@ export function buildObjectKey({ userId, storedName }: StorageTarget) { } function safeJoin(base: string, target: string) { - const targetPath = path.join(base, target); - const resolved = path.resolve(targetPath); - if (!resolved.startsWith(path.resolve(base))) { + const normalizedBase = path.normalize(base); + const sanitized = target + .replace(/^[\\/]+/, "") + .split(/[\\/]+/) + .filter(Boolean) + .map((part) => { + if (part === "." || part === "..") { + throw new Error("Path traversal detected"); + } + return part; + }); + const normalized = path.normalize( + [normalizedBase, ...sanitized].join(path.sep), + ); + const basePrefix = normalizedBase.endsWith(path.sep) + ? normalizedBase + : `${normalizedBase}${path.sep}`; + if (normalized !== normalizedBase && !normalized.startsWith(basePrefix)) { throw new Error("Path traversal detected"); } - return resolved; + return normalized; } function buildLocalPath(root: string, target: StorageTarget) { diff --git a/tsconfig.json b/tsconfig.json index 67e2332..8857137 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "ES2017", - "lib": [ - "dom", - "dom.iterable", - "esnext", - ], + "lib": ["dom","dom.iterable","esnext"], "allowJs": true, "skipLibCheck": true, "strict": true,