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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@ To update DockPull itself: `docker compose pull dockpull && docker compose up -d
`BASE_PATH=/dockpull` and build the client with the same value
(`BASE_PATH=/dockpull docker compose build`).

**Behind a Cloudflare Tunnel?** A tunnel makes DockPull reachable from the
public internet, and DockPull controls your Docker socket — the built-in
password alone is not enough there. Put a [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/policies/access/)
policy (Zero Trust login) in front of the hostname, and set `TRUST_PROXY=1`
and `BASE_URL=https://your-hostname` so rate-limiting sees real client IPs and
the session cookie is marked `Secure`.

---

## Troubleshooting
Expand All @@ -157,6 +164,6 @@ To update DockPull itself: `docker compose pull dockpull && docker compose up -d
public or `docker login ghcr.io`.

---
DISCLAIMER: This porject was made using Claude
DISCLAIMER: This project was made using Claude
Endpoint/field reference: [`API_CONTRACT.md`](./API_CONTRACT.md) ·
Development setup: [`CONTRIBUTING.md`](./CONTRIBUTING.md) · License: MIT.
5 changes: 5 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ DockPull is built for a **trusted LAN / homelab** behind authentication. It is

- **Keep it off the open internet.** Put it on your LAN, a VPN (WireGuard /
Tailscale), or behind an authenticating reverse proxy / tunnel.
- **Using a Cloudflare Tunnel?** That *is* internet exposure. Require a
[Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/policies/access/)
policy on the hostname (so visitors authenticate to Cloudflare before they
ever reach DockPull), and set `TRUST_PROXY=1` + `BASE_URL=https://…`. Don't
rely on the single app password as the only gate to a Docker-socket app.
- **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
Expand Down
16 changes: 15 additions & 1 deletion server/src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,25 @@ const LOCKOUT_MS = 15 * 60 * 1000; // how long a lockout lasts once tripped

const loginAttempts = new Map(); // ip -> { count, firstAt, lockedUntil }

// Bound the map: once it grows past this, expired entries are swept on the
// next recorded failure, so a wide scan from many IPs can't grow it forever.
const MAX_TRACKED_IPS = 10000;

function pruneLoginAttempts(now) {
for (const [ip, a] of loginAttempts) {
const windowExpired = now - a.firstAt > FAILURE_WINDOW_MS;
const lockoutExpired = !a.lockedUntil || a.lockedUntil <= now;
if (windowExpired && lockoutExpired) loginAttempts.delete(ip);
}
}

export function isLockedOut(ip, now = Date.now()) {
const a = loginAttempts.get(ip);
return Boolean(a && a.lockedUntil && a.lockedUntil > now);
}

export function recordLoginFailure(ip, now = Date.now()) {
if (loginAttempts.size >= MAX_TRACKED_IPS) pruneLoginAttempts(now);
let a = loginAttempts.get(ip);
if (!a || now - a.firstAt > FAILURE_WINDOW_MS) {
a = { count: 0, firstAt: now, lockedUntil: 0 };
Expand Down Expand Up @@ -127,7 +140,8 @@ export function loginHandler(req, res) {
* cookie that may not even be valid is harmless.
*/
export function logoutHandler(req, res) {
res.clearCookie(SESSION_COOKIE, { path: '/' });
// Must match the path the cookie was set with, or the browser won't clear it.
res.clearCookie(SESSION_COOKIE, { path: config.BASE_PATH || '/' });
return res.status(200).json({ ok: true });
}

Expand Down
21 changes: 12 additions & 9 deletions server/src/urlguard.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
/**
* SSRF guards for the user-supplied Discord webhook URL.
* URL validation for the user-supplied notification target.
*
* 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:
* The active check is `isValidNotifyUrl`: a well-formed http(s) URL, with
* private/LAN hosts deliberately allowed — a self-hosted ntfy/Gotify on your
* network is a normal target, and the field is admin-only (behind login).
* See SECURITY.md ("Notification URL is admin-only").
*
* Two stricter guards are kept (tested, currently unused) in case a future
* feature fetches URLs that should never reach internal hosts:
*
* - `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.
* obviously-internal name (localhost / *.local).
* - `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.
* the hostname via DNS and rejects if *any* resolved address is private,
* closing the gap where a public hostname points at an internal IP (or a
* DNS-rebind).
*/

import dns from 'node:dns/promises';
Expand Down