diff --git a/.env.example b/.env.example index 240cb26..51f384a 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,12 @@ SESSION_TTL=604800 # If this starts with https, the login cookie is marked Secure. BASE_URL=http://localhost:5000 +# Set this ONLY when running behind a reverse proxy (nginx, Traefik, Cloudflare) +# so login rate-limiting sees the real client IP and the Secure cookie is +# detected. Use the number of proxy hops in front of the app (usually 1), or +# true. Leave unset when exposed directly, so X-Forwarded-For can't be spoofed. +# TRUST_PROXY=1 + # --- Background checks & notifications (all optional; also editable in the UI) --- # Whether the server checks for updates on a schedule. Default: true. # BACKGROUND_CHECK_ENABLED=true diff --git a/API_CONTRACT.md b/API_CONTRACT.md index 2275eb2..8fc3138 100644 --- a/API_CONTRACT.md +++ b/API_CONTRACT.md @@ -115,6 +115,12 @@ All request/response bodies are JSON unless noted otherwise. - Query params: `limit` (default `50`), `offset` (default `0`). - Response: same shape as `GET /api/history`, filtered to that container. +### `DELETE /api/history` + +- Auth: cookie. +- Deletes **all** update-history rows. +- Response: `200` — `{ "ok": true }`. + ### `GET /api/pinned` - Auth: cookie. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d0b4a0c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,50 @@ +# Contributing / Development + +## Local dev (two terminals) + +```bash +# Terminal 1 — API on :5000 +cd server +npm install +# provide the required env vars (or SKIP_CONFIG_CHECK=1 for a no-secrets boot) +ADMIN_PASSWORD=dev SESSION_SECRET=dev DATA_DIR=./.data npm start + +# Terminal 2 — Vite dev server on :5173 (proxies /api to :5000) +cd client +npm install +npm run dev +``` + +Open `http://localhost:5173`. Without a Docker daemon, `/api/containers` returns +`503` (expected), but auth, history, pins, settings, and the UI all work. + +## Tests & build + +```bash +cd server && npm test # node --test (reconcile, containers-service, auth, registry, urlguard, …) +cd client && npm run build # production bundle -> client/dist/ (includes the PWA service worker) +``` + +## Build the production image + +The build context must be the repo root: + +```bash +docker build -f server/Dockerfile -t dockpull . +``` + +## Project layout + +- `server/` — Express API. Talks to the Docker socket (`dockerode` + `docker compose` + via `spawn`, never a shell string), checks registries, stores state in SQLite + (`better-sqlite3`). Entry point `server/src/index.js`. +- `client/` — React + Vite SPA (mobile-first, installable PWA). Same-origin `/api`. +- `API_CONTRACT.md` — the authoritative endpoint/field reference. Keep it in sync + with route changes. +- `SECURITY.md` — threat model and operator hardening guidance. + +## Images / releases + +`:edge` is published from `main`. Cutting a release tag +(`git tag v0.1.0 && git push origin v0.1.0`) publishes the multi-arch +(`linux/amd64` + `linux/arm64`) image as `:latest` and semver tags. diff --git a/README.md b/README.md index 54a5e79..056c8d4 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,19 @@ # DockPull -A small, self-hosted, mobile-first web UI for updating Docker containers that -are managed by `docker compose` (e.g. via [Dockge](https://github.com/louislam/dockge)). -It checks your images' registries for newer versions and lets you apply updates -with one tap — **manually, never automatically** (no watchtower-style surprise -upgrades). +A small, self-hosted, **mobile-first** web UI for updating your `docker compose` +containers (works great with [Dockge](https://github.com/louislam/dockge)). It +checks your images' registries for newer versions and lets you apply updates +with one tap — **manually, never automatically**. No watchtower-style surprise +upgrades, and no Diun or external notifier required. -It exists to replace this workflow: *something pings you that an update exists → -you open Dockge on your phone (which is awkward on mobile) → you cross-reference -which stack to update → you click update.* Instead you get one screen that lists -your containers grouped by stack, shows which have updates, and updates them with -one tap. - -> **Self-contained.** This app talks to your Docker socket and queries image -> registries directly. It does **not** require Diun (or any external notifier). - -![one card per container, grouped by stack, badge when an update is available, tap Update to pull + recreate] - ---- - -## Contents - -- [How it works](#how-it-works) -- [Requirements](#requirements) -- [Quick start](#quick-start) -- [Add to an existing Compose stack](#add-to-an-existing-compose-stack) -- [Step-by-step setup](#step-by-step-setup) - - [1. Get the code onto your server](#1-get-the-code-onto-your-server) - - [2. Create your `.env`](#2-create-your-env) - - [3. Configure the compose file (the same-path mount)](#3-configure-the-compose-file-the-same-path-mount) - - [4. Build and start](#4-build-and-start) - - [5. (Optional) Expose it with a Cloudflare Tunnel](#5-optional-expose-it-with-a-cloudflare-tunnel) -- [Using the app](#using-the-app) -- [Configuration reference](#configuration-reference) -- [Security notes](#security-notes-read-this) -- [Troubleshooting](#troubleshooting) -- [Known limitations](#known-limitations) -- [Development](#development) - ---- - -## How it works - -1. **You open the app and it checks.** On load (and whenever you tap **Check for - updates**), the app lists your containers from the Docker socket, reads each - one's **currently-running image digest**, and asks each image's registry for - the current digest of its tag — without pulling anything. Anything whose - registry digest differs from what's running is flagged **Update available**. -2. **Live Docker state is the source of truth.** The badge is self-correcting: - if you update a container elsewhere (e.g. Dockge), the badge clears on the - next check, because the app always reconciles against the digest that's - actually running. -3. **You click Update.** The app runs `docker compose pull` then - `docker compose up -d` for that one service, using the compose file recorded - in the container's own labels, and streams the live output back to your - browser. On success it records the update in history and clears the badge. - **There is no auto-update** — nothing changes until you tap Update. - -The full endpoint/field reference is in [`API_CONTRACT.md`](./API_CONTRACT.md). - ---- - -## Requirements - -- A Linux host with **Docker** and the **`docker compose` v2** plugin. -- Your stacks are managed by `docker compose` (Dockge counts — it's compose - under the hood). Containers must carry the standard `com.docker.compose.*` - labels, which compose adds automatically. -- Your compose stacks live in one directory on the host (Dockge's default is - `/opt/stacks`). - -> **One hard rule:** the stacks directory must be bind-mounted into this -> container at the **same absolute path** it has on the host. See -> [step 3](#3-configure-the-compose-file-the-same-path-mount) and -> [security notes](#security-notes-read-this) for why. +One screen lists your containers grouped by stack, shows which have updates, and +updates them with a tap. --- ## Quick start -```bash -# on your Docker host -git clone dockpull && cd dockpull -cp .env.example .env - -# fill in .env: -openssl rand -hex 32 # -> paste as SESSION_SECRET -# set ADMIN_PASSWORD to something strong -# set STACKS_DIR to the absolute host path of your compose stacks (e.g. /opt/stacks) - -docker compose up -d --build -``` - -Then open `http://:5000` and log in with `ADMIN_PASSWORD`. - ---- - -## Add to an existing Compose stack - -If you already manage stacks with Docker Compose (or Dockge), the quickest path -is the **prebuilt image** — no cloning, no building. Drop this service into an -existing compose file (e.g. a `management` stack) and fill in the two secrets. -**Set both the `STACKS_DIR` env and the stacks volume to your real stacks path** -(Dockge users: `/opt/stacks`): +Drop this into a compose file on your Docker host and start it: ```yaml services: @@ -114,321 +25,116 @@ services: - "5000:5000" environment: - ADMIN_PASSWORD=change-me # your login password - - SESSION_SECRET=REPLACE_ME # openssl rand -hex 32 + - SESSION_SECRET=REPLACE_ME # run: openssl rand -hex 32 - STACKS_DIR=/opt/stacks # absolute host path to your stacks volumes: - /var/run/docker.sock:/var/run/docker.sock - # ⚠️ SAME absolute path on the host AND inside the container. This MUST - # match STACKS_DIR above, or updates fail with "compose file not found" - # and relative bind mounts in your other stacks break on recreate. - - /opt/stacks:/opt/stacks + - /opt/stacks:/opt/stacks # ⚠️ SAME path on host AND container (see below) - dockpull-data:/data volumes: dockpull-data: ``` -Generate the secret (`openssl rand -hex 32`), then start just this service: - ```bash -docker compose up -d dockpull +docker compose up -d ``` -Then open `http://:5000`. - -**Image tags:** `:edge` tracks the latest commit on `main`; cutting a release -tag (`git tag v0.1.0 && git push origin v0.1.0`) also publishes `:latest` and -semver tags (`:0.1.0`, `:0.1`). Pin to a version for stability. +Then open `http://:5000` and log in with your `ADMIN_PASSWORD`. That's it — +the dashboard runs an update check automatically on first load. -> **Can't pull the image?** The GHCR package inherits the repo's visibility. To -> let other hosts pull it without auth, make the package public: GitHub → your -> avatar → **Packages** → `dockpull` → **Package settings** → **Change -> visibility** → *Public*. Otherwise run `docker login ghcr.io` (with a PAT that -> has `read:packages`) on each host first. - -The [same-path mount](#3-configure-the-compose-file-the-same-path-mount) and -[Docker-socket](#security-notes-read-this) warnings apply here too. Prefer to -build from source? Use [Step-by-step setup](#step-by-step-setup) below instead. +> **Building from source instead?** `git clone` this repo, `cp .env.example .env`, +> fill in the three values above, and run `docker compose up -d --build`. --- -## Step-by-step setup - -### 1. Get the code onto your server - -```bash -git clone dockpull -cd dockpull -``` - -### 2. Create your `.env` - -```bash -cp .env.example .env -``` - -Edit `.env` and set the required values: - -```ini -# --- required --- -ADMIN_PASSWORD=choose-a-strong-password -SESSION_SECRET= - -# Absolute host path of your compose stacks (Dockge users: /opt/stacks) -STACKS_DIR=/opt/stacks - -# --- optional (defaults shown) --- -PORT=5000 -DOCKER_SOCKET=/var/run/docker.sock -DATA_DIR=/data -SESSION_TTL=604800 -BASE_URL=http://localhost:5000 # set to your real https URL if behind a tunnel/proxy -``` - -> If `BASE_URL` starts with `https`, the login cookie is marked `Secure` (only -> sent over HTTPS). Keep it `http://...` for plain LAN access; set it to your -> `https://...` hostname when serving over a tunnel/reverse proxy. - -### 3. Configure the compose file (the same-path mount) - -The provided [`docker-compose.yml`](./docker-compose.yml) is ready to use. The -critical part is the **same-path stacks mount**: - -```yaml - volumes: - - /var/run/docker.sock:/var/run/docker.sock - # ⚠️ SAME PATH on host and in container — do not change one side only: - - ${STACKS_DIR}:${STACKS_DIR} - - dockpull-data:/data # persistent SQLite (events/history/pins) -``` - -**Why same-path?** This app calls `docker compose` against the *host* Docker -daemon over the socket, but the `docker compose` CLI reads the compose file from -*this container's* filesystem, and the daemon resolves relative paths in your -stacks' compose files (volumes like `./data:/data`, build contexts, `env_file`) -against the path it sees on the host. If this container saw the stacks at -`/stacks` but the host has them at `/opt/stacks`, the CLI couldn't find the -compose file (you'd get `open /opt/stacks//compose.yaml: no such file or -directory`) and any relative bind mount would resolve to a non-existent host -path. Mounting at the identical path keeps both correct. (This is the same -constraint Dockge imposes, for the same reason.) - -> If the stacks dir isn't mounted at the right path, the app shows a warning -> banner at the top of the dashboard so you can fix it before an update fails. - -### 4. Build and start - -```bash -docker compose up -d --build -``` - -Check it's healthy: - -```bash -curl -s http://localhost:5000/api/health # -> {"ok":true} -docker logs dockpull # -> "...server listening at ..." -``` - -The SQLite database is created automatically in the `dockpull-data` volume -on first start. The first time you load the UI you'll get the login screen — -enter `ADMIN_PASSWORD`, and the dashboard will run an initial update check. +## ⚠️ The one rule: same-path stacks mount -> **Prefer a prebuilt image?** Releases publish a multi-arch image -> (`linux/amd64` + `linux/arm64`) to GHCR. Instead of `build:`, point the -> compose service at `image: ghcr.io/strandedturtle/dockpull:edge` (keep the -> same environment + volumes) and `docker compose up -d` (no `--build`). - -### 5. (Optional) Expose it with a Cloudflare Tunnel - -Add a hostname to your tunnel config pointing at the app, then set -`BASE_URL=https://updates.example.org` in `.env` and restart so the login cookie -is issued with `Secure`: +Your stacks directory **must be bind-mounted at the same absolute path on the host +and inside the container**, and `STACKS_DIR` must equal that path: ```yaml -- hostname: updates.example.org - service: http://localhost:5000 +- /opt/stacks:/opt/stacks # host path : identical container path ``` -For extra safety you can also put **Cloudflare Access** in front of it. +Why: DockPull runs `docker compose` against the host daemon, but the compose CLI +reads the compose file from *this container's* filesystem, and the daemon resolves +relative paths (`./data:/data`, build contexts, `env_file`) against the host path. +If the paths don't match you'll get `compose file not found` and broken bind mounts. +(Dockge imposes the same rule, for the same reason.) Dockge's default is +`/opt/stacks`. DockPull shows a banner if it detects the mount is wrong. --- ## Using the app -Open `http://:5000` (or your tunnel URL) and log in with `ADMIN_PASSWORD`. - -**Updates tab (home).** Containers are **grouped by stack** (their compose -project / Dockge folder) in collapsible sections, with anything that has an -update sorted to the top. By default the list shows **only containers that need -an update**; flip the filter to **All** to see everything. - -- When you open the app it automatically runs a check. **Check for updates** - re-runs it on demand (queries each image's registry for a newer digest). -- Each card shows the image, its **version** (from the image's - `org.opencontainers.image.version` label when present, otherwise its tag), and - whether an update is available. -- Tap **Update** to pull the new image and recreate that service. Tap **Show - logs** to watch the live `docker compose pull` / `up -d` output. When it - finishes you get a success/error message and the badge clears. -- **Update all** runs every eligible container one at a time (a failure on one - doesn't stop the rest). -- The dashboard **updates itself live** — when a check runs or an update - finishes, the list refreshes automatically. -- **Pin Version** holds a container at its current version (it stops being - flagged for updates and moves to a separate section). You can still update it - manually. - -**History tab.** A log of past updates (container, image, old→new digest, -success/failure, relative time). Tap a row to expand; "Load more" pages older -entries. - -**Settings tab.** Theme (dark/light), pinned-version management, and a -server-health indicator. - -**Install as a mobile app (PWA).** In your phone's browser, use "Add to Home -Screen". It installs as a standalone, full-screen app with an icon. - -### About the update check - -The check queries registries directly for each running image's current digest -and flags anything out of date. It supports registries reachable **anonymously** -over the standard token flow — Docker Hub, GHCR, lscr.io, quay.io, etc. for -public images. Private images that require credentials are skipped (counted -under `errors`). - -### Background checks & Discord notifications - -By default the server runs a daily scan (09:00, server-local time) so badges stay fresh -even when the app is closed. Configure it under **Settings → Background checks & -Discord**: - -- **Daily scan** on/off and the time of day it runs. -- **Discord webhook URL** — paste a Discord channel webhook to get a message when - updates are found, then use **Send test message** to verify it. Each update is - announced once (no repeats on every check). - -These can also be seeded from the environment (`BACKGROUND_CHECK_ENABLED`, -`SCHEDULED_CHECK_TIME`, `DISCORD_WEBHOOK_URL`); the Settings UI overrides at -runtime. +- **Updates tab** — containers grouped by stack, update-available ones on top. + Defaults to showing only what needs updating; flip to **All** to see everything. + Tap **Update** to pull + recreate that service (watch live logs), or **Update all** + to run them one at a time. **Pin Version** holds a container at its current version. +- **History tab** — a log of past updates. **Clear history** wipes it (with a confirm). +- **Settings tab** — theme, default view, auto-check on open, the **daily background + scan** + **Discord webhook** (with a "send test" button), and pinned-version + management. +- **Install as an app (PWA)** — use your browser's "Add to Home Screen" / "Install" + to get a standalone, full-screen icon. + +The update check queries registries directly (Docker Hub, GHCR, lscr.io, quay.io, …) +for **public** images reachable anonymously. Private images that need credentials are +skipped. --- -## Configuration reference +## Configuration -All configuration is via environment variables (see `.env.example`). +All config is via environment variables (see [`.env.example`](./.env.example)). | Var | Default | Required | Notes | |---|---|---|---| | `ADMIN_PASSWORD` | — | ✅ | Single shared login password. | | `SESSION_SECRET` | — | ✅ | Signs the session cookie. `openssl rand -hex 32`. | -| `STACKS_DIR` | `/stacks` | ✅ (effectively) | Host path of your compose stacks. **Must be mounted at the identical path in the container.** | +| `STACKS_DIR` | `/stacks` | ✅ | Host path of your stacks; **mount it at the identical path**. | | `PORT` | `5000` | | Server listen port. | -| `DOCKER_SOCKET` | `/var/run/docker.sock` | | Docker socket path. | -| `DATA_DIR` | `/data` | | SQLite (`app.db`) location; persist via a volume. | +| `DATA_DIR` | `/data` | | SQLite location; persist via a volume. | | `SESSION_TTL` | `604800` | | Login cookie lifetime in seconds (7 days). | | `BASE_URL` | `http://localhost:5000` | | Public URL; if `https`, the cookie is set `Secure`. | -| `DISCORD_WEBHOOK_URL` | — | | Discord webhook for update notifications (optional; also set in Settings). | -| `SCHEDULED_CHECK_TIME` | `09:00` | | Daily local time (HH:MM) for the scheduled scan. | -| `BACKGROUND_CHECK_ENABLED` | `true` | | Whether the scheduled background check runs. | -| `SELF_CONTAINER_NAME` | `dockpull` | | This app's own container name, excluded from the dashboard so it can't update itself. | +| `TRUST_PROXY` | _off_ | | Set (e.g. `1`) when behind a reverse proxy so rate-limiting sees real client IPs. | +| `DISCORD_WEBHOOK_URL` | — | | Discord webhook for notifications (also editable in Settings). | +| `SCHEDULED_CHECK_TIME` | `09:00` | | Daily local time (HH:MM) for the background scan. | +| `BACKGROUND_CHECK_ENABLED` | `true` | | Whether the scheduled scan runs. | +| `SELF_CONTAINER_NAME` | `dockpull` | | This app's container, excluded so it can't update itself. | -The two required vars are enforced at startup — the server refuses to boot -without them (a `SKIP_CONFIG_CHECK=1` escape hatch exists for skeleton -smoke-tests only; never use it in production). +The two required vars are enforced at startup — the server won't boot without them. --- -## Security notes (read this) - -- **Docker socket access is root-equivalent.** Mounting `/var/run/docker.sock` - gives this app full control over every container, image, network, and volume - on the host — effectively root on the host. Run it only on hosts you trust, - keep it on an internal network, and keep it behind the login (and ideally a - reverse proxy with TLS or Cloudflare Access). Mounting the socket `:ro` does - **not** restrict this — `:ro` only makes the socket *file* read-only; the - Docker API still allows writes. -- **Auth** is a single password compared in constant time, issuing a signed, - `httpOnly`, `SameSite=Lax` cookie (`Secure` when `BASE_URL` is https). Failed - logins are rate-limited per client IP (lockout after repeated failures) to - blunt brute-force — but this is not a substitute for keeping the app off the - open internet or fronting it with Cloudflare Access if exposure matters. -- **The app excludes its own container** from the dashboard (it can't safely - update itself). Update the updater the normal way: - `docker compose pull dockpull && docker compose up -d dockpull`. +## Security ---- - -## Troubleshooting +DockPull mounts the Docker socket, which is **root-equivalent on the host**. Run it +on a **trusted network behind its login** — don't expose it raw to the internet. It +ships with a constant-time password check, per-IP login lockout, SSRF-guarded +webhooks, and security headers. See **[SECURITY.md](./SECURITY.md)** for the threat +model and hardening tips (HTTPS/`BASE_URL`, `TRUST_PROXY`, `SESSION_TTL`). -**Update fails with `compose file not found` / `no such file or directory`.** -This is the **same-path mount**. The `docker compose` CLI runs inside this -container and reads your compose file from this container's filesystem, so your -stacks dir must be mounted at the identical absolute path on both sides -(`${STACKS_DIR}:${STACKS_DIR}`), and `STACKS_DIR` must match where your compose -files actually live on the host (Dockge: `/opt/stacks`). The dashboard shows a -warning banner when it detects the stacks dir isn't mounted. - -**`GET /api/containers` returns 503 `docker_unavailable`.** The app can't reach -the Docker daemon. Check the socket is mounted (`/var/run/docker.sock`) and the -path matches `DOCKER_SOCKET`. - -**A check reports updates under `errors` / some images never flag.** Those -images are on registries that need credentials (private images), which the -anonymous check can't query. Public images on Docker Hub / GHCR / lscr.io / -quay.io work. - -**Badge won't clear after a successful update.** A successful update resolves the -pending event automatically (this also covers multi-arch images, where the -registry digest and the running digest legitimately differ). If a badge sticks, -tap **Check for updates** again; if it persists, there may be a genuinely newer -image — check the History tab and `docker logs dockpull`. - -**Can't log in / cookie not sticking.** If you're on `https`, make sure -`BASE_URL` is your `https://` URL (otherwise the `Secure` cookie won't be set -appropriately). Clear the cookie and retry. +To update DockPull itself: `docker compose pull dockpull && docker compose up -d dockpull`. --- -## Known limitations +## Troubleshooting -- **The update check needs registries reachable anonymously.** Private images - that require credentials are skipped for now. -- **Standalone (non-compose) containers** are updated on a best-effort basis - (pull + recreate preserving config); compose-managed containers are the - supported path and what you should rely on. -- **Stopped containers** are listed too; updating one will start it. +- **`compose file not found` on update** → the [same-path mount](#️-the-one-rule-same-path-stacks-mount). + `STACKS_DIR` must match the host path *and* the container mount path. +- **`503 docker_unavailable`** → the app can't reach the Docker daemon; check the + socket is mounted and `DOCKER_SOCKET` matches. +- **Some images never flag / show under `errors`** → they're private and need + credentials; the anonymous check can't query them. +- **Can't log in / cookie not sticking** → on HTTPS, set `BASE_URL` to your `https://` + URL so the `Secure` cookie is issued; clear the cookie and retry. +- **Image tags:** `:edge` tracks `main`; release tags also publish `:latest` and semver + (`:0.1.0`). If a host can't pull, the GHCR package may be private — make it public or + `docker login ghcr.io`. --- -## Development - -Run the server and client separately (two terminals): - -```bash -# Terminal 1 — API on :5000 -cd server -npm install -# provide the required env vars (or SKIP_CONFIG_CHECK=1 for a no-secrets boot) -ADMIN_PASSWORD=dev SESSION_SECRET=dev DATA_DIR=./.data npm start - -# Terminal 2 — Vite dev server on :5173 (proxies /api to :5000) -cd client -npm install -npm run dev -``` - -Open `http://localhost:5173`. Without a Docker daemon, `/api/containers` returns -`503` (expected) but auth, history, pins, and the UI all work. - -Run the server test suite and build the client: - -```bash -cd server && npm test # node --test (reconcile, containers-service, auth, registry) -cd client && npm run build # production bundle -> client/dist/ -``` - -Build the production image manually (build context must be the repo root): - -```bash -docker build -f server/Dockerfile -t dockpull . -``` +Endpoint/field reference: [`API_CONTRACT.md`](./API_CONTRACT.md) · +Development setup: [`CONTRIBUTING.md`](./CONTRIBUTING.md) · License: MIT. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f3aae66 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,50 @@ +# Security + +## Threat model + +DockPull mounts the host **Docker socket** (`/var/run/docker.sock`). Anything that +can reach the Docker socket is effectively **root on the host** — it can start +privileged containers, mount the host filesystem, and read other containers' +secrets. Treat DockPull as a root-equivalent admin tool. + +DockPull is built for a **trusted LAN / homelab** behind authentication. It is +**not** hardened to be exposed directly to the public internet. + +## What DockPull does to protect you + +- **Single-password login** with a signed, `httpOnly`, `SameSite=Lax` session + cookie. The password is compared in **constant time**. +- **Login rate-limiting / lockout** per client IP (10 failures → 15-minute + lockout) to blunt brute-force. +- **All `/api/*` routes require the session cookie** (only `GET /api/health`, + login, and `me` are public). +- **No shell interpolation.** Docker actions run via `spawn(..., {shell:false})` + with argument arrays — never a shell string — so container/label values can't + inject commands. +- **Parameterized SQL** (better-sqlite3 prepared statements). +- **SSRF-guarded webhooks.** The Discord webhook URL must be `https` to a public + host; loopback/private/link-local/metadata addresses are rejected, including + hostnames that *resolve* to a private address. +- **Security headers** on every response: `Content-Security-Policy`, + `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, + `Cross-Origin-Opener-Policy`, and `Strict-Transport-Security` when served over + https. + +## Recommendations for operators + +- **Keep it off the open internet.** Put it on your LAN, a VPN (WireGuard / + Tailscale), or behind an authenticating reverse proxy / tunnel. +- **Use a strong `ADMIN_PASSWORD`** and a random `SESSION_SECRET` + (`openssl rand -hex 32`). +- **Serve it over HTTPS** (set `BASE_URL=https://…`) so the session cookie gets + the `Secure` flag and HSTS is sent. +- **Behind a reverse proxy?** Set `TRUST_PROXY` (e.g. `TRUST_PROXY=1`) so login + rate-limiting sees real client IPs and the `Secure` cookie is detected. Leave + it unset when exposed directly, so `X-Forwarded-For` can't be spoofed. +- **Shorten the session** if you like: `SESSION_TTL` defaults to 7 days + (604800s); lower it (e.g. `86400` = 1 day) for a tighter window. + +## Reporting a vulnerability + +Please open a private report via GitHub Security Advisories, or email the +maintainer rather than filing a public issue. We'll respond as soon as we can. diff --git a/client/package-lock.json b/client/package-lock.json index 72d3573..129e491 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,13 @@ { - "name": "diun-updater-client", + "name": "dockpull-client", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "diun-updater-client", + "name": "dockpull-client", "version": "0.1.0", + "license": "MIT", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/client/src/App.jsx b/client/src/App.jsx index 8b95130..ecc1a26 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { Routes, Route, Navigate } from 'react-router-dom'; -import { getMe } from './api.js'; +import { Routes, Route, Navigate, useNavigate } from 'react-router-dom'; +import { getMe, setUnauthorizedHandler } from './api.js'; import { useTheme } from './hooks/useTheme.js'; import AuthPage from './AuthPage.jsx'; import Dashboard from './Dashboard.jsx'; @@ -13,6 +13,7 @@ export default function App() { // Initialized at the app level so `data-theme` is set on from the // first paint, before any route-specific component mounts. useTheme(); + const navigate = useNavigate(); const [loading, setLoading] = useState(true); const [authenticated, setAuthenticated] = useState(false); @@ -32,9 +33,17 @@ export default function App() { checkSession().finally(() => setLoading(false)); }, [checkSession]); + // If any authenticated request 401s (session expired mid-use), drop straight + // back to the sign-in gate instead of stranding the user on a broken page. + useEffect(() => { + setUnauthorizedHandler(() => setAuthenticated(false)); + return () => setUnauthorizedHandler(null); + }, []); + const handleAuthed = useCallback(() => { checkSession(); - }, [checkSession]); + navigate('/'); // land on the dashboard after signing in + }, [checkSession, navigate]); const handleLoggedOut = useCallback(() => { setAuthenticated(false); diff --git a/client/src/api.js b/client/src/api.js index 29c9244..ba51bd0 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -23,6 +23,17 @@ async function parseBody(res) { } } +// Global handler invoked when an authenticated request comes back 401 (an +// expired/cleared session mid-use). App registers this to drop the user back +// to the sign-in gate. Auth endpoints are excluded below so a wrong-password +// login (also 401) doesn't trigger it. +let onUnauthorized = null; +export function setUnauthorizedHandler(fn) { + onUnauthorized = fn; +} + +const AUTH_PATHS = ['/auth/login', '/auth/me']; + async function request(method, path, body) { const res = await fetch(`${BASE}${path}`, { method, @@ -34,6 +45,10 @@ async function request(method, path, body) { const data = await parseBody(res); if (!res.ok) { + if (res.status === 401 && !AUTH_PATHS.some((p) => path.startsWith(p))) { + // Session is gone — bounce back to the login gate. + if (onUnauthorized) onUnauthorized(); + } const errMessage = (data && typeof data === 'object' && data.error) || (typeof data === 'string' && data) || @@ -97,6 +112,10 @@ export function getHistory(params = {}) { return get(`/history${qs ? `?${qs}` : ''}`); } +export function clearHistory() { + return del('/history'); +} + // --- Pinning --- export function getPinned() { diff --git a/client/src/components/ConfirmDialog.jsx b/client/src/components/ConfirmDialog.jsx new file mode 100644 index 0000000..e4af59f --- /dev/null +++ b/client/src/components/ConfirmDialog.jsx @@ -0,0 +1,74 @@ +import React, { useEffect, useRef } from 'react'; + +/** + * Minimal accessible confirm dialog for destructive actions. Renders an overlay + * with a title, message, and Cancel / Confirm buttons. Escape or an overlay + * click cancels; the confirm button is focused on open. + * + * @param {{ + * title: string, + * message?: string, + * confirmLabel?: string, + * cancelLabel?: string, + * confirming?: boolean, + * onConfirm: () => void, + * onCancel: () => void, + * }} props + */ +export default function ConfirmDialog({ + title, + message, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + confirming = false, + onConfirm, + onCancel, +}) { + const confirmRef = useRef(null); + + useEffect(() => { + confirmRef.current?.focus(); + const onKey = (e) => { + if (e.key === 'Escape' && !confirming) onCancel(); + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [onCancel, confirming]); + + return ( +
{ + if (!confirming) onCancel(); + }} + > +
e.stopPropagation()} + > +

+ {title} +

+ {message &&

{message}

} +
+ + +
+
+
+ ); +} diff --git a/client/src/pages/HistoryPage.jsx b/client/src/pages/HistoryPage.jsx index e48a02a..5f4723a 100644 --- a/client/src/pages/HistoryPage.jsx +++ b/client/src/pages/HistoryPage.jsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { getHistory } from '../api.js'; +import { getHistory, clearHistory } from '../api.js'; import HistoryRow from '../components/HistoryRow.jsx'; +import ConfirmDialog from '../components/ConfirmDialog.jsx'; const PAGE_LIMIT = 50; @@ -12,6 +13,8 @@ export default function HistoryPage() { const [offset, setOffset] = useState(0); const [hasMore, setHasMore] = useState(true); const [filter, setFilter] = useState(''); + const [confirmClear, setConfirmClear] = useState(false); + const [clearing, setClearing] = useState(false); const loadFirstPage = useCallback(async () => { setError(''); @@ -53,6 +56,23 @@ export default function HistoryPage() { loadFirstPage().finally(() => setLoading(false)); }, [loadFirstPage]); + const handleClear = useCallback(async () => { + setClearing(true); + setError(''); + try { + await clearHistory(); + setRows([]); + setOffset(0); + setHasMore(false); + setConfirmClear(false); + } catch (err) { + setError(err.message || 'Failed to clear history'); + setConfirmClear(false); + } finally { + setClearing(false); + } + }, []); + const filteredRows = useMemo(() => { const needle = filter.trim().toLowerCase(); if (!needle) return rows; @@ -68,6 +88,14 @@ export default function HistoryPage() { {filteredRows.length} {filteredRows.length !== rows.length ? ` / ${rows.length}` : ''} + + {confirmClear && ( + setConfirmClear(false)} + /> + )} + {loading && (
diff --git a/client/src/styles/app.css b/client/src/styles/app.css index a09a3a1..a0a6866 100644 --- a/client/src/styles/app.css +++ b/client/src/styles/app.css @@ -142,6 +142,57 @@ a { font-size: 0.85rem; } +.btn-danger { + background: var(--color-error); + border-color: var(--color-error); + color: #ffffff; +} + +.btn-danger:hover:not(:disabled) { + filter: brightness(1.08); +} + +/* ---------- Confirm dialog ---------- */ + +.confirm-overlay { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: var(--color-overlay); +} + +.confirm-dialog { + width: 100%; + max-width: 380px; + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 20px; +} + +.confirm-title { + margin: 0 0 8px; + font-size: 1.05rem; +} + +.confirm-message { + margin: 0 0 18px; + font-size: 0.9rem; + line-height: 1.45; + color: var(--color-text-muted); +} + +.confirm-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + .btn-icon { padding: 8px; } @@ -449,6 +500,7 @@ a { display: flex; align-items: center; justify-content: space-between; + flex-wrap: wrap; gap: 8px; margin-top: 12px; } @@ -456,6 +508,7 @@ a { .card-actions-left { display: flex; align-items: center; + flex-wrap: wrap; gap: 12px; min-width: 0; } @@ -794,16 +847,26 @@ a { gap: 12px; } +/* Separate consecutive rows so dense sections (Background checks) don't feel + cramped — a subtle divider + breathing room between each control. */ +.settings-row + .settings-row { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--color-border); +} + .settings-row-label { display: flex; flex-direction: column; - gap: 2px; + gap: 3px; + min-width: 0; } .settings-row-desc { font-size: 0.85rem; color: var(--color-text-muted); - margin: 0 0 8px; + margin: 0; + line-height: 1.4; } .theme-switch { @@ -935,11 +998,11 @@ a { flex-direction: column; align-items: center; justify-content: center; - gap: 2px; + gap: 3px; min-height: 56px; padding: 6px 4px; - color: var(--color-text-faint); - font-size: 0.7rem; + color: var(--color-text-muted); + font-size: 0.74rem; font-weight: 600; text-decoration: none; } @@ -1276,3 +1339,25 @@ a { width: auto; min-width: 120px; } + +/* ---------- Settings: stack rows vertically on phones ---------- */ +/* On narrow screens the label + control no longer fit side-by-side without + cramping, so stack them and let controls go full-width / left-aligned. */ +@media (max-width: 540px) { + .settings-row { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + /* Controls align to the start once stacked under their label. */ + .settings-row .theme-switch, + .settings-row .filter-row { + align-self: flex-start; + } + + .settings-time { + width: 100%; + min-width: 0; + } +} diff --git a/client/src/styles/themes.css b/client/src/styles/themes.css index d55de08..8d97130 100644 --- a/client/src/styles/themes.css +++ b/client/src/styles/themes.css @@ -47,7 +47,9 @@ /* Text */ --color-text: #1a1b1e; --color-text-muted: #495057; - --color-text-faint: #868e96; + /* #868e96 was borderline (~3:1) for small text on the light bg; darkened to + ~4.8:1 so faint timestamps/labels stay legible. */ + --color-text-faint: #6b7280; /* Accent / status */ --color-accent: #3b82f6; diff --git a/client/vite.config.js b/client/vite.config.js index 27413d5..f4b7c7a 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -9,20 +9,25 @@ export default defineConfig({ registerType: 'autoUpdate', manifest: { name: 'DockPull', - short_name: 'Updater', + short_name: 'DockPull', + description: 'Check your Docker containers for image updates and apply them by hand.', theme_color: '#0f1117', background_color: '#0f1117', display: 'standalone', + start_url: '/', + scope: '/', icons: [ { src: '/icon-192.png', sizes: '192x192', type: 'image/png', + purpose: 'any maskable', }, { src: '/icon-512.png', sizes: '512x512', type: 'image/png', + purpose: 'any maskable', }, ], }, diff --git a/server/src/config.js b/server/src/config.js index ac6d252..3e8230b 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -9,6 +9,21 @@ function envInt(name, fallback) { return Number.isNaN(parsed) ? fallback : parsed; } +/** + * Parse the TRUST_PROXY env into a value Express's `trust proxy` accepts. + * Empty/unset or falsy → `false` (don't trust XFF). `true`/`1`/`yes`/`on` → + * `true`. A bare integer → that hop count. Anything else (e.g. a subnet) is + * passed through verbatim. + */ +function parseTrustProxy(raw) { + if (raw === undefined || raw === '') return false; + const v = String(raw).trim().toLowerCase(); + if (v === 'false' || v === '0' || v === 'no' || v === 'off') return false; + if (v === 'true' || v === 'yes' || v === 'on') return true; + if (/^\d+$/.test(v)) return parseInt(v, 10); + return String(raw).trim(); +} + export const config = { PORT: envInt('PORT', 5000), STACKS_DIR: process.env.STACKS_DIR || '/stacks', @@ -22,6 +37,12 @@ export const config = { // can't try to update (and kill) itself. Defaults to the container_name // used in the shipped docker-compose.yml; override if you rename it. SELF_CONTAINER_NAME: process.env.SELF_CONTAINER_NAME || 'dockpull', + // Express `trust proxy` setting. Leave unset/false when the app is exposed + // directly. Set it when behind a reverse proxy (nginx, Traefik, Cloudflare) + // so `req.ip` (used for login rate-limiting) reflects the real client and + // the Secure cookie is detected. Accepts `true`/`1`, a hop count (e.g. `1`), + // or a subnet string passed straight to Express. + TRUST_PROXY: parseTrustProxy(process.env.TRUST_PROXY), }; /** diff --git a/server/src/db.js b/server/src/db.js index a3819ef..b57f53e 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -86,6 +86,9 @@ const stmts = { ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ? `), + clearHistory: db.prepare(` + DELETE FROM update_history + `), pin: db.prepare(` INSERT INTO pinned (ref) VALUES (?) ON CONFLICT(ref) DO NOTHING @@ -154,6 +157,11 @@ export function getHistory({ containerName, limit = 50, offset = 0 } = {}) { return stmts.getHistoryAll.all(limit, offset); } +/** Delete all update-history rows. Returns the better-sqlite3 run info. */ +export function clearHistory() { + return stmts.clearHistory.run(); +} + export function pin(ref) { return stmts.pin.run(ref); } diff --git a/server/src/index.js b/server/src/index.js index b8da00b..c88287a 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -9,6 +9,7 @@ import db from './db.js'; import { authRouter, requireAuth } from './auth.js'; import { apiRouter } from './routes/api.js'; import { updateRouter } from './routes/update.js'; +import { securityHeaders } from './security.js'; import scheduler from './scheduler.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -27,6 +28,15 @@ if (process.env.SKIP_CONFIG_CHECK !== '1') { const app = express(); app.disable('x-powered-by'); +// Only trust X-Forwarded-* when explicitly configured (behind a known reverse +// proxy). Default `false` keeps client IPs un-spoofable for login throttling. +if (config.TRUST_PROXY !== false) { + app.set('trust proxy', config.TRUST_PROXY); +} + +// Security headers for every response (no external dependency). +app.use(securityHeaders({ https: config.BASE_URL.startsWith('https') })); + app.use(express.json()); app.use(cookieParser(config.SESSION_SECRET)); diff --git a/server/src/notify.js b/server/src/notify.js index 40657d0..df8e767 100644 --- a/server/src/notify.js +++ b/server/src/notify.js @@ -6,6 +6,8 @@ * network; `sendDiscord` does the actual POST. */ +import { assertPublicWebhookUrl } from './urlguard.js'; + const MAX_LISTED = 25; // keep the message from blowing past Discord's limits /** @@ -39,6 +41,9 @@ export function buildDiscordPayload(items) { * @returns {Promise<{ ok: boolean, status: number }>} */ export async function postWebhook(url, payload, { timeoutMs = 10000 } = {}) { + // SSRF guard: reject non-https / internal / private-resolving URLs before we + // make any outbound request. + await assertPublicWebhookUrl(url); const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/server/src/routes/api.js b/server/src/routes/api.js index 5fb01d5..adb4b37 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -17,6 +17,7 @@ import { getSettings, updateSettings } from '../settings.js'; import scheduler from '../scheduler.js'; import { sendDiscordTest } from '../notify.js'; import { getChangelog } from '../changelog.js'; +import { isSafeWebhookUrl } from '../urlguard.js'; import * as db from '../db.js'; export const apiRouter = express.Router(); @@ -42,7 +43,7 @@ apiRouter.get('/api/containers', async (req, res) => { containers = await listContainers(); } catch (err) { console.error(`api.js: GET /api/containers failed to list containers: ${err.message}`); - return res.status(503).json({ error: 'docker_unavailable', message: err.message }); + return res.status(503).json({ error: 'docker_unavailable' }); } const { items, refsToResolve } = buildContainerItems({ @@ -65,7 +66,7 @@ apiRouter.post('/api/check', async (req, res) => { result = await runCheck(); } catch (err) { console.error(`api.js: POST /api/check failed: ${err.message}`); - return res.status(503).json({ error: 'docker_unavailable', message: err.message }); + return res.status(503).json({ error: 'docker_unavailable' }); } broadcastGlobal({ type: 'containers-changed' }); return res.status(200).json(result); @@ -92,6 +93,12 @@ apiRouter.get('/api/history/:name', (req, res) => { return res.status(200).json(rows); }); +// Wipe all update history (behind requireAuth, like the rest of /api/*). +apiRouter.delete('/api/history', (req, res) => { + db.clearHistory(); + return res.status(200).json({ ok: true }); +}); + apiRouter.get('/api/pinned', (req, res) => { return res.status(200).json(db.getPinned()); }); @@ -153,12 +160,25 @@ apiRouter.post('/api/notify/test', async (req, res) => { if (!url) { return res.status(400).json({ error: 'no_webhook', message: 'No Discord webhook URL configured.' }); } + // SSRF guard: only allow an https webhook to a public host (also enforced at + // send time, but reject early with a clear message here). + if (!isSafeWebhookUrl(url)) { + return res + .status(400) + .json({ error: 'invalid_webhook', message: 'Webhook must be an https URL to a public host.' }); + } try { const result = await sendDiscordTest(url); if (result.ok) return res.status(200).json({ ok: true }); return res.status(502).json({ error: 'webhook_failed', status: result.status }); } catch (err) { - return res.status(502).json({ error: 'webhook_failed', message: err.message }); + console.error(`api.js: POST /api/notify/test failed: ${err.message}`); + if (err.code === 'unsafe_url') { + return res + .status(400) + .json({ error: 'invalid_webhook', message: 'Webhook must be an https URL to a public host.' }); + } + return res.status(502).json({ error: 'webhook_failed' }); } }); @@ -173,7 +193,8 @@ apiRouter.get('/api/changelog/:name', async (req, res) => { meta = await getContainerImageMeta(req.params.name); } catch (err) { if (err.statusCode === 404) return res.status(404).json({ error: 'not_found' }); - return res.status(503).json({ error: 'docker_unavailable', message: err.message }); + console.error(`api.js: GET /api/changelog/${req.params.name} inspect failed: ${err.message}`); + return res.status(503).json({ error: 'docker_unavailable' }); } if (!meta.image) return res.status(404).json({ error: 'not_found' }); @@ -187,7 +208,8 @@ apiRouter.get('/api/changelog/:name', async (req, res) => { changelogCache.set(key, { at: Date.now(), data }); return res.status(200).json(data); } catch (err) { - return res.status(502).json({ error: 'changelog_failed', message: err.message }); + console.error(`api.js: GET /api/changelog/${req.params.name} failed: ${err.message}`); + return res.status(502).json({ error: 'changelog_failed' }); } }); diff --git a/server/src/security.js b/server/src/security.js new file mode 100644 index 0000000..a31efb9 --- /dev/null +++ b/server/src/security.js @@ -0,0 +1,42 @@ +/** + * Security response headers. The app is fully same-origin — it serves its own + * hashed JS/CSS bundles and talks only to its own /api — so a tight CSP holds. + * `style-src` needs 'unsafe-inline' for React inline-style attributes and + * Vite-injected styles; scripts are self-hosted bundles so `script-src 'self'` + * is enough. + */ + +export const CONTENT_SECURITY_POLICY = [ + "default-src 'self'", + "img-src 'self' data:", + "style-src 'self' 'unsafe-inline'", + "script-src 'self'", + "connect-src 'self'", + "worker-src 'self'", + "manifest-src 'self'", + "base-uri 'none'", + "form-action 'self'", + "frame-ancestors 'none'", +].join('; '); + +/** + * Build an Express middleware that sets security headers on every response. + * HSTS is only emitted when the app is served over https. + * + * @param {{ https?: boolean }} [opts] + */ +export function securityHeaders({ https = false } = {}) { + return function securityHeadersMiddleware(req, res, next) { + res.set('X-Content-Type-Options', 'nosniff'); + res.set('X-Frame-Options', 'DENY'); + res.set('Referrer-Policy', 'no-referrer'); + res.set('Cross-Origin-Opener-Policy', 'same-origin'); + res.set('Content-Security-Policy', CONTENT_SECURITY_POLICY); + if (https) { + res.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + next(); + }; +} + +export default { securityHeaders, CONTENT_SECURITY_POLICY }; diff --git a/server/src/settings.js b/server/src/settings.js index 4100a6d..bc6c9f1 100644 --- a/server/src/settings.js +++ b/server/src/settings.js @@ -10,6 +10,7 @@ */ import * as db from './db.js'; +import { isSafeWebhookUrl } from './urlguard.js'; function bool(v, fallback) { if (v === undefined || v === null) return fallback; @@ -29,9 +30,11 @@ function timeOrUndef(v) { return isValidTime(v) ? v.trim() : undefined; } +// Accept an empty string (clears the webhook) or an https URL whose host isn't +// internal — blocks SSRF via private/loopback/metadata addresses. function urlOrUndef(v) { if (v === '') return ''; - if (typeof v === 'string' && /^https?:\/\//i.test(v.trim())) return v.trim(); + if (typeof v === 'string' && isSafeWebhookUrl(v)) return v.trim(); return undefined; } diff --git a/server/src/urlguard.js b/server/src/urlguard.js new file mode 100644 index 0000000..45d8379 --- /dev/null +++ b/server/src/urlguard.js @@ -0,0 +1,116 @@ +/** + * SSRF guards for the user-supplied Discord webhook URL. + * + * The webhook URL is fetched server-side (scheduled notify + "send test"), so + * an unrestricted URL would let an authenticated user probe the host's internal + * network or cloud metadata endpoints. We defend in two layers: + * + * - `isSafeWebhookUrl(url)` — synchronous, cheap. Requires https and rejects + * URLs whose host is a literal loopback/private/link-local/reserved IP or an + * obviously-internal name (localhost / *.local). Used when validating input + * before storing it and in the test endpoint. + * - `assertPublicWebhookUrl(url)` — async. Does the sync checks, then resolves + * the hostname via DNS and rejects if *any* resolved address is private. + * This closes the gap where a public hostname points at an internal IP (or a + * DNS-rebind). Called right before the network request in notify.js. + */ + +import dns from 'node:dns/promises'; +import net from 'node:net'; + +/** Strip brackets from a URL hostname (IPv6 literals are bracketed). */ +function unbracket(host) { + return host.replace(/^\[/, '').replace(/\]$/, ''); +} + +/** + * Is this a private / loopback / link-local / otherwise-non-public IP literal? + * Handles IPv4, IPv6, and IPv4-mapped IPv6 (::ffff:a.b.c.d). + */ +export function isPrivateIp(ip) { + const kind = net.isIP(ip); + if (kind === 4) return isPrivateIpv4(ip); + if (kind === 6) return isPrivateIpv6(ip); + return false; // not an IP literal +} + +function isPrivateIpv4(ip) { + const parts = ip.split('.').map((n) => parseInt(n, 10)); + if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) { + return true; // malformed → treat as unsafe + } + const [a, b] = parts; + if (a === 0) return true; // 0.0.0.0/8 + if (a === 10) return true; // 10/8 private + if (a === 127) return true; // loopback + if (a === 169 && b === 254) return true; // link-local + cloud metadata + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12 private + if (a === 192 && b === 168) return true; // 192.168/16 private + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64/10 + if (a === 192 && b === 0 && parts[2] === 0) return true; // 192.0.0/24 + if (a === 198 && (b === 18 || b === 19)) return true; // benchmarking 198.18/15 + if (a >= 224) return true; // multicast + reserved + return false; +} + +function isPrivateIpv6(ip) { + const addr = ip.toLowerCase(); + if (addr === '::1' || addr === '::') return true; // loopback / unspecified + // IPv4-mapped (::ffff:a.b.c.d) — classify by the embedded v4 address. + const mapped = addr.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (mapped) return isPrivateIpv4(mapped[1]); + if (addr.startsWith('fe80')) return true; // link-local + const first = addr.split(':')[0]; + if (/^f[cd]/.test(first)) return true; // unique-local fc00::/7 + return false; +} + +/** Synchronous, network-free safety check. Requires https. */ +export function isSafeWebhookUrl(value) { + if (typeof value !== 'string') return false; + let url; + try { + url = new URL(value.trim()); + } catch { + return false; + } + if (url.protocol !== 'https:') return false; + const host = unbracket(url.hostname).toLowerCase(); + if (!host) return false; + if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) { + return false; + } + if (net.isIP(host) && isPrivateIp(host)) return false; + return true; +} + +/** + * Async guard used right before fetching the webhook. Throws an Error with + * `.code = 'unsafe_url'` if the URL fails the sync checks or resolves to a + * private address. + */ +export async function assertPublicWebhookUrl(value) { + if (!isSafeWebhookUrl(value)) { + const err = new Error('webhook URL is not allowed'); + err.code = 'unsafe_url'; + throw err; + } + const host = unbracket(new URL(value.trim()).hostname); + // If it's already an IP literal, the sync check covered it. + if (net.isIP(host)) return; + let addrs; + try { + addrs = await dns.lookup(host, { all: true }); + } catch { + const err = new Error('could not resolve webhook host'); + err.code = 'unsafe_url'; + throw err; + } + if (addrs.some((a) => isPrivateIp(a.address))) { + const err = new Error('webhook host resolves to a private address'); + err.code = 'unsafe_url'; + throw err; + } +} + +export default { isPrivateIp, isSafeWebhookUrl, assertPublicWebhookUrl }; diff --git a/server/test/db-history.test.js b/server/test/db-history.test.js new file mode 100644 index 0000000..ff251ce --- /dev/null +++ b/server/test/db-history.test.js @@ -0,0 +1,37 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// Point DATA_DIR at a throwaway dir BEFORE importing db — it creates the SQLite +// file from config.DATA_DIR at import time. +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dockpull-db-')); +process.env.DATA_DIR = tmp; + +const db = await import('../src/db.js'); + +test('clearHistory: removes all update-history rows', () => { + db.recordUpdate({ + container_name: 'nginx', + image: 'nginx:latest', + old_digest: 'sha256:a', + new_digest: 'sha256:b', + status: 'success', + }); + db.recordUpdate({ + container_name: 'redis', + image: 'redis:7', + old_digest: 'sha256:c', + new_digest: 'sha256:d', + status: 'success', + }); + assert.equal(db.getHistory({}).length, 2); + + db.clearHistory(); + assert.equal(db.getHistory({}).length, 0); + + // Idempotent on an already-empty table. + assert.doesNotThrow(() => db.clearHistory()); + assert.equal(db.getHistory({}).length, 0); +}); diff --git a/server/test/security.test.js b/server/test/security.test.js new file mode 100644 index 0000000..d5e193d --- /dev/null +++ b/server/test/security.test.js @@ -0,0 +1,39 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { securityHeaders, CONTENT_SECURITY_POLICY } from '../src/security.js'; + +function fakeRes() { + const headers = {}; + return { + headers, + set(k, v) { + headers[k] = v; + }, + }; +} + +function run(opts) { + const res = fakeRes(); + let called = false; + securityHeaders(opts)({}, res, () => { + called = true; + }); + return { headers: res.headers, called }; +} + +test('securityHeaders: sets the core headers and CSP, calls next', () => { + const { headers, called } = run(); + assert.equal(called, true); + assert.equal(headers['X-Content-Type-Options'], 'nosniff'); + assert.equal(headers['X-Frame-Options'], 'DENY'); + assert.equal(headers['Referrer-Policy'], 'no-referrer'); + assert.equal(headers['Cross-Origin-Opener-Policy'], 'same-origin'); + assert.equal(headers['Content-Security-Policy'], CONTENT_SECURITY_POLICY); + assert.match(headers['Content-Security-Policy'], /default-src 'self'/); + assert.match(headers['Content-Security-Policy'], /frame-ancestors 'none'/); +}); + +test('securityHeaders: HSTS only when https', () => { + assert.equal(run({ https: false }).headers['Strict-Transport-Security'], undefined); + assert.match(run({ https: true }).headers['Strict-Transport-Security'], /max-age=31536000/); +}); diff --git a/server/test/urlguard.test.js b/server/test/urlguard.test.js new file mode 100644 index 0000000..cce61b8 --- /dev/null +++ b/server/test/urlguard.test.js @@ -0,0 +1,48 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { isPrivateIp, isSafeWebhookUrl, assertPublicWebhookUrl } from '../src/urlguard.js'; + +test('isPrivateIp: classifies IPv4 ranges', () => { + for (const ip of ['127.0.0.1', '10.0.0.5', '192.168.1.10', '172.16.0.1', '172.31.255.255', '169.254.169.254', '0.0.0.0', '100.64.0.1']) { + assert.equal(isPrivateIp(ip), true, `${ip} should be private`); + } + for (const ip of ['1.1.1.1', '8.8.8.8', '93.184.216.34', '172.32.0.1']) { + assert.equal(isPrivateIp(ip), false, `${ip} should be public`); + } +}); + +test('isPrivateIp: classifies IPv6 (loopback, ULA, link-local, mapped)', () => { + assert.equal(isPrivateIp('::1'), true); + assert.equal(isPrivateIp('fc00::1'), true); + assert.equal(isPrivateIp('fd12:3456::1'), true); + assert.equal(isPrivateIp('fe80::1'), true); + assert.equal(isPrivateIp('::ffff:127.0.0.1'), true); + assert.equal(isPrivateIp('2606:4700:4700::1111'), false); +}); + +test('isSafeWebhookUrl: requires https + public host', () => { + assert.equal(isSafeWebhookUrl('https://discord.com/api/webhooks/1/abc'), true); + assert.equal(isSafeWebhookUrl('https://1.1.1.1/hook'), true); + // rejected: non-https + assert.equal(isSafeWebhookUrl('http://discord.com/api/webhooks/1/abc'), false); + // rejected: internal hosts + assert.equal(isSafeWebhookUrl('https://localhost/x'), false); + assert.equal(isSafeWebhookUrl('https://app.local/x'), false); + assert.equal(isSafeWebhookUrl('https://127.0.0.1/x'), false); + assert.equal(isSafeWebhookUrl('https://10.0.0.1/x'), false); + assert.equal(isSafeWebhookUrl('https://192.168.0.1/x'), false); + assert.equal(isSafeWebhookUrl('https://169.254.169.254/latest/meta-data'), false); + assert.equal(isSafeWebhookUrl('https://[::1]/x'), false); + // rejected: junk + assert.equal(isSafeWebhookUrl('not-a-url'), false); + assert.equal(isSafeWebhookUrl(''), false); + assert.equal(isSafeWebhookUrl(null), false); +}); + +test('assertPublicWebhookUrl: throws unsafe_url for internal, resolves IP literals', async () => { + await assert.rejects(() => assertPublicWebhookUrl('http://discord.com/x'), /not allowed/); + await assert.rejects(() => assertPublicWebhookUrl('https://127.0.0.1/x'), /not allowed/); + await assert.rejects(() => assertPublicWebhookUrl('https://10.1.2.3/x'), /not allowed/); + // Public IP literal skips DNS and passes. + await assert.doesNotReject(() => assertPublicWebhookUrl('https://1.1.1.1/x')); +});