From 9b6d1b18eb968b6692868f108a0378221a836fff Mon Sep 17 00:00:00 2001 From: BadAtCaptchas <2359196+BadAtCaptchas@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:27:05 -0400 Subject: [PATCH] Fix federated attachment downloads allowing SSRF via redirects (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Backspace — initial public release * chore: pin Node 20 (LTS) and pnpm 10.34.3 Unpinned `pnpm@latest` in the Dockerfile made fresh builds non-reproducible: `latest` now resolves to pnpm 11, but the committed lockfile targets pnpm 10, so `pnpm install --frozen-lockfile` fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH on a clean host. Pin pnpm to 10.34.3 across the Dockerfile, the `packageManager` field, and the release workflow; pin Node to 20 (LTS) via `.nvmrc` and `engines` so Docker, CI, and from-source builds all use the same tested toolchain. Also corrects the docs: the lockfile is v9.0 (requires pnpm 10, not "pnpm 8+"), and "Node 20+" implied untested newer majors were supported. * fix(compose): fail fast on missing JWT_SECRET instead of a silent restart loop Without JWT_SECRET the server throws at boot (config.ts) and `restart: unless-stopped` turns that into an endless crash loop that is invisible unless you run `docker compose logs backspace`. Guard it exactly like DOMAIN already is, so `docker compose up` stops immediately with an actionable message. install.sh generates the secret before bringing the stack up, so this only affects the manual `cp .env.example .env && docker compose up` path. * docs(env): make the JWT_SECRET requirement explicit in .env.example Spell out that JWT_SECRET is required and must be a strong 32+ char value, and that the empty default is intentional (docker compose fails fast rather than the server boot-looping). Pairs with the compose-level guard added in 592c23f. * fix(desktop): don't let uiohook-napi rebuild abort the whole workspace install The @backspace/desktop postinstall runs `electron-rebuild -f -w uiohook-napi`, which compiles a native module from source and needs a C++ toolchain (make/g++/python3). On a bare host without those — a typical VPS a self-hoster clones onto — that failure took the ENTIRE `pnpm install` down (exit 255), so the server never installed either, even though uiohook-napi is desktop-only and irrelevant to hosting. Make the rebuild non-fatal: on failure it now prints a clear one-line warning and continues. Machines with build tools (CI, desktop devs) are byte-identical — the fallback never fires; only toolless boxes (which aren't building the desktop app anyway) skip it. Verified on a bare x86 box: full `pnpm install` now exits 0 and the server builds and boots from source. Docs note the build-tool requirement for desktop work and point self-hosters at the Docker installer. * feat(deploy): three deployment modes + prebuilt multi-arch image for robust self-hosting Make Backspace self-hostable in any homelab environment, not just a clean host that owns ports 80/443. install.sh is now mode-aware and auto-detects which fits: - allinone (default): bundled Caddy + auto-HTTPS — unchanged behavior - proxy: behind your own reverse proxy (nginx / Traefik / Caddy / Nginx Proxy Manager / SWAG) — app published on 127.0.0.1:APP_PORT, no bundled Caddy, prints paste-ready proxy snippets - tunnel: behind a tunnel (Cloudflare / Tailscale) — same, plus a 90MB upload cap (under Cloudflare's 100MB body limit) and voice force-disabled (WebRTC over UDP can't traverse a tunnel) Port detection is Docker-aware (consults `docker ps` published ports, not just `ss`), so a host whose proxy already owns 80/443 via iptables DNAT — with no listening socket for `ss` to see — is correctly detected as "taken" instead of dead-ending. docker-compose.proxy.yml is a small overlay, layered via COMPOSE_FILE (written into .env so no `-f` flags are ever needed), that publishes the loopback port and parks Caddy in an inert profile. The base compose file is untouched, so All-in-One behaves exactly as before. Prebuilt image: .github/workflows/docker-publish.yml builds and pushes a multi-arch (linux/amd64 + linux/arm64) image to ghcr.io/thezwiss/backspace on release tags (and manual dispatch), so weak/ARM hosts skip the ~1.6GB local build (the Vite build OOMs small ARM boxes). install.sh and docker-compose.yml default to pulling it, fall back to an image already present on the host, and finally to a from-source build — AGPL §13 commit stamping preserved on every path. Kept deliberately separate from the desktop-installer workflow (release.yml). Docs: README gains a "Deployment modes" section (all three modes, nginx / Caddy / Traefik snippets, GUI-proxy field-by-field, cloudflared ingress, the update path, and voice-per-mode caveats); docs/systems/deployment.md updated to match. Verified live on a throwaway VM: proxy + all-in-one end-to-end through install.sh (with a real Let's Encrypt cert), tunnel config generation, loopback-only binding, and the local-image fallback path. * Fix federation file download SSRF (#1) --------- Co-authored-by: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> --- packages/server/src/utils/federationWorker.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/server/src/utils/federationWorker.ts b/packages/server/src/utils/federationWorker.ts index 58cc40ac..d2a359cb 100644 --- a/packages/server/src/utils/federationWorker.ts +++ b/packages/server/src/utils/federationWorker.ts @@ -10,6 +10,7 @@ import { generateSnowflake } from './snowflake.js'; import { getDmMessageWithUser } from '../routes/dm.js'; import { connectionManager } from '../ws/handler.js'; import { generateThumbnail } from './thumbnail.js'; +import { safeFetch } from './ssrf.js'; import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent } from '@backspace/shared'; import { startupBootstrapSync, onPeerDeactivated } from './federationPeerActivation.js'; import { probePeerReachable, recoverOrDetectReset, detectResetOnNeedsAttentionPeers, detectResetForPeer } from './federationRecovery.js'; @@ -840,7 +841,8 @@ async function processFileQueueEntry( maxUploadSize: number, now: number, ): Promise { - // SSRF protection: validate sourceUrl hostname matches peerOrigin hostname + // SSRF protection: sourceUrl must start at the peer host; safeFetch below + // re-validates DNS and every redirect hop before downloading bytes. try { const sourceHostname = new URL(entry.sourceUrl).hostname; const peerHostname = new URL(entry.peerOrigin).hostname; @@ -885,7 +887,7 @@ async function processFileQueueEntry( fileQueueAbortController = new AbortController(); try { - const response = await fetch(entry.sourceUrl, { + const response = await safeFetch(entry.sourceUrl, { signal: AbortSignal.any([ fileQueueAbortController.signal, AbortSignal.timeout(FILE_DOWNLOAD_TIMEOUT_MS),