Skip to content

Commit 26cb8f8

Browse files
committed
feat: (security) implement safe internal and external HTTP fetch functions
1 parent 3c22dd3 commit 26cb8f8

30 files changed

Lines changed: 778 additions & 397 deletions

.codacy/codacy.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
engines:
2+
eslint:
3+
enabled: true
4+
exclude:
5+
- node_modules/**
6+
- .next/**
7+
- dist/**
8+
- build/**
9+
runtimes:
10+
- dart@3.7.2
11+
- go@1.22.3
12+
- java@17.0.10
13+
- node@22.2.0
14+
- python@3.11.11
15+
tools:
16+
- dartanalyzer@3.7.2
17+
- eslint@8.57.0
18+
- lizard@1.17.31
19+
- pmd@7.11.0
20+
- pylint@3.3.6
21+
- revive@1.7.0
22+
- semgrep@1.78.0
23+
- trivy@0.66.0

.github/workflows/build-docker.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,15 @@ on:
44
push:
55
tags:
66
- "v*"
7+
pull_request:
8+
types:
9+
- closed
10+
branches:
11+
- main
712

813
jobs:
914
build:
15+
if: github.event_name != 'pull_request' || github.event.pull_request.merged == true
1016
runs-on: ubuntu-latest
1117
steps:
1218
- name: Check out the repo

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,35 @@ This project follows [Semantic Versioning](https://iconical.dev/versioning).
99

1010
✨ Nothing here yet; stay tuned for upcoming features and tweaks.
1111

12+
---
13+
14+
## v1.1.1 – URL Safety Patch 🔐
15+
16+
**Released: February 25, 2026**
17+
18+
### 🛡️ Security
19+
20+
- Fixed edge-case URL validation gaps so all external URLs are consistently sanitized before outbound requests.
21+
- Added centralized safe HTTP wrappers for:
22+
- internal API routes,
23+
- same-origin browser fetches,
24+
- externally validated HTTP(S) requests.
25+
- Updated multiple client/server request paths to use these safe wrappers for stronger static-analysis compliance.
26+
- Hardened AES-GCM decryption paths by enforcing explicit auth tag length checks.
27+
- Replaced dynamic regex and unsafe HTML assignment patterns in critical paths with safer parsing/DOM handling.
28+
29+
### 🗂️ Filesystem Safety
30+
31+
- Standardized traversal-safe path resolution and normalization across upload/export/preview/stream/storage flows.
32+
- Added shared reusable path helper logic to remove duplicated path-guard implementations.
33+
34+
### 🧹 Maintainability
35+
36+
- Reduced duplicated logic in bulk update flows and metadata parsing helpers.
37+
- Refactored language registration/detection internals to lower complexity and improve readability.
38+
- Consolidated repeated security/path handling patterns into reusable utilities.
39+
40+
1241
---
1342

1443
## v1.1.0 – URL Safety Hardening 🔐

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "swush",
3-
"version": "1.1.0",
3+
"version": "1.1.1",
44
"private": true,
55
"description": "Swush; A secure, self-hosted file sharing app with privacy-first features.",
66
"author": {

src/components/Common/GlobalCommand.tsx

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
CommandSeparator,
4040
} from "@/components/ui/command";
4141
import { apiV1, apiV1Path } from "@/lib/api-path";
42+
import { fetchSafeInternalApi } from "@/lib/security/http-client";
4243
import { cn } from "@/lib/utils";
4344
import { toast } from "sonner";
4445

@@ -427,7 +428,7 @@ export default function GlobalCommand() {
427428
abortRef.current = ac;
428429
setLoading(!cached);
429430
const searchPath = apiV1("/search");
430-
fetch(`${searchPath}?q=${encodeURIComponent(query)}`, {
431+
fetchSafeInternalApi(`${searchPath}?q=${encodeURIComponent(query)}`, {
431432
signal: ac.signal,
432433
})
433434
.then(async (r) => {
@@ -496,29 +497,36 @@ export default function GlobalCommand() {
496497

497498
const toggleFavorite = async (item: SearchItem) => {
498499
const nextValue = !getIsFavorite(item);
500+
const updateFavorite = async () => {
501+
if (item.type === "file") {
502+
if (!item.slug) throw new Error("Missing file slug");
503+
return fetchSafeInternalApi(
504+
apiV1Path("/files", item.slug, "favorite"),
505+
{
506+
method: "PATCH",
507+
},
508+
);
509+
}
510+
511+
const safeType = item.type
512+
.trim()
513+
.toLowerCase()
514+
.replace(/[^a-z0-9-]/g, "");
515+
if (!safeType) throw new Error("Unsupported item type");
516+
517+
return fetchSafeInternalApi(apiV1Path(`/${safeType}s`, item.id), {
518+
method: "PATCH",
519+
headers: { "Content-Type": "application/json" },
520+
body: JSON.stringify({ isFavorite: nextValue }),
521+
});
522+
};
523+
499524
const key = `${item.type}:${item.id}`;
500525
setActionLoading((prev) => ({ ...prev, [`fav:${key}`]: true }));
501526
setFavoriteOverrides((prev) => ({ ...prev, [key]: nextValue }));
502527
try {
503-
if (item.type === "file") {
504-
if (!item.slug) throw new Error("Missing file slug");
505-
const res = await fetch(apiV1Path("/files", item.slug, "favorite"), {
506-
method: "PATCH",
507-
});
508-
if (!res.ok) throw new Error("Failed to update favorite");
509-
} else {
510-
const safeType = item.type
511-
.trim()
512-
.toLowerCase()
513-
.replace(/[^a-z0-9-]/g, "");
514-
if (!safeType) throw new Error("Unsupported item type");
515-
const res = await fetch(apiV1Path(`/${safeType}s`, item.id), {
516-
method: "PATCH",
517-
headers: { "Content-Type": "application/json" },
518-
body: JSON.stringify({ isFavorite: nextValue }),
519-
});
520-
if (!res.ok) throw new Error("Failed to update favorite");
521-
}
528+
const res = await updateFavorite();
529+
if (!res.ok) throw new Error("Failed to update favorite");
522530
} catch {
523531
setFavoriteOverrides((prev) => ({ ...prev, [key]: !nextValue }));
524532
toast.error("Failed to update favorite");
@@ -557,9 +565,21 @@ export default function GlobalCommand() {
557565
const toggleTypeFilter = (type: string) => {
558566
if (typeTokens.length) {
559567
const token = `type:${type}`;
560-
const regex = new RegExp(`\\btype:${type}s?\\b`, "i");
561-
const next = regex.test(q)
562-
? q.replace(regex, " ").replace(/\s+/g, " ").trim()
568+
const lowerToken = token.toLowerCase();
569+
const lowerPluralToken = `${token}s`.toLowerCase();
570+
const words = q.split(/\s+/).filter(Boolean);
571+
const hasToken = words.some((word) => {
572+
const lowerWord = word.toLowerCase();
573+
return lowerWord === lowerToken || lowerWord === lowerPluralToken;
574+
});
575+
const next = hasToken
576+
? words
577+
.filter((word) => {
578+
const lowerWord = word.toLowerCase();
579+
return lowerWord !== lowerToken && lowerWord !== lowerPluralToken;
580+
})
581+
.join(" ")
582+
.trim()
563583
: `${q} ${token}`.trim();
564584
setQ(next);
565585
return;

src/components/Settings/ExportData.tsx

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
import { PaginationFooter } from "@/components/Shared/PaginationFooter";
3333
import { useCachedPagedList } from "@/hooks/use-cached-paged-list";
3434
import { apiV1, apiV1Path } from "@/lib/api-path";
35+
import { fetchSafeInternalApi } from "@/lib/security/http-client";
3536
import { toast } from "sonner";
3637

3738
type ExportItem = {
@@ -68,10 +69,13 @@ export default function ExportData() {
6869
qs.set("limit", String(pageSize));
6970
qs.set("offset", String((page - 1) * pageSize));
7071
const exportListPath = apiV1("/profile/export");
71-
const res = await fetch(`${exportListPath}?${qs.toString()}`, {
72-
cache: "no-store",
73-
credentials: "include",
74-
});
72+
const res = await fetchSafeInternalApi(
73+
`${exportListPath}?${qs.toString()}`,
74+
{
75+
cache: "no-store",
76+
credentials: "include",
77+
},
78+
);
7579
if (!res.ok) return null;
7680
const data = (await res.json()) as { items?: ExportItem[]; total?: number };
7781
return {
@@ -124,7 +128,7 @@ export default function ExportData() {
124128

125129
setCreating(true);
126130
try {
127-
const res = await fetch(apiV1("/profile/export"), {
131+
const res = await fetchSafeInternalApi(apiV1("/profile/export"), {
128132
method: "POST",
129133
headers: { "Content-Type": "application/json" },
130134
body: JSON.stringify(payload),
@@ -149,9 +153,12 @@ export default function ExportData() {
149153
const clearFailed = async () => {
150154
setClearingFailed(true);
151155
try {
152-
const res = await fetch(apiV1("/profile/export?scope=failed"), {
153-
method: "DELETE",
154-
});
156+
const res = await fetchSafeInternalApi(
157+
apiV1("/profile/export?scope=failed"),
158+
{
159+
method: "DELETE",
160+
},
161+
);
155162
if (!res.ok) {
156163
const body = await res.json().catch(() => ({}));
157164
throw new Error(body?.error || "Failed to clear exports");
@@ -171,9 +178,12 @@ export default function ExportData() {
171178
const deleteAllArchives = async () => {
172179
setDeletingAll(true);
173180
try {
174-
const res = await fetch(apiV1("/profile/export?scope=all"), {
175-
method: "DELETE",
176-
});
181+
const res = await fetchSafeInternalApi(
182+
apiV1("/profile/export?scope=all"),
183+
{
184+
method: "DELETE",
185+
},
186+
);
177187
if (!res.ok) {
178188
const body = await res.json().catch(() => ({}));
179189
throw new Error(body?.error || "Failed to delete archives");
@@ -193,7 +203,7 @@ export default function ExportData() {
193203
const deleteArchive = async (id: string) => {
194204
setDeletingId(id);
195205
try {
196-
const res = await fetch(apiV1Path("/profile/export", id), {
206+
const res = await fetchSafeInternalApi(apiV1Path("/profile/export", id), {
197207
method: "DELETE",
198208
});
199209
if (!res.ok) {

src/components/Settings/Integrations.tsx

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919

2020
import { useEffect, useMemo, useState } from "react";
2121
import { toast } from "sonner";
22-
import { apiV1, apiV1Path } from "@/lib/api-path";
22+
import { apiV1 } from "@/lib/api-path";
23+
import { fetchSafeInternalApi } from "@/lib/security/http-client";
2324
import { Button } from "@/components/ui/button";
2425
import { Input } from "@/components/ui/input";
2526
import { Badge } from "@/components/ui/badge";
@@ -92,7 +93,7 @@ export function Integrations() {
9293
const fetchWebhooks = async () => {
9394
setLoading(true);
9495
try {
95-
const res = await fetch(apiV1("/integrations/webhooks"), {
96+
const res = await fetchSafeInternalApi(apiV1("/integrations/webhooks"), {
9697
cache: "no-store",
9798
});
9899
if (!res.ok) throw new Error("Failed to load webhooks");
@@ -128,7 +129,7 @@ export function Integrations() {
128129
}
129130
setCreating(true);
130131
try {
131-
const res = await fetch(apiV1("/integrations/webhooks"), {
132+
const res = await fetchSafeInternalApi(apiV1("/integrations/webhooks"), {
132133
method: "POST",
133134
headers: { "Content-Type": "application/json" },
134135
body: JSON.stringify({
@@ -160,9 +161,13 @@ export function Integrations() {
160161

161162
const handleDelete = async (id: string) => {
162163
try {
163-
const res = await fetch(apiV1Path("/integrations/webhooks", id), {
164-
method: "DELETE",
165-
});
164+
const safeId = encodeURIComponent(id);
165+
const res = await fetchSafeInternalApi(
166+
apiV1(`/integrations/webhooks/${safeId}`),
167+
{
168+
method: "DELETE",
169+
},
170+
);
166171
if (!res.ok) throw new Error("Failed to delete webhook");
167172
setWebhooks((prev) => prev.filter((w) => w.id !== id));
168173
toast.success("Webhook deleted");
@@ -175,9 +180,13 @@ export function Integrations() {
175180

176181
const handleTest = async (id: string) => {
177182
try {
178-
const res = await fetch(apiV1Path("/integrations/webhooks", id, "test"), {
179-
method: "POST",
180-
});
183+
const safeId = encodeURIComponent(id);
184+
const res = await fetchSafeInternalApi(
185+
apiV1(`/integrations/webhooks/${safeId}/test`),
186+
{
187+
method: "POST",
188+
},
189+
);
181190
if (!res.ok) throw new Error("Test failed");
182191
toast.success("Test sent");
183192
await fetchWebhooks();
@@ -190,11 +199,15 @@ export function Integrations() {
190199

191200
const handleToggle = async (id: string, enabled: boolean) => {
192201
try {
193-
const res = await fetch(apiV1Path("/integrations/webhooks", id), {
194-
method: "PATCH",
195-
headers: { "Content-Type": "application/json" },
196-
body: JSON.stringify({ enabled }),
197-
});
202+
const safeId = encodeURIComponent(id);
203+
const res = await fetchSafeInternalApi(
204+
apiV1(`/integrations/webhooks/${safeId}`),
205+
{
206+
method: "PATCH",
207+
headers: { "Content-Type": "application/json" },
208+
body: JSON.stringify({ enabled }),
209+
},
210+
);
198211
if (!res.ok) throw new Error("Update failed");
199212
setWebhooks((prev) =>
200213
prev.map((w) => (w.id === id ? { ...w, enabled } : w)),

0 commit comments

Comments
 (0)