From d89fdd7dd7ebb29e1e851ee4425ff5ac31e405dd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:22:02 +0000 Subject: [PATCH] fix: accurate prune sizes, shared-image and revert tracking, and security hardening Prune reported each leftover image's WHOLE size (Docker's `Size` includes base layers still used by current images), so the preview and the "Freed X" message were wildly inflated. Now: - preview shows what each image really frees (Size minus layers shared with other images; SharedSize taken from the unfiltered list, since Docker only computes it among the images in the same response) - "Freed X" is measured (image-layer disk usage before/after) - untagged images still used by a container are no longer offered - prune-all counts images, not untag/layer entries - revert points whose image was pruned are dropped (and Revert refuses cleanly with 410 if its image is gone), and the Settings badge refreshes Found by running the app against a real Docker daemon: - containers on a tag that moved (sibling updated first) or that were reverted reported "up to date" forever; reverted standalone containers couldn't be updated (pull of a bare image ID) - revert copied Compose's image label, so a later compose update was a silent no-op; also added a force-recreate safety net - no revert point / blank history versions when the old image's digest was unknown (tracked by image ID now; versions stored on history rows) - loopback registries (localhost:5000) are spoken to over http, like Docker Security: - CSRF: state-changing API calls require an X-DockPull header (SameSite=Lax doesn't cover other apps on the same host) - container names validated before reaching the Docker API - session cookie bound to ADMIN_PASSWORD (changing it signs everyone out) - registry credentials only sent to https token servers Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KiL5R3bRpvGk6QP3E21eHF --- API_CONTRACT.md | 40 +++- SECURITY.md | 9 +- client/src/api.js | 10 +- client/src/pages/SettingsPage.jsx | 32 +++- server/src/auth.js | 35 +++- server/src/db.js | 32 +++- server/src/docker.js | 308 ++++++++++++++++++++++++++---- server/src/index.js | 4 +- server/src/registry.js | 30 ++- server/src/routes/api.js | 31 ++- server/src/routes/update.js | 45 ++++- server/src/security.js | 34 +++- server/test/auth.test.js | 26 ++- server/test/docker.test.js | 19 ++ server/test/prune.test.js | 113 +++++++++++ server/test/registry.test.js | 12 ++ server/test/security.test.js | 32 ++++ 17 files changed, 720 insertions(+), 92 deletions(-) create mode 100644 server/test/prune.test.js diff --git a/API_CONTRACT.md b/API_CONTRACT.md index 16613ae..6dc859e 100644 --- a/API_CONTRACT.md +++ b/API_CONTRACT.md @@ -16,6 +16,14 @@ All request/response bodies are JSON unless noted otherwise. require a valid `dockpull_session` cookie. If it is missing, invalid, or expired, the server responds `401 Unauthorized` with `{ "error": "unauthorized" }`. +- The cookie is bound to the current `ADMIN_PASSWORD`: changing the password + signs out every existing session. +- **CSRF:** every state-changing `/api/*` request (anything but + GET/HEAD/OPTIONS — login included) must send the header `X-DockPull: 1`, or + it's rejected with `403 { "error": "csrf_header_missing" }`. +- Routes with a `:name` param reject anything that isn't a valid container + name with `400 { "error": "invalid_container_name" }`. +- Error bodies may include a human-readable `message` alongside `error`. ## Endpoints @@ -103,7 +111,13 @@ All request/response bodies are JSON unless noted otherwise. update; subscribe via `GET /api/update/:name/stream`. - Response: `200 { "streamId": "string" }`. - Errors: `404 no_rollback` if there's nothing to revert to; `404 not_found` - if no such container; `409` if an update/revert is already in progress. + if no such container; `409` if an update/revert is already in progress; + `410 rollback_image_gone` if the saved image no longer exists (e.g. it was + pruned) — the rollback point is dropped and the container is not touched. +- The recreated container runs a bare image ID, so DockPull labels it with + `io.dockpull.image-ref` / `io.dockpull.image-digest` to keep tracking its + image (checks, updates and pins keep working, and the newer version is + offered again). ### `GET /api/update/:name/stream` @@ -159,12 +173,16 @@ All request/response bodies are JSON unless noted otherwise. - Auth: cookie. - Dry-run preview of what `POST /api/images/prune` would remove — lists - dangling images (untagged layers no container references) without - deleting anything, for a confirmation dialog to summarize before the user - commits to pruning. + dangling images (untagged images no container — running or stopped — uses) + without deleting anything, for a confirmation dialog to summarize before + the user commits to pruning. - Response: `200` — - `{ "count": number, "totalSize": number, "images": [{ "id": string, "size": number, "created": number|null, "fromContainer": string|null }] }` - where `totalSize` is in bytes, `id` is a short (12-char) image ID, and + `{ "count": number, "totalSize": number, "exact": boolean, "images": [{ "id": string, "size": number, "fullSize": number, "created": number|null, "fromContainer": string|null }] }` + where `size` is what removing that image should free — its whole size + (`fullSize`) minus the layers it shares with other images, which stay — + and `totalSize` is their sum, in bytes (`exact: false` when the daemon + didn't report shared sizes, so `size` falls back to the whole image). `id` + is a short (12-char) image ID, and `fromContainer` is the name of the container this image was replaced on (via its remembered rollback point), or `null` when that's unknown — images left over from before the container's most recent update, or @@ -184,9 +202,13 @@ All request/response bodies are JSON unless noted otherwise. layers); each ID is re-checked against the current dangling set before removal, so a stale or non-dangling ID is silently skipped. With no body (or no `ids`), every dangling layer is pruned. -- Response: `200` — `{ "ok": true, "deleted": number, "spaceReclaimed": number }` - where `deleted` is the number of image layers removed and `spaceReclaimed` - is in bytes. +- Response: `200` — + `{ "ok": true, "deleted": number, "spaceReclaimed": number, "revertsRemoved": [string] }` + where `deleted` is the number of images removed, `spaceReclaimed` is the + bytes actually freed — measured as image-layer disk usage before minus + after (falls back to the per-image estimate if the daemon can't report it) + — and `revertsRemoved` names containers whose revert point was among the + removed images (their rollback points are dropped). - `503 { "error": "docker_unavailable" }` when the Docker daemon is unreachable. diff --git a/SECURITY.md b/SECURITY.md index 4072b9a..ec864ac 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,7 +17,14 @@ DockPull is built for a **trusted LAN / homelab** behind authentication. It is - **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). + login, and `me` are public). The cookie is tied to the current + `ADMIN_PASSWORD`, so **changing the password signs out every session**. +- **CSRF protection.** State-changing API requests must carry an `X-DockPull` + header, which another site (or another app on a different port of the same + host — still "same-site" to the browser) can't add to a forged request. +- **Container names are validated** before they reach the Docker API. +- **Registry credentials** from your Docker config are only ever sent to + `https` token servers. - **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. diff --git a/client/src/api.js b/client/src/api.js index 72c754b..f3b6a55 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -42,7 +42,13 @@ async function request(method, path, body) { const res = await fetch(`${BASE}${path}`, { method, credentials: 'include', - headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, + headers: { + // Required by the server on every state-changing request: a cross-site + // page can't add custom headers without a CORS preflight (which the + // server never grants), so this blocks CSRF from other sites/ports. + 'X-DockPull': '1', + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, body: body !== undefined ? JSON.stringify(body) : undefined, }); @@ -54,7 +60,7 @@ async function request(method, path, body) { if (onUnauthorized) onUnauthorized(); } const errMessage = - (data && typeof data === 'object' && data.error) || + (data && typeof data === 'object' && (data.message || data.error)) || (typeof data === 'string' && data) || `${method} ${path} failed with ${res.status}`; throw new ApiError(errMessage, res.status, data); diff --git a/client/src/pages/SettingsPage.jsx b/client/src/pages/SettingsPage.jsx index 1851fba..73bd237 100644 --- a/client/src/pages/SettingsPage.jsx +++ b/client/src/pages/SettingsPage.jsx @@ -16,7 +16,7 @@ import { useTheme } from '../hooks/useTheme.js'; // Human-readable byte count: whole bytes below 1 KB, one decimal above. function formatBytes(n) { if (!Number.isFinite(n) || n < 1024) return `${n} B`; - const units = ['KB', 'MB', 'GB']; + const units = ['KB', 'MB', 'GB', 'TB']; let value = n; let i = -1; do { @@ -195,11 +195,14 @@ export default function SettingsPage({ onPruneComplete } = {}) { setPruning(true); setPruneStatus(''); try { - const { deleted = 0, spaceReclaimed = 0 } = (await pruneImages(ids)) || {}; + const { deleted = 0, spaceReclaimed = 0, revertsRemoved = [] } = (await pruneImages(ids)) || {}; + const revertNote = revertsRemoved.length + ? ` Revert is no longer available for ${revertsRemoved.join(', ')}.` + : ''; setPruneStatus( - deleted > 0 + (deleted > 0 ? `Freed ${formatBytes(spaceReclaimed)} (${deleted} layer${deleted === 1 ? '' : 's'} removed).` - : 'Nothing to prune — no dangling layers found.' + : 'Nothing to prune — no dangling layers found.') + revertNote ); if (deleted > 0) { onPruneComplete?.(); @@ -511,7 +514,7 @@ export default function SettingsPage({ onPruneComplete } = {}) { dialogClassName="confirm-dialog--wide" confirmLabel={ pruneSelection.length - ? `Prune ${pruneSelection.length} (${formatBytes( + ? `Prune ${pruneSelection.length} (~${formatBytes( pruneSelection.reduce((sum, img) => sum + (img.size || 0), 0) )})` : 'Prune' @@ -527,7 +530,15 @@ export default function SettingsPage({ onPruneComplete } = {}) {

Leftover layers from image updates. Remove any row with ✕ to keep that layer — it'll reappear here next time. Tagged images and anything in use are never touched. + Sizes are what removing each one should free (layers shared with images you still + use aren't counted).

+ {pruneSelection.some((img) => img.fromContainer) && ( +

+ ⚠ Rows named after a container are its previous version — pruning one removes the + option to revert that container's last update. +

+ )} {pruneSelection.length === 0 ? (

All layers excluded — nothing will be pruned.

) : ( @@ -550,7 +561,16 @@ export default function SettingsPage({ onPruneComplete } = {}) { {img.id} - {formatBytes(img.size || 0)} + + {formatBytes(img.size || 0)} + {formatAge(img.created)}