Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .codacy/codacy.yaml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions .github/workflows/build-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 πŸ”
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
66 changes: 43 additions & 23 deletions src/components/Common/GlobalCommand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down
34 changes: 22 additions & 12 deletions src/components/Settings/ExportData.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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) {
Expand Down
41 changes: 27 additions & 14 deletions src/components/Settings/Integrations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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");
Expand All @@ -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();
Expand All @@ -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)),
Expand Down
Loading