Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88f1d4a414 | ||
|
|
dd1110ef52 | ||
|
|
6732a1e1df | ||
|
|
ea96abe95a | ||
|
|
3ccf66754a | ||
|
|
c71b794ccc | ||
|
|
f2285a2550 | ||
|
|
54a1df5ac4 | ||
|
|
eb546bf468 | ||
|
|
84e051d975 | ||
|
|
628bbe32f6 | ||
|
|
f8b99f9b5a | ||
|
|
eab2b00b13 | ||
|
|
84edb035e3 | ||
|
|
d313b1e872 | ||
|
|
be021588c7 | ||
|
|
d8459a4a87 | ||
|
|
499fcc2742 | ||
|
|
93d6c44e45 | ||
|
|
4c4ffc35ca | ||
|
|
7e68955e66 | ||
|
|
089f5d2902 | ||
|
|
0e7bb97129 | ||
|
|
98737995a9 | ||
|
|
99ec6ebc53 | ||
|
|
a7260436d1 | ||
|
|
a4e4b24732 | ||
|
|
0086de97da | ||
|
|
18ffd2e519 | ||
|
|
2fef1c47e5 | ||
|
|
e8c5a7a1fb | ||
|
|
270e8d57e3 | ||
|
|
7db152499c | ||
|
|
a9bb4a1835 | ||
|
|
b64f23301b | ||
|
|
83d7c65bf2 | ||
|
|
1462024944 | ||
|
|
297332b0e1 | ||
|
|
384f9c8bdb |
+25
@@ -400,3 +400,28 @@ Tickets DC-046 through DC-049 implement pluggable auth + email magic link. Sami
|
|||||||
- **prerequisite:** None.
|
- **prerequisite:** None.
|
||||||
- **result:** Shipped codex-graded A. All 49 `console.*` sites in `src/managers/update-manager.js` now route through `log.info/log.warn/log.error` from `src/utils/logging` (tag = `'update'`). Mixed-content strings extracted into structured meta payloads (`containerName`, `schedule`, `imageName`, `error.message`, `digestPrefix`, `oldImageIdPrefix`, `httpStatus`, `maxAttempts`, `attempt`, `durationMs`, `scheduledTime`, etc.) so fields are queryable instead of inlined into the message. Errors now go through `log.error(ctx, errObj)` so they land in error.log with full stack trace + context, not just stderr. 1539/1539 Jest tests pass (78/78 update-manager tests still pass). ESLint: 14 pre-existing warnings in this file unchanged, zero new warnings introduced (verified with `git stash` baseline check).
|
- **result:** Shipped codex-graded A. All 49 `console.*` sites in `src/managers/update-manager.js` now route through `log.info/log.warn/log.error` from `src/utils/logging` (tag = `'update'`). Mixed-content strings extracted into structured meta payloads (`containerName`, `schedule`, `imageName`, `error.message`, `digestPrefix`, `oldImageIdPrefix`, `httpStatus`, `maxAttempts`, `attempt`, `durationMs`, `scheduledTime`, etc.) so fields are queryable instead of inlined into the message. Errors now go through `log.error(ctx, errObj)` so they land in error.log with full stack trace + context, not just stderr. 1539/1539 Jest tests pass (78/78 update-manager tests still pass). ESLint: 14 pre-existing warnings in this file unchanged, zero new warnings introduced (verified with `git stash` baseline check).
|
||||||
|
|
||||||
|
|
||||||
|
### DC-086: Service-status flicker fix — asymmetric hysteresis on the badge
|
||||||
|
- **status:** in-progress
|
||||||
|
- **owner:** hermes
|
||||||
|
- **details:** Dashboard service badges perpetually flip between green and red for "a few seconds at a time, never stable" (Sami's report, 2026-08-20). Root cause: `src/monitoring/health-checker.js` `recordStatus()` emits `'status-check'` on EVERY probe (every 30s), and `src/websocket/dashboard-ws.js` forwards every probe as `'status-change'` to the browser with no diff. The frontend `live-events.js` then unconditionally calls `setBadge()` — which resets the icon + pill text on every event. A single transient 5xx (Caddy reload, container CPU steal, mid-flight TLS handshake, container restart during probe) flips the badge red and the next green probe flips it back. Fix: add asymmetric hysteresis in `_computeDisplayedStatus(serviceId, rawStatus)` — going DOWN requires 2 consecutive "down" probes (default `HEALTH_DOWN_THRESHOLD=2`), going UP requires only 1 (default `HEALTH_UP_THRESHOLD=1`). History + `consecutiveFailures` still record raw probe results (operators want full fidelity for postmortems); only the dashboard broadcast is filtered. `getCurrentStatus()` now returns the displayed status so a page reload shows the same badge as the live SSE stream. Both thresholds are env-var configurable so operators can tune. New tests in `__tests__/health-checker-hysteresis.test.js` cover: first probe emits; second probe same-status does NOT re-emit; one-down-then-up keeps green; two-down flips to red; one-up after down flips back to green; `getCurrentStatus` returns displayed not raw. Effort: ~30 min. Risk: low — pure behavior filter, no schema breaks, all 63 existing health-checker tests must stay green.
|
||||||
|
- **impact:** Operators stop seeing perpetual red/green flicker on healthy services. Real outages still get flagged (2 consecutive 30s probes = ~60s before badge flips red, which is still faster than a human notices). Background probe history is unchanged so postmortem analysis still works.
|
||||||
|
- **prerequisite:** None.
|
||||||
|
- **result:** _pending — ship + codex round_
|
||||||
|
|
||||||
|
### DC-085: Link-first invite — Discord-style "share it however you want"
|
||||||
|
- **status:** in-progress
|
||||||
|
- **owner:** hermes
|
||||||
|
- **details:** Today `POST /api/v1/auth/admin/invites` defaults to sending the invite link via SMTP; if SMTP is not configured it spams the server console with `[DC-048-DEV-INVITE-LINK]` log lines. Sami wants Discord-style: the link is always returned in the response, and email is an opt-in checkbox. Operators should be free to copy the link and share it via iMessage / SMS / WhatsApp / Telegram / Signal / Discord / paste-in-email — whatever fits. (1) Flip default `sendEmail !== false` to `sendEmail === true` in `routes/auth/admin.js` so omitting the field means "no email, just hand me the link." (2) Stop logging the raw invite URL to error.log when SMTP is unconfigured — that path was only useful when there was no UI way to grab the link; now there is. (3) Add a `shareText` field to the response: `"Join my DashCaddy as <role> — <acceptUrl> — expires in Nh."` for one-tap paste into any messenger. (4) Frontend: `status/js/admin.js` `_renderInviteForm` flips the "Send email" checkbox default to **unchecked**, updates `_renderIssuedInviteBanner` to show both the raw link AND the shareText (with its own copy button + `navigator.share()` native share-sheet button where available). (5) New tests in `__tests__/admin-invites.test.js` covering: default sendEmail=false (no SMTP send attempted, no console log); `sendEmail: true` triggers SMTP send; `shareText` is present and well-formed; `acceptUrl` is always returned; expired sendEmail path doesn't leak token to logs. Effort: ~1 hr. Risk: low — pure behavior flip + UI additive change.
|
||||||
|
- **impact:** Closes the friction between "host wants to add a friend" and "host has to configure SMTP first." Mirrors Discord/Slack/Linear invite flows where the link IS the deliverable. No new tier changes, no schema breaks.
|
||||||
|
- **prerequisite:** DC-048 (invite store + admin route), DC-052 (Pro gate stays).
|
||||||
|
- **result:** _pending — ship + codex round_
|
||||||
|
|
||||||
|
### DC-084: Remove redundant active Caddy health check from `arch.sami` site — eliminate 6 syslog spam lines/min
|
||||||
|
- **status:** done
|
||||||
|
- **owner:** hermes
|
||||||
|
- **details:** `/etc/caddy/sites/arch.sami` had an active Caddy health check (`health_uri /api/stats health_interval 10s`) probing `100.120.159.34:5000` every 10 seconds. The upstream Arch Linux server `100.120.159.34` has been permanently unreachable (100% packet loss on ping, ports 5000 + 8080 both time out). Result: 6 `level:info HTTP request failed` journal lines per minute, 360/hour, 8640/day — pure noise, no dashboard value, no incident resolution. The `src/monitoring/caddy-upstream-watcher.js` (the same module whose source comments explicitly call out this exact spam as "the noisy spam the dashboard currently sees for `100.120.159.34:5000`") ALREADY provides equivalent monitoring: 60s probe cadence (6x less frequent), 5-minute confirmation window before opening incidents, mute toggle, deduped snapshot, incident integration with the health-checker. The active Caddy check is redundant. Fix: edit `/etc/caddy/sites/arch.sami` to remove the `health_uri / health_interval` block, leaving only `reverse_proxy 100.120.159.34:5000`. Apply via `caddy-apply` (validates+reloads+commits atomically). Backup `.bak-DC-084-pre` created pre-edit; deleted after `caddy-apply` succeeded because the `.bak` file was being picked up by Caddy's `import sites/*` and causing an "ambiguous site definition" validation error.
|
||||||
|
- **impact:** Eliminates 100% of recurring caddy journal spam from the dead Arch upstream. The dashboard's `caddy-upstream-watcher.js` continues to monitor the dead upstream correctly (now at `consecutiveFailures: 1905+`, `lastSuccessAt: null`, `status: down`, `dead: true`) — operators see the dead upstream in the dashboard, just without the journal noise. Future Caddyfile authors who add an active health check to a `*.sami` site will be unaware that they should not (since the dashboard handles monitoring), so a follow-up could add a CLAUDE.md note or a Caddyfile lint warning. Out of scope for this tick.
|
||||||
|
- **prerequisite:** None. `caddy-upstream-watcher.js` already provides equivalent monitoring.
|
||||||
|
- **result:** Shipped GLM-pending (Codex quota dead). Before/after on DNS2 (`journalctl -u caddy --since "5 minutes ago" | grep health_checker.active | wc -l`): **before = ~30 entries / 5min** (active probe every 10s, all failing); **after = 0 entries / 5min**. Live-verified: `caddy validate` succeeded (after removing `.bak` file that caused `ambiguous site definition`), Caddy reloaded via `caddy-apply`, route `arch.sami → 100.120.159.34:5000` still active in admin API (verified via `curl http://localhost:2019/config/apps/http/servers/srv0/routes` — `health_uri: None, health_interval: None` confirms the block is gone). Container `dashcaddy-api Up About an hour (healthy)` (no restart needed — only Caddyfile changed, not container). Live HTTP smoke all green: `https://status.sami=200`, `https://dashcaddy.net=200`, `https://ca.sami=200`, `https://status.sami/api/health=401` (auth-gated, expected). Watcher state for `100.120.159.34:5000`: `consecutiveFailures: 1905`, `lastError: "probe timeout"`, `status: down`, `dead: true` — correctly tracked in `/opt/dashcaddy/dashcaddy-api/data/caddy-upstreams.json`. Backup deleted (would have caused site-definition ambiguity on next Caddy reload). Git: change lives only in DNS2's `/etc/caddy/sites/arch.sami` (the `/etc/caddy` git repo `.gitignore` excludes `sites/` per design — only the main `Caddyfile` is tracked). The dashcaddy source repo (`/root/dashcaddy`) carries only this BACKLOG.md documentation update on branch `dc/DC-084-arch-sami-caddy-healthcheck-removal`.
|
||||||
|
- **Tests:** No source code change; existing `__tests__/caddy-upstream-watcher.test.js` 26/26 pass (baseline preserved). 2465/2465 repo tests pass (4 pre-existing billing test suites fail with `Cannot find module pdfkit` — unrelated to this change).
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* Tests for DC-085: link-first invite (Discord-style "share it however you want").
|
||||||
|
*
|
||||||
|
* - default sendEmail omission = no email sent, link returned, no token in logs
|
||||||
|
* - sendEmail:true triggers SMTP send when configured
|
||||||
|
* - sendEmail:true + SMTP unconfigured = deliveredVia:'failed', no token leaked
|
||||||
|
* - shareText field present and well-formed in every response
|
||||||
|
* - acceptUrl always present (regardless of sendEmail)
|
||||||
|
* - role + ttl validation unchanged from DC-048
|
||||||
|
*
|
||||||
|
* Strategy: drive the route handler directly with mock req/res, mount the admin
|
||||||
|
* router against an isolated userStore + inviteStore + email-sender stub.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
function _tmpDir() {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-admin-invites-test-'));
|
||||||
|
}
|
||||||
|
function _cleanup(dir) {
|
||||||
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub email-sender so we can assert "was it called?" without an SMTP server.
|
||||||
|
// NOTE: the variable name MUST start with `mock` so Jest's hoisted `jest.mock()`
|
||||||
|
// call is allowed to reference it (Babel guard against out-of-scope access).
|
||||||
|
const mockEmailSender = {
|
||||||
|
isConfigured: jest.fn(() => false),
|
||||||
|
sendEmail: jest.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
jest.mock('../src/auth/providers/email-sender', () => mockEmailSender);
|
||||||
|
|
||||||
|
describe('DC-085: link-first admin invites', () => {
|
||||||
|
let dir, app, request;
|
||||||
|
let logCalls; // captured { level, msg, meta } from our fake log
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
dir = _tmpDir();
|
||||||
|
logCalls = [];
|
||||||
|
|
||||||
|
// Set up email auth enable flag so userStore mounts.
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
const { createUserStore } = require('../src/security/user-store');
|
||||||
|
const userStore = createUserStore({ dataDir: dir });
|
||||||
|
|
||||||
|
// Bootstrap the admin so we have a session-attributable user.
|
||||||
|
await userStore.login({ email: 'admin@sami-host.me' });
|
||||||
|
|
||||||
|
// Build a tiny Express app with the admin router mounted, but skip the
|
||||||
|
// global auth gate (we inject req.user directly).
|
||||||
|
const adminRouter = require('../routes/auth/admin')({
|
||||||
|
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||||
|
errorResponse: (_res, code, msg) => ({ status: code, msg }),
|
||||||
|
log: {
|
||||||
|
info: (topic, msg, meta) => logCalls.push({ level: 'info', topic, msg, meta }),
|
||||||
|
warn: (topic, msg, meta) => logCalls.push({ level: 'warn', topic, msg, meta }),
|
||||||
|
error: (topic, msg, meta) => logCalls.push({ level: 'error', topic, msg, meta }),
|
||||||
|
},
|
||||||
|
session: { isSessionValid: () => true, create: () => {}, setCookie: () => {} },
|
||||||
|
dataDir: dir,
|
||||||
|
});
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
// Inject req.user = admin so /admin/* passes the role gate.
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
req.user = { id: 'admin-id', email: 'admin@sami-host.me', role: 'admin' };
|
||||||
|
req.app.locals = req.app.locals || {};
|
||||||
|
req.app.locals.siteConfig = {}; // no publicBaseUrl — route uses req.headers
|
||||||
|
req.app.locals.emailConfig = null; // SMTP not configured by default
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use('/api/v1/auth', adminRouter);
|
||||||
|
// Error handler — last in chain.
|
||||||
|
app.use((err, _req, res, _next) => {
|
||||||
|
const code = (err && err.statusCode) || 500;
|
||||||
|
res.status(code).json({
|
||||||
|
success: false,
|
||||||
|
error: err && err.message,
|
||||||
|
code: err && err.code,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
request = require('supertest');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => _cleanup(dir));
|
||||||
|
|
||||||
|
test('default sendEmail (omitted) returns link and does NOT send email', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'friend@example.com', role: 'operator' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||||
|
expect(res.body.acceptUrl).toMatch(/\/api\/v1\/auth\/invites\/[^/]+\/accept$/);
|
||||||
|
expect(res.body.deliveredVia).toBe('manual');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default sendEmail does NOT log raw token to server log', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'friend@example.com', role: 'operator' });
|
||||||
|
|
||||||
|
const acceptUrl = res.body.acceptUrl;
|
||||||
|
// Extract the token from the URL and verify it does NOT appear in any log call.
|
||||||
|
const token = acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
|
||||||
|
const tokenLeaked = logCalls.some(c =>
|
||||||
|
typeof c.msg === 'string' && c.msg.includes(token)
|
||||||
|
);
|
||||||
|
expect(tokenLeaked).toBe(false);
|
||||||
|
|
||||||
|
// Also assert no log entry mentions the URL verbatim (the old
|
||||||
|
// `[DC-048-DEV-INVITE-LINK] url=...` spam).
|
||||||
|
const oldSpam = logCalls.find(c =>
|
||||||
|
typeof c.msg === 'string' && c.msg.includes('[DC-048-DEV-INVITE-LINK]')
|
||||||
|
);
|
||||||
|
expect(oldSpam).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shareText is present and well-formed in every response', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'friend@example.com', role: 'operator', ttlHours: 24 });
|
||||||
|
|
||||||
|
expect(res.body.shareText).toBeDefined();
|
||||||
|
expect(res.body.shareText).toContain('Join my DashCaddy');
|
||||||
|
expect(res.body.shareText).toContain('operator');
|
||||||
|
expect(res.body.shareText).toContain(res.body.acceptUrl);
|
||||||
|
expect(res.body.shareText).toContain('expires in 24h');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('acceptUrl is always returned regardless of sendEmail', async () => {
|
||||||
|
const r1 = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'a@x.com', sendEmail: false });
|
||||||
|
const r2 = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'b@x.com' });
|
||||||
|
expect(r1.body.acceptUrl).toBeTruthy();
|
||||||
|
expect(r2.body.acceptUrl).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendEmail: true triggers SMTP send when configured', async () => {
|
||||||
|
// Build a SECOND app instance where emailConfig is a real-looking object,
|
||||||
|
// so isConfigured() returns true. The first app uses emailConfig=null.
|
||||||
|
mockEmailSender.isConfigured.mockReturnValueOnce(true);
|
||||||
|
mockEmailSender.sendEmail.mockResolvedValueOnce(undefined);
|
||||||
|
const app2 = express();
|
||||||
|
app2.use(express.json());
|
||||||
|
app2.use((req, _res, next) => {
|
||||||
|
req.user = { id: 'admin-id', email: 'admin@sami-host.me', role: 'admin' };
|
||||||
|
req.app.locals = req.app.locals || {};
|
||||||
|
req.app.locals.siteConfig = {};
|
||||||
|
req.app.locals.emailConfig = { host: 'smtp.test', from: 'noreply@test' };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
const { createUserStore } = require('../src/security/user-store');
|
||||||
|
const userStore2 = createUserStore({ dataDir: dir });
|
||||||
|
await userStore2.login({ email: 'admin@sami-host.me' });
|
||||||
|
const router2 = require('../routes/auth/admin')({
|
||||||
|
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||||
|
errorResponse: (_res, code, msg) => ({ status: code, msg }),
|
||||||
|
log: { info() {}, warn: (t, m, meta) => logCalls.push({ level: 'warn', topic: t, msg: m, meta }), error() {} },
|
||||||
|
session: { isSessionValid: () => true, create: () => {}, setCookie: () => {} },
|
||||||
|
dataDir: dir,
|
||||||
|
});
|
||||||
|
app2.use('/api/v1/auth', router2);
|
||||||
|
|
||||||
|
const res = await request(app2)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'friend@example.com', role: 'viewer', sendEmail: true });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockEmailSender.sendEmail).toHaveBeenCalledTimes(1);
|
||||||
|
const [_cfg, to, subject, text, html] = mockEmailSender.sendEmail.mock.calls[0];
|
||||||
|
expect(to).toBe('friend@example.com');
|
||||||
|
expect(subject).toMatch(/invited/i);
|
||||||
|
expect(text).toContain(res.body.acceptUrl);
|
||||||
|
expect(html).toContain(res.body.acceptUrl);
|
||||||
|
expect(res.body.deliveredVia).toBe('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendEmail: true + SMTP unconfigured returns deliveredVia:failed and does NOT leak token', async () => {
|
||||||
|
mockEmailSender.isConfigured.mockReturnValueOnce(false);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'friend@example.com', role: 'operator', sendEmail: true });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||||
|
expect(res.body.deliveredVia).toBe('failed');
|
||||||
|
// acceptUrl + shareText still present so the operator can share manually.
|
||||||
|
expect(res.body.acceptUrl).toBeTruthy();
|
||||||
|
expect(res.body.shareText).toBeTruthy();
|
||||||
|
// Token does NOT appear in any log call.
|
||||||
|
const token = res.body.acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
|
||||||
|
const tokenLeaked = logCalls.some(c =>
|
||||||
|
typeof c.msg === 'string' && c.msg.includes(token)
|
||||||
|
);
|
||||||
|
expect(tokenLeaked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid role silently defaults to operator (DC-048 behavior preserved)', async () => {
|
||||||
|
// DC-048: the route's `(role && VALID_ROLES.has(role)) ? role : 'operator'`
|
||||||
|
// silently substitutes default rather than throwing. This test pins that
|
||||||
|
// behavior so a future "strict role validation" change is a deliberate
|
||||||
|
// decision, not a silent regression.
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'a@x.com', role: 'superuser' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.role).toBe('operator');
|
||||||
|
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('email validation: missing email still rejected', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ role: 'operator' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ttlHours: 1 still produces shareText with correct expiry wording', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'a@x.com', ttlHours: 1 });
|
||||||
|
expect(res.body.shareText).toContain('expires in 1h');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DC-089: SMTP-unconfigured warn log masks the invite email (no raw PII)', async () => {
|
||||||
|
mockEmailSender.isConfigured.mockReturnValueOnce(false);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'friend@example.com', role: 'operator', sendEmail: true });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.deliveredVia).toBe('failed');
|
||||||
|
const warn = logCalls.find(c =>
|
||||||
|
c.level === 'warn' && c.topic === 'auth-invite-send'
|
||||||
|
);
|
||||||
|
expect(warn).toBeDefined();
|
||||||
|
// The raw address must not appear; the masked form must.
|
||||||
|
expect(JSON.stringify(warn.meta)).not.toContain('friend@example.com');
|
||||||
|
expect(warn.meta.email).toBe('fr****@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DC-089: invite-accepted info log masks the created user email (no raw PII)', async () => {
|
||||||
|
// Pre-authorize the email (POST /admin/users) so userStore.login doesn't
|
||||||
|
// reject with not_authorized — bootstrap already happened in beforeEach.
|
||||||
|
const preauth = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/users')
|
||||||
|
.send({ email: 'newfriend@example.com' });
|
||||||
|
expect(preauth.status).toBe(200);
|
||||||
|
|
||||||
|
const issue = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'newfriend@example.com', role: 'viewer' });
|
||||||
|
expect(issue.status).toBe(200);
|
||||||
|
const token = issue.body.acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/v1/auth/invites/${token}/accept`)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const info = logCalls.find(c =>
|
||||||
|
c.level === 'info' && c.msg === 'invite accepted, user created'
|
||||||
|
);
|
||||||
|
expect(info).toBeDefined();
|
||||||
|
expect(JSON.stringify(info.meta)).not.toContain('newfriend@example.com');
|
||||||
|
expect(info.meta.email).toBe('ne****@example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
/**
|
||||||
|
* Tests for DC-086: asymmetric hysteresis on the dashboard service badge.
|
||||||
|
*
|
||||||
|
* - First probe always emits (no prior state).
|
||||||
|
* - Same-status probe does NOT re-emit (dedup against repeated green).
|
||||||
|
* - One "down" then back to "up" keeps the badge green (no flicker).
|
||||||
|
* - Two consecutive "down" probes flip the badge to red.
|
||||||
|
* - One "up" after a down streak flips back to green (fast recovery).
|
||||||
|
* - History retains every raw probe even when no emit happens.
|
||||||
|
* - getCurrentStatus returns displayed status, not raw.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
// Use an isolated data dir so test history doesn't pollute the real one.
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-hyst-'));
|
||||||
|
process.env.HEALTH_DATA_DIR = tmpDir;
|
||||||
|
process.env.HEALTH_CONFIG_FILE = path.join(tmpDir, 'health-config.json');
|
||||||
|
process.env.HEALTH_HISTORY_FILE = path.join(tmpDir, 'health-history.json');
|
||||||
|
|
||||||
|
// Module exports a singleton instance, not a class — see module.exports in
|
||||||
|
// src/monitoring/health-checker.js. The test creates fresh state by replacing
|
||||||
|
// the relevant maps on the singleton in beforeEach.
|
||||||
|
const healthCheckerSingleton = require('../src/monitoring/health-checker');
|
||||||
|
const originalDownThreshold = process.env.HEALTH_DOWN_THRESHOLD;
|
||||||
|
const originalUpThreshold = process.env.HEALTH_UP_THRESHOLD;
|
||||||
|
|
||||||
|
function restoreEnv(name, value) {
|
||||||
|
if (value === undefined) delete process.env[name];
|
||||||
|
else process.env[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeUp(serviceId = 'svc1') {
|
||||||
|
return {
|
||||||
|
serviceId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'up',
|
||||||
|
responseTime: 50,
|
||||||
|
statusCode: 200,
|
||||||
|
message: 'Service is healthy',
|
||||||
|
details: { headers: {}, bodyLength: 12 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDown(serviceId = 'svc1') {
|
||||||
|
return {
|
||||||
|
serviceId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'down',
|
||||||
|
responseTime: 50,
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'fail',
|
||||||
|
details: { headers: {}, bodyLength: 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DC-086: hysteresis on the dashboard badge', () => {
|
||||||
|
let hc;
|
||||||
|
let emitSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset the singleton's per-test state so each case starts clean.
|
||||||
|
healthCheckerSingleton.displayedStatus = new Map();
|
||||||
|
healthCheckerSingleton.consecutiveSinceChange = new Map();
|
||||||
|
healthCheckerSingleton.currentStatus = new Map();
|
||||||
|
healthCheckerSingleton.history = {};
|
||||||
|
healthCheckerSingleton.removeAllListeners('status-check');
|
||||||
|
emitSpy = jest.fn();
|
||||||
|
healthCheckerSingleton.on('status-check', emitSpy);
|
||||||
|
hc = healthCheckerSingleton;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
restoreEnv('HEALTH_DOWN_THRESHOLD', originalDownThreshold);
|
||||||
|
restoreEnv('HEALTH_UP_THRESHOLD', originalUpThreshold);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('first probe (no prior state) emits', () => {
|
||||||
|
hc.recordStatus('svc1', makeUp());
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(emitSpy.mock.calls[0][0].status).toBe('up');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('second probe with same status does NOT re-emit', () => {
|
||||||
|
hc.recordStatus('svc1', makeUp());
|
||||||
|
hc.recordStatus('svc1', makeUp());
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one "down" then "up" keeps the badge green (the flicker bug)', () => {
|
||||||
|
hc.recordStatus('svc1', makeUp()); // baseline: green, emit 1
|
||||||
|
hc.recordStatus('svc1', makeDown()); // one blip — keep green, no emit
|
||||||
|
hc.recordStatus('svc1', makeUp()); // recovered — still green, no emit
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hc.displayedStatus.get('svc1').status).toBe('up');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('up, down, up, down, down resets the first streak before flipping', () => {
|
||||||
|
hc.recordStatus('svc1', makeUp());
|
||||||
|
hc.recordStatus('svc1', makeDown());
|
||||||
|
hc.recordStatus('svc1', makeUp());
|
||||||
|
hc.recordStatus('svc1', makeDown());
|
||||||
|
expect(hc.displayedStatus.get('svc1').status).toBe('up');
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
hc.recordStatus('svc1', makeDown());
|
||||||
|
expect(hc.displayedStatus.get('svc1').status).toBe('down');
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two consecutive "down" probes flip the badge to red', () => {
|
||||||
|
hc.recordStatus('svc1', makeUp()); // baseline: green
|
||||||
|
hc.recordStatus('svc1', makeDown()); // blip #1 — keep green (counter=1)
|
||||||
|
hc.recordStatus('svc1', makeDown()); // blip #2 — flip red (counter=2 >= DOWN_THRESHOLD)
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(emitSpy.mock.calls[1][0].status).toBe('down');
|
||||||
|
expect(hc.displayedStatus.get('svc1').status).toBe('down');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one "up" after a down streak flips back to green (fast recovery)', () => {
|
||||||
|
hc.recordStatus('svc1', makeUp());
|
||||||
|
hc.recordStatus('svc1', makeDown());
|
||||||
|
hc.recordStatus('svc1', makeDown()); // now red
|
||||||
|
expect(hc.displayedStatus.get('svc1').status).toBe('down');
|
||||||
|
|
||||||
|
hc.recordStatus('svc1', makeUp()); // first green — flip back
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(3);
|
||||||
|
expect(emitSpy.mock.calls[2][0].status).toBe('up');
|
||||||
|
expect(hc.displayedStatus.get('svc1').status).toBe('up');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('history retains every raw probe even when no emit happens', () => {
|
||||||
|
hc.recordStatus('svc1', makeUp());
|
||||||
|
hc.recordStatus('svc1', makeDown()); // blip, no emit
|
||||||
|
hc.recordStatus('svc1', makeUp()); // recovery, no emit
|
||||||
|
expect(hc.history['svc1'].length).toBe(3);
|
||||||
|
expect(hc.history['svc1'][0].status).toBe('up');
|
||||||
|
expect(hc.history['svc1'][1].status).toBe('down');
|
||||||
|
expect(hc.history['svc1'][2].status).toBe('up');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getCurrentStatus returns the displayed status, not the raw probe', () => {
|
||||||
|
const displayedUp = makeUp();
|
||||||
|
displayedUp.timestamp = '2026-08-22T09:59:00.000Z';
|
||||||
|
displayedUp.statusCode = 200;
|
||||||
|
displayedUp.message = 'healthy';
|
||||||
|
displayedUp.details = { source: 'accepted-up' };
|
||||||
|
hc.recordStatus('svc1', displayedUp);
|
||||||
|
const latestRaw = makeDown();
|
||||||
|
latestRaw.timestamp = '2026-08-22T10:00:00.000Z';
|
||||||
|
latestRaw.responseTime = 987;
|
||||||
|
latestRaw.statusCode = 500;
|
||||||
|
latestRaw.message = 'failed probe';
|
||||||
|
latestRaw.error = 'upstream failure';
|
||||||
|
latestRaw.details = { source: 'suppressed-down' };
|
||||||
|
hc.recordStatus('svc1', latestRaw); // raw=down, displayed=up
|
||||||
|
const out = hc.getCurrentStatus();
|
||||||
|
expect(out['svc1'].status).toBe('up'); // shown to API consumers
|
||||||
|
expect(out['svc1'].timestamp).toBe(displayedUp.timestamp);
|
||||||
|
expect(out['svc1'].statusCode).toBe(200);
|
||||||
|
expect(out['svc1'].message).toBe('healthy');
|
||||||
|
expect(out['svc1'].error).toBeUndefined();
|
||||||
|
expect(out['svc1'].details).toEqual({ source: 'accepted-up' });
|
||||||
|
expect(hc.currentStatus.get('svc1')).toBe(latestRaw);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a long steady-green run produces exactly ONE emit (no per-probe spam)', () => {
|
||||||
|
for (let i = 0; i < 50; i++) hc.recordStatus('svc1', makeUp());
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a long steady-green-then-steady-red transition: 1 emit (up), 1 emit (red)', () => {
|
||||||
|
for (let i = 0; i < 10; i++) hc.recordStatus('svc1', makeUp());
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(1);
|
||||||
|
hc.recordStatus('svc1', makeDown());
|
||||||
|
hc.recordStatus('svc1', makeDown()); // flips to red
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(2);
|
||||||
|
for (let i = 0; i < 10; i++) hc.recordStatus('svc1', makeDown());
|
||||||
|
expect(emitSpy).toHaveBeenCalledTimes(2); // no further broadcasts
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DOWN_THRESHOLD env var is honored', () => {
|
||||||
|
process.env.HEALTH_DOWN_THRESHOLD = '3';
|
||||||
|
jest.resetModules();
|
||||||
|
const HC2Module = require('../src/monitoring/health-checker');
|
||||||
|
// Module is a singleton with DOWN_THRESHOLD captured at module load —
|
||||||
|
// resetModules gives us a fresh module-level instance with the new env.
|
||||||
|
const hc2 = HC2Module;
|
||||||
|
hc2.displayedStatus = new Map();
|
||||||
|
hc2.consecutiveSinceChange = new Map();
|
||||||
|
hc2.currentStatus = new Map();
|
||||||
|
hc2.history = {};
|
||||||
|
hc2.removeAllListeners('status-check');
|
||||||
|
const spy = jest.fn();
|
||||||
|
hc2.on('status-check', spy);
|
||||||
|
hc2.recordStatus('svc1', makeUp());
|
||||||
|
hc2.recordStatus('svc1', makeDown()); // 1
|
||||||
|
hc2.recordStatus('svc1', makeDown()); // 2 — still green (need 3)
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
|
||||||
|
hc2.recordStatus('svc1', makeDown()); // 3 — flip
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(hc2.displayedStatus.get('svc1').status).toBe('down');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(['not-a-number', '0', '-2', '1.5'])('malformed DOWN_THRESHOLD %s falls back to 2', value => {
|
||||||
|
process.env.HEALTH_DOWN_THRESHOLD = value;
|
||||||
|
jest.resetModules();
|
||||||
|
const hc2 = require('../src/monitoring/health-checker');
|
||||||
|
hc2.displayedStatus = new Map();
|
||||||
|
hc2.consecutiveSinceChange = new Map();
|
||||||
|
hc2.currentStatus = new Map();
|
||||||
|
hc2.history = {};
|
||||||
|
hc2.removeAllListeners('status-check');
|
||||||
|
const spy = jest.fn();
|
||||||
|
hc2.on('status-check', spy);
|
||||||
|
hc2.recordStatus('svc1', makeUp());
|
||||||
|
hc2.recordStatus('svc1', makeDown());
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
hc2.recordStatus('svc1', makeDown());
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UP_THRESHOLD env var greater than 1 is honored', () => {
|
||||||
|
process.env.HEALTH_UP_THRESHOLD = '2';
|
||||||
|
jest.resetModules();
|
||||||
|
const hc2 = require('../src/monitoring/health-checker');
|
||||||
|
hc2.displayedStatus = new Map();
|
||||||
|
hc2.consecutiveSinceChange = new Map();
|
||||||
|
hc2.currentStatus = new Map();
|
||||||
|
hc2.history = {};
|
||||||
|
hc2.removeAllListeners('status-check');
|
||||||
|
const spy = jest.fn();
|
||||||
|
hc2.on('status-check', spy);
|
||||||
|
hc2.recordStatus('svc1', makeDown());
|
||||||
|
hc2.recordStatus('svc1', makeUp());
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hc2.displayedStatus.get('svc1').status).toBe('down');
|
||||||
|
hc2.recordStatus('svc1', makeUp());
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(['not-a-number', '0', '-2', '1.5'])('malformed UP_THRESHOLD %s falls back to 1', value => {
|
||||||
|
process.env.HEALTH_UP_THRESHOLD = value;
|
||||||
|
jest.resetModules();
|
||||||
|
const hc2 = require('../src/monitoring/health-checker');
|
||||||
|
hc2.displayedStatus = new Map();
|
||||||
|
hc2.consecutiveSinceChange = new Map();
|
||||||
|
hc2.currentStatus = new Map();
|
||||||
|
hc2.history = {};
|
||||||
|
hc2.removeAllListeners('status-check');
|
||||||
|
const spy = jest.fn();
|
||||||
|
hc2.on('status-check', spy);
|
||||||
|
hc2.recordStatus('svc1', makeDown());
|
||||||
|
hc2.recordStatus('svc1', makeUp());
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeService clears hysteresis state before the same ID is re-added', () => {
|
||||||
|
hc.config.services.svc1 = { name: 'Service 1' };
|
||||||
|
hc.recordStatus('svc1', makeUp());
|
||||||
|
hc.recordStatus('svc1', makeDown());
|
||||||
|
expect(hc.displayedStatus.has('svc1')).toBe(true);
|
||||||
|
expect(hc.consecutiveSinceChange.get('svc1')).toBe(1);
|
||||||
|
hc.consecutiveFailures.set('svc1', 3);
|
||||||
|
const timer = setTimeout(() => {}, 60_000);
|
||||||
|
hc.serviceTimers.set('svc1', timer);
|
||||||
|
|
||||||
|
hc.saveConfig = jest.fn();
|
||||||
|
hc.removeService('svc1');
|
||||||
|
|
||||||
|
expect(hc.displayedStatus.has('svc1')).toBe(false);
|
||||||
|
expect(hc.consecutiveSinceChange.has('svc1')).toBe(false);
|
||||||
|
expect(hc.currentStatus.has('svc1')).toBe(false);
|
||||||
|
expect(hc.consecutiveFailures.has('svc1')).toBe(false);
|
||||||
|
expect(hc.serviceTimers.has('svc1')).toBe(false);
|
||||||
|
|
||||||
|
hc.config.services.svc1 = { name: 'Service 1 re-added' };
|
||||||
|
const emitSpyAfterReAdd = jest.fn();
|
||||||
|
hc.on('status-check', emitSpyAfterReAdd);
|
||||||
|
hc.recordStatus('svc1', makeDown());
|
||||||
|
|
||||||
|
expect(emitSpyAfterReAdd).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hc.displayedStatus.get('svc1').status).toBe('down');
|
||||||
|
expect(hc.consecutiveSinceChange.has('svc1')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* Tests for DC-090: outage incidents follow the DISPLAYED (post-hysteresis)
|
||||||
|
* status — the same signal that flips the dashboard badge.
|
||||||
|
*
|
||||||
|
* - A single raw "down" blip that hysteresis suppresses opens NO outage
|
||||||
|
* incident (the DC-089-noted raw-transition bug).
|
||||||
|
* - A suppressed blip does not resolve a real open outage (UP_THRESHOLD=2).
|
||||||
|
* - DOWN_THRESHOLD consecutive downs open exactly ONE outage incident.
|
||||||
|
* - The incident payload carries the displayed snapshot, not the raw probe.
|
||||||
|
* - Direct callers without hysteresis state keep legacy raw semantics.
|
||||||
|
*
|
||||||
|
* The probe() helper replicates checkService's exact call order: capture the
|
||||||
|
* pre-probe raw + displayed state, recordStatus (updates both maps), then
|
||||||
|
* checkForIncidents with both previous states.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
// Use an isolated data dir so test history doesn't pollute the real one.
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-incpar-'));
|
||||||
|
process.env.HEALTH_DATA_DIR = tmpDir;
|
||||||
|
process.env.HEALTH_CONFIG_FILE = path.join(tmpDir, 'health-config.json');
|
||||||
|
process.env.HEALTH_HISTORY_FILE = path.join(tmpDir, 'health-history.json');
|
||||||
|
|
||||||
|
// Module exports a singleton instance, not a class. Reset per-test state by
|
||||||
|
// replacing the relevant maps on the singleton in beforeEach.
|
||||||
|
const healthCheckerSingleton = require('../src/monitoring/health-checker');
|
||||||
|
const originalDownThreshold = process.env.HEALTH_DOWN_THRESHOLD;
|
||||||
|
const originalUpThreshold = process.env.HEALTH_UP_THRESHOLD;
|
||||||
|
|
||||||
|
function restoreEnv(name, value) {
|
||||||
|
if (value === undefined) delete process.env[name];
|
||||||
|
else process.env[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeUp(serviceId = 'svc1') {
|
||||||
|
return {
|
||||||
|
serviceId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'up',
|
||||||
|
responseTime: 50,
|
||||||
|
statusCode: 200,
|
||||||
|
message: 'Service is healthy',
|
||||||
|
details: { headers: {}, bodyLength: 12 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDown(serviceId = 'svc1') {
|
||||||
|
return {
|
||||||
|
serviceId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'down',
|
||||||
|
responseTime: 50,
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'fail',
|
||||||
|
details: { headers: {}, bodyLength: 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DC-090: outage incidents follow the displayed (hysteresis) status', () => {
|
||||||
|
let hc;
|
||||||
|
let incidentCreatedSpy;
|
||||||
|
let incidentResolvedSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
healthCheckerSingleton.displayedStatus = new Map();
|
||||||
|
healthCheckerSingleton.consecutiveSinceChange = new Map();
|
||||||
|
healthCheckerSingleton.currentStatus = new Map();
|
||||||
|
healthCheckerSingleton.history = {};
|
||||||
|
healthCheckerSingleton.incidents = [];
|
||||||
|
healthCheckerSingleton.removeAllListeners('incident-created');
|
||||||
|
healthCheckerSingleton.removeAllListeners('incident-resolved');
|
||||||
|
incidentCreatedSpy = jest.fn();
|
||||||
|
incidentResolvedSpy = jest.fn();
|
||||||
|
healthCheckerSingleton.on('incident-created', incidentCreatedSpy);
|
||||||
|
healthCheckerSingleton.on('incident-resolved', incidentResolvedSpy);
|
||||||
|
hc = healthCheckerSingleton;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
restoreEnv('HEALTH_DOWN_THRESHOLD', originalDownThreshold);
|
||||||
|
restoreEnv('HEALTH_UP_THRESHOLD', originalUpThreshold);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replicates checkService's record+incident sequence for one raw probe.
|
||||||
|
function probe(status, config = {}) {
|
||||||
|
const previousStatus = hc.currentStatus.get(status.serviceId);
|
||||||
|
const previousDisplayed = hc.displayedStatus.get(status.serviceId) || null;
|
||||||
|
hc.recordStatus(status.serviceId, status);
|
||||||
|
hc.checkForIncidents(status.serviceId, status, config, previousStatus, previousDisplayed);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a single down blip between two ups opens NO outage incident', () => {
|
||||||
|
probe(makeUp()); // baseline: displayed up
|
||||||
|
probe(makeDown()); // blip — hysteresis keeps displayed up
|
||||||
|
probe(makeUp()); // recovered
|
||||||
|
expect(hc.incidents).toHaveLength(0);
|
||||||
|
expect(incidentCreatedSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DOWN_THRESHOLD consecutive downs open exactly one outage incident (critical)', () => {
|
||||||
|
probe(makeUp());
|
||||||
|
probe(makeDown()); // counter=1, displayed still up
|
||||||
|
probe(makeDown()); // counter=2 → displayed flips down → incident
|
||||||
|
expect(hc.incidents).toHaveLength(1);
|
||||||
|
const incident = hc.incidents[0];
|
||||||
|
expect(incident.type).toBe('outage');
|
||||||
|
expect(incident.severity).toBe('critical');
|
||||||
|
expect(incident.status).toBe('open');
|
||||||
|
expect(incidentCreatedSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
probe(makeDown()); // still down — no new transition, no second incident
|
||||||
|
expect(hc.incidents).toHaveLength(1);
|
||||||
|
expect(incident.occurrences).toBe(1); // occurrences count displayed flips, not raw probes
|
||||||
|
expect(incidentCreatedSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the outage incident payload carries the displayed snapshot, not the raw blip', () => {
|
||||||
|
probe(makeUp());
|
||||||
|
const blip = makeDown();
|
||||||
|
blip.statusCode = 599;
|
||||||
|
probe(blip); // suppressed blip — must not appear in any incident
|
||||||
|
probe(makeDown()); // flip
|
||||||
|
expect(hc.incidents).toHaveLength(1);
|
||||||
|
// The incident's details snapshot is the probe that FLIPPED the displayed
|
||||||
|
// state (the second down), not the earlier suppressed blip.
|
||||||
|
expect(hc.incidents[0].details.statusCode).not.toBe(599);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a suppressed up blip does not resolve a real open outage (UP_THRESHOLD=2)', () => {
|
||||||
|
process.env.HEALTH_UP_THRESHOLD = '2';
|
||||||
|
jest.resetModules();
|
||||||
|
const hc2 = require('../src/monitoring/health-checker');
|
||||||
|
hc2.displayedStatus = new Map();
|
||||||
|
hc2.consecutiveSinceChange = new Map();
|
||||||
|
hc2.currentStatus = new Map();
|
||||||
|
hc2.history = {};
|
||||||
|
hc2.incidents = [];
|
||||||
|
hc2.removeAllListeners('incident-created');
|
||||||
|
hc2.removeAllListeners('incident-resolved');
|
||||||
|
|
||||||
|
const p2 = (status) => {
|
||||||
|
const prevRaw = hc2.currentStatus.get(status.serviceId);
|
||||||
|
const prevDisp = hc2.displayedStatus.get(status.serviceId) || null;
|
||||||
|
hc2.recordStatus(status.serviceId, status);
|
||||||
|
hc2.checkForIncidents(status.serviceId, status, {}, prevRaw, prevDisp);
|
||||||
|
};
|
||||||
|
|
||||||
|
p2(makeUp());
|
||||||
|
p2(makeDown());
|
||||||
|
p2(makeDown()); // displayed down → outage opens
|
||||||
|
expect(hc2.incidents).toHaveLength(1);
|
||||||
|
expect(hc2.incidents[0].status).toBe('open');
|
||||||
|
|
||||||
|
p2(makeUp()); // counter=1 < UP_THRESHOLD=2 → displayed still down
|
||||||
|
expect(hc2.displayedStatus.get('svc1').status).toBe('down');
|
||||||
|
expect(hc2.incidents[0].status).toBe('open'); // NOT resolved by the blip
|
||||||
|
|
||||||
|
p2(makeUp()); // counter=2 → displayed up → incident resolves
|
||||||
|
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
|
||||||
|
expect(hc2.incidents[0].status).toBe('resolved');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy direct callers (no displayed state) keep raw transition semantics', () => {
|
||||||
|
hc.currentStatus.set('svc1', { status: 'up' });
|
||||||
|
const status = { status: 'down', timestamp: new Date().toISOString(), responseTime: 100 };
|
||||||
|
hc.checkForIncidents('svc1', status, {}); // 4-arg call, no previousDisplayed
|
||||||
|
expect(hc.incidents).toHaveLength(1);
|
||||||
|
expect(hc.incidents[0].type).toBe('outage');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slow-response detection still fires per-probe regardless of hysteresis', () => {
|
||||||
|
const slowUp = makeUp();
|
||||||
|
slowUp.responseTime = 6000;
|
||||||
|
probe(slowUp, { slowResponseThreshold: 5000 });
|
||||||
|
expect(hc.incidents.some(i => i.type === 'slow-response')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -203,6 +203,55 @@ describe('HealthChecker', () => {
|
|||||||
expect(result.error).toBe('ECONNREFUSED');
|
expect(result.error).toBe('ECONNREFUSED');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opens and resolves an outage incident across real checkService transitions', async () => {
|
||||||
|
// DC-090: incidents follow the DISPLAYED (post-hysteresis) status.
|
||||||
|
// DOWN_THRESHOLD defaults to 2, so it takes two consecutive failed
|
||||||
|
// probes to flip displayed down and open the outage; one up probe
|
||||||
|
// (UP_THRESHOLD=1) resolves it.
|
||||||
|
healthChecker._doRequest = jest.fn()
|
||||||
|
.mockResolvedValueOnce({ healthy: true, statusCode: 200, message: 'ok', details: {} })
|
||||||
|
.mockResolvedValueOnce({ healthy: false, statusCode: 500, message: 'down', details: {} })
|
||||||
|
.mockResolvedValueOnce({ healthy: false, statusCode: 500, message: 'down', details: {} })
|
||||||
|
.mockResolvedValueOnce({ healthy: true, statusCode: 200, message: 'ok', details: {} });
|
||||||
|
|
||||||
|
const config = { url: 'http://test.local' };
|
||||||
|
await healthChecker.checkService('svc1', config);
|
||||||
|
await healthChecker.checkService('svc1', config);
|
||||||
|
expect(healthChecker.incidents).toHaveLength(0); // one down alone: suppressed blip
|
||||||
|
|
||||||
|
await healthChecker.checkService('svc1', config); // second down flips displayed → open
|
||||||
|
expect(healthChecker.incidents).toHaveLength(1);
|
||||||
|
expect(healthChecker.incidents[0]).toMatchObject({
|
||||||
|
serviceId: 'svc1',
|
||||||
|
type: 'outage',
|
||||||
|
status: 'open'
|
||||||
|
});
|
||||||
|
|
||||||
|
await healthChecker.checkService('svc1', config); // up resolves
|
||||||
|
expect(healthChecker.incidents[0].status).toBe('resolved');
|
||||||
|
expect(healthChecker.incidents[0].resolvedAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not resurrect state when an in-flight probe resolves after removal', async () => {
|
||||||
|
let resolveProbe;
|
||||||
|
healthChecker.config.services.svc1 = { url: 'http://test.local' };
|
||||||
|
healthChecker._doRequest = jest.fn(() => new Promise(resolve => {
|
||||||
|
resolveProbe = resolve;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pending = healthChecker.checkService('svc1', healthChecker.config.services.svc1);
|
||||||
|
healthChecker.saveConfig = jest.fn();
|
||||||
|
healthChecker.removeService('svc1');
|
||||||
|
resolveProbe({ healthy: true, statusCode: 200, message: 'late', details: {} });
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
|
||||||
|
expect(healthChecker.displayedStatus.has('svc1')).toBe(false);
|
||||||
|
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
|
||||||
|
expect(healthChecker.history.svc1).toBeUndefined();
|
||||||
|
expect(healthChecker.incidents).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('increments consecutive failures on error', async () => {
|
it('increments consecutive failures on error', async () => {
|
||||||
healthChecker._doRequest = jest.fn().mockRejectedValue(new Error('fail'));
|
healthChecker._doRequest = jest.fn().mockRejectedValue(new Error('fail'));
|
||||||
|
|
||||||
@@ -549,6 +598,113 @@ describe('HealthChecker', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('DC-088: removeService generation tombstones + incident closure', () => {
|
||||||
|
it('does not leak a serviceGenerations entry and records a tombstone', () => {
|
||||||
|
healthChecker.configureService('svc1', { url: 'http://test.local' });
|
||||||
|
expect(healthChecker.serviceGenerations.has('svc1')).toBe(true);
|
||||||
|
|
||||||
|
healthChecker.removeService('svc1');
|
||||||
|
|
||||||
|
expect(healthChecker.serviceGenerations.has('svc1')).toBe(false);
|
||||||
|
const tomb = healthChecker.removedGenerations.get('svc1');
|
||||||
|
expect(tomb).toBeDefined();
|
||||||
|
expect(tomb.generation).toBeGreaterThan(0);
|
||||||
|
expect(tomb.removedAt).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-added service gets a strictly higher generation (no ABA)', () => {
|
||||||
|
healthChecker.configureService('svc1', { url: 'http://test.local' });
|
||||||
|
const gen1 = healthChecker.serviceGenerations.get('svc1');
|
||||||
|
|
||||||
|
healthChecker.removeService('svc1');
|
||||||
|
healthChecker.configureService('svc1', { url: 'http://test.local/v2' });
|
||||||
|
|
||||||
|
const gen2 = healthChecker.serviceGenerations.get('svc1');
|
||||||
|
expect(gen2).toBeGreaterThan(gen1);
|
||||||
|
expect(healthChecker.removedGenerations.has('svc1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes open incidents for the removed service as resolved', () => {
|
||||||
|
healthChecker.saveConfig = jest.fn();
|
||||||
|
healthChecker.incidents.push({
|
||||||
|
id: 'incident-test-1',
|
||||||
|
serviceId: 'svc1',
|
||||||
|
type: 'outage',
|
||||||
|
status: 'open',
|
||||||
|
createdAt: new Date(Date.now() - 60_000).toISOString()
|
||||||
|
});
|
||||||
|
healthChecker.incidents.push({
|
||||||
|
id: 'incident-other',
|
||||||
|
serviceId: 'svc2',
|
||||||
|
type: 'outage',
|
||||||
|
status: 'open',
|
||||||
|
createdAt: new Date(Date.now() - 60_000).toISOString()
|
||||||
|
});
|
||||||
|
const resolvedSpy = jest.fn();
|
||||||
|
healthChecker.on('incident-resolved', resolvedSpy);
|
||||||
|
|
||||||
|
healthChecker.removeService('svc1');
|
||||||
|
|
||||||
|
const closed = healthChecker.incidents.find(i => i.id === 'incident-test-1');
|
||||||
|
expect(closed.status).toBe('resolved');
|
||||||
|
expect(closed.resolvedBy).toBe('service-removed');
|
||||||
|
expect(closed.resolvedAt).toBeDefined();
|
||||||
|
expect(closed.duration).toBeGreaterThan(0);
|
||||||
|
expect(healthChecker.incidents.find(i => i.id === 'incident-other').status).toBe('open');
|
||||||
|
expect(resolvedSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('in-flight probe captured before removal is discarded via tombstone', async () => {
|
||||||
|
let resolveProbe;
|
||||||
|
healthChecker.config.services.svc1 = { url: 'http://test.local' };
|
||||||
|
healthChecker._doRequest = jest.fn(() => new Promise(resolve => {
|
||||||
|
resolveProbe = resolve;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pending = healthChecker.checkService('svc1', healthChecker.config.services.svc1);
|
||||||
|
healthChecker.saveConfig = jest.fn();
|
||||||
|
healthChecker.removeService('svc1');
|
||||||
|
resolveProbe({ healthy: true, statusCode: 200, message: 'late', details: {} });
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
|
||||||
|
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a rejected in-flight probe after removal does not re-create failure state', async () => {
|
||||||
|
let rejectProbe;
|
||||||
|
healthChecker.config.services.svc1 = { url: 'http://test.local' };
|
||||||
|
healthChecker._doRequest = jest.fn(() => new Promise((resolve, reject) => {
|
||||||
|
rejectProbe = reject;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pending = healthChecker.checkService('svc1', healthChecker.config.services.svc1);
|
||||||
|
healthChecker.saveConfig = jest.fn();
|
||||||
|
healthChecker.removeService('svc1');
|
||||||
|
rejectProbe(new Error('late failure'));
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
|
||||||
|
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweeps expired tombstones in cleanupHistory', () => {
|
||||||
|
healthChecker.removedGenerations.set('svc1', {
|
||||||
|
generation: 1,
|
||||||
|
removedAt: Date.now() - 60 * 60 * 1000 // 1h ago, TTL default 10m
|
||||||
|
});
|
||||||
|
healthChecker.removedGenerations.set('svc2', {
|
||||||
|
generation: 2,
|
||||||
|
removedAt: Date.now() // fresh
|
||||||
|
});
|
||||||
|
|
||||||
|
healthChecker.cleanupHistory();
|
||||||
|
|
||||||
|
expect(healthChecker.removedGenerations.has('svc1')).toBe(false);
|
||||||
|
expect(healthChecker.removedGenerations.has('svc2')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('cleanupHistory', () => {
|
describe('cleanupHistory', () => {
|
||||||
it('removes entries older than retention period', () => {
|
it('removes entries older than retention period', () => {
|
||||||
const old = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(); // 35 days ago
|
const old = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(); // 35 days ago
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ jest.mock('dockerode', () => {
|
|||||||
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
|
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
|
||||||
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
||||||
|
|
||||||
|
// DC-087 — mirror src/app.js faithfully: the caddy check goes through
|
||||||
|
// fetchT (which injects the Origin header Caddy's enforce_origin allowlist
|
||||||
|
// requires), and is MOCKED so the suite is hermetic — no live request to a
|
||||||
|
// real Caddy admin on :2019. The previous raw-`fetch` mirror sent an
|
||||||
|
// Origin-less probe to the LIVE admin whenever the full suite ran on the
|
||||||
|
// prod host (adversarial cron every 30 min): 12 journal 403 lines per run,
|
||||||
|
// ~700/day of `client is not allowed to access from origin ''` noise,
|
||||||
|
// plus a false checks.caddy.ok=false in the mirrored readiness payload.
|
||||||
|
const fetchT = jest.spyOn(require('../src/utils/http'), 'fetchT')
|
||||||
|
.mockImplementation(async () => (caddyOk
|
||||||
|
? { ok: true, status: 200 }
|
||||||
|
: { ok: false, status: 403 }));
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const config = {
|
const config = {
|
||||||
CONFIG_FILE: '/tmp/dc-test-config.json',
|
CONFIG_FILE: '/tmp/dc-test-config.json',
|
||||||
@@ -103,9 +116,13 @@ function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk
|
|||||||
allOk = false;
|
allOk = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DC-087 — mirror src/app.js exactly (fetchT, not raw fetch). fetchT is
|
||||||
|
// mocked at buildApp() scope, so this stays hermetic: no live probe to a
|
||||||
|
// real Caddy admin (the old raw-fetch mirror 403-spammed the prod journal
|
||||||
|
// every time the adversarial cron ran the full suite on this host).
|
||||||
try {
|
try {
|
||||||
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
||||||
const response = await fetch(`${caddyUrl}/config/apps/http/servers/srv0/listen`, { signal: AbortSignal.timeout(10000) });
|
const response = await fetchT(`${caddyUrl}/config/apps/http/servers/srv0/listen`, {}, 10000);
|
||||||
checks.caddy = { ok: response.ok, status: response.status };
|
checks.caddy = { ok: response.ok, status: response.status };
|
||||||
if (!response.ok) allOk = false;
|
if (!response.ok) allOk = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -33,9 +33,18 @@ jest.mock('dockerode', () => {
|
|||||||
|
|
||||||
// Mirror the canonical handler block from src/app.js — if this drifts from
|
// Mirror the canonical handler block from src/app.js — if this drifts from
|
||||||
// the real handler, these tests will start failing and force a sync.
|
// the real handler, these tests will start failing and force a sync.
|
||||||
function buildApp({ configOk = true, servicesOk = true, dockerOk = true } = {}) {
|
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
|
||||||
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
||||||
|
|
||||||
|
// DC-087 — mirror src/app.js: caddy check via fetchT (Origin-injecting),
|
||||||
|
// mocked here so the suite is hermetic. The old raw-fetch mirror probed the
|
||||||
|
// LIVE Caddy admin on :2019 whenever the full suite ran on the prod host
|
||||||
|
// (adversarial cron): Origin-less → 403 → 12 journal error lines per run.
|
||||||
|
const fetchT = jest.spyOn(require('../src/utils/http'), 'fetchT')
|
||||||
|
.mockImplementation(async () => (caddyOk
|
||||||
|
? { ok: true, status: 200 }
|
||||||
|
: { ok: false, status: 403 }));
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const config = {
|
const config = {
|
||||||
CONFIG_FILE: '/tmp/dc-test-config.json',
|
CONFIG_FILE: '/tmp/dc-test-config.json',
|
||||||
@@ -108,8 +117,10 @@ function buildApp({ configOk = true, servicesOk = true, dockerOk = true } = {})
|
|||||||
allOk = false;
|
allOk = false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
// DC-087 — mirror src/app.js exactly: fetchT (mocked above), not raw
|
||||||
|
// fetch. Hermetic: no live request to a real Caddy admin.
|
||||||
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
||||||
const response = await fetch(`${caddyUrl}/config/apps/http/servers/srv0/listen`, { signal: AbortSignal.timeout(10000) });
|
const response = await fetchT(`${caddyUrl}/config/apps/http/servers/srv0/listen`, {}, 10000);
|
||||||
checks.caddy = { ok: response.ok, status: response.status };
|
checks.caddy = { ok: response.ok, status: response.status };
|
||||||
if (!response.ok) allOk = false;
|
if (!response.ok) allOk = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Nesting-guard tests — DC-077 (data/data recursive duplicate cleanup)
|
||||||
|
*
|
||||||
|
* The guard runs at app startup. Pre-fix, `src/config/paths.js` did NOT
|
||||||
|
* re-export `dataDir`, so `paths.dataDir` resolved to `undefined`. The
|
||||||
|
* outer try/catch swallowed the resulting `TypeError [ERR_INVALID_ARG_TYPE]`
|
||||||
|
* and the entire guard became a silent no-op — every startup logged
|
||||||
|
* `[nesting-guard] Skipped: The "path" argument must be of type string.
|
||||||
|
* Received undefined`. Post-fix, paths.js exports `dataDir` and the guard
|
||||||
|
* falls back to platform-paths directly if `paths.dataDir` is missing.
|
||||||
|
*
|
||||||
|
* Tests use jest.isolateModules() for clean module-cache isolation.
|
||||||
|
* jest.doMock is intentionally avoided — it persists across tests in a
|
||||||
|
* describe and is the root cause of subtle flakes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
describe('nesting-guard (DC-077)', () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeTmpTree() {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'nest-guard-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(p, obj) {
|
||||||
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||||
|
fs.writeFileSync(p, JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('removes a recursive data/data duplicate when present', () => {
|
||||||
|
const tmp = makeTmpTree();
|
||||||
|
writeJson(path.join(tmp, 'config.json'), { x: 1 });
|
||||||
|
writeJson(path.join(tmp, 'data', 'config.json'), { x: 1 });
|
||||||
|
writeJson(path.join(tmp, 'data', 'services.json'), []);
|
||||||
|
|
||||||
|
process.env.SERVICES_FILE = path.join(tmp, 'services.json');
|
||||||
|
process.env.CONFIG_FILE = path.join(tmp, 'config.json');
|
||||||
|
|
||||||
|
let cleanupLog = '';
|
||||||
|
let warnLog = '';
|
||||||
|
jest.isolateModules(() => {
|
||||||
|
const guard = require('../src/utilities/nesting-guard');
|
||||||
|
jest.spyOn(console, 'log').mockImplementation((m) => { cleanupLog += String(m) + '\n'; });
|
||||||
|
jest.spyOn(console, 'warn').mockImplementation((m) => { warnLog += String(m) + '\n'; });
|
||||||
|
guard();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(tmp, 'data'))).toBe(false);
|
||||||
|
expect(fs.existsSync(path.join(tmp, 'config.json'))).toBe(true);
|
||||||
|
expect(cleanupLog).toMatch(/Removing recursive data nesting|Recursive nesting removed/);
|
||||||
|
expect(warnLog).not.toMatch(/Skipped/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when no nested data/data directory exists', () => {
|
||||||
|
const tmp = makeTmpTree();
|
||||||
|
writeJson(path.join(tmp, 'config.json'), { x: 1 });
|
||||||
|
|
||||||
|
process.env.SERVICES_FILE = path.join(tmp, 'services.json');
|
||||||
|
process.env.CONFIG_FILE = path.join(tmp, 'config.json');
|
||||||
|
|
||||||
|
let cleanupLog = '';
|
||||||
|
let warnLog = '';
|
||||||
|
jest.isolateModules(() => {
|
||||||
|
const guard = require('../src/utilities/nesting-guard');
|
||||||
|
jest.spyOn(console, 'log').mockImplementation((m) => { cleanupLog += String(m) + '\n'; });
|
||||||
|
jest.spyOn(console, 'warn').mockImplementation((m) => { warnLog += String(m) + '\n'; });
|
||||||
|
guard();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(tmp, 'config.json'))).toBe(true);
|
||||||
|
expect(warnLog).not.toMatch(/Skipped/);
|
||||||
|
expect(cleanupLog).not.toMatch(/Removing recursive data nesting/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('src/config/paths exports dataDir as a non-empty string', () => {
|
||||||
|
let dataDir;
|
||||||
|
jest.isolateModules(() => {
|
||||||
|
const paths = require('../src/config/paths');
|
||||||
|
dataDir = paths.dataDir;
|
||||||
|
});
|
||||||
|
expect(typeof dataDir).toBe('string');
|
||||||
|
expect(dataDir.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('src/config/paths.dataDir equals dirname(SERVICES_FILE) when SERVICES_FILE env is set', () => {
|
||||||
|
const tmp = makeTmpTree();
|
||||||
|
process.env.SERVICES_FILE = path.join(tmp, 'services.json');
|
||||||
|
process.env.CONFIG_FILE = path.join(tmp, 'config.json');
|
||||||
|
|
||||||
|
let servicesFile, dataDir;
|
||||||
|
jest.isolateModules(() => {
|
||||||
|
const paths = require('../src/config/paths');
|
||||||
|
servicesFile = paths.SERVICES_FILE;
|
||||||
|
dataDir = paths.dataDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dataDir).toBe(path.dirname(servicesFile));
|
||||||
|
expect(dataDir).toBe(tmp);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -112,6 +112,7 @@ function createApp(depsOverride = {}) {
|
|||||||
errorResponse: jest.fn(),
|
errorResponse: jest.fn(),
|
||||||
log,
|
log,
|
||||||
renewCSRFToken,
|
renewCSRFToken,
|
||||||
|
siteConfig: { tld: '.sami', dashboardHost: 'status.sami' },
|
||||||
...depsOverride,
|
...depsOverride,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -299,7 +300,7 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
|||||||
it('returns 200 + creates new session + rotates CSRF on valid code (BACKLOG: "valid TOTP → session token → authenticated request succeeds")', async () => {
|
it('returns 200 + creates new session + rotates CSRF on valid code (BACKLOG: "valid TOTP → session token → authenticated request succeeds")', async () => {
|
||||||
const secret = await setupTOTP();
|
const secret = await setupTOTP();
|
||||||
const token = authenticator.generate(secret);
|
const token = authenticator.generate(secret);
|
||||||
const res = await request(app).post('/api/totp/verify').send({ code: token });
|
const res = await request(app).post('/api/totp/verify').send({ code: token, serviceId: 'plex' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.success).toBe(true);
|
expect(res.body.success).toBe(true);
|
||||||
expect(res.body.message).toMatch(/Authenticated successfully/);
|
expect(res.body.message).toMatch(/Authenticated successfully/);
|
||||||
@@ -308,8 +309,29 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
|||||||
expect(deps.session.create).toHaveBeenCalled();
|
expect(deps.session.create).toHaveBeenCalled();
|
||||||
expect(deps.session.setCookie).toHaveBeenCalled();
|
expect(deps.session.setCookie).toHaveBeenCalled();
|
||||||
expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1);
|
expect(deps.session.createHandoffToken).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.session.createHandoffToken).toHaveBeenCalledWith('plex.sami');
|
||||||
expect(deps.renewCSRFToken).toHaveBeenCalled();
|
expect(deps.renewCSRFToken).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not issue an unbound handoff token for a dashboard-only login', async () => {
|
||||||
|
const secret = await setupTOTP();
|
||||||
|
const token = authenticator.generate(secret);
|
||||||
|
const res = await request(app).post('/api/totp/verify').send({ code: token });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.ssoToken).toBeNull();
|
||||||
|
expect(deps.session.createHandoffToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid handoff service ID before issuing a token', async () => {
|
||||||
|
const secret = await setupTOTP();
|
||||||
|
const token = authenticator.generate(secret);
|
||||||
|
const res = await request(app).post('/api/totp/verify').send({ code: token, serviceId: 'plex.sami' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/Invalid service ID/);
|
||||||
|
expect(deps.session.createHandoffToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────
|
||||||
@@ -450,7 +472,7 @@ describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
|||||||
|
|
||||||
// 4. Re-login via /totp/verify (the "login" path)
|
// 4. Re-login via /totp/verify (the "login" path)
|
||||||
const loginCode = authenticator.generate(secret);
|
const loginCode = authenticator.generate(secret);
|
||||||
const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode });
|
const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode, serviceId: 'plex' });
|
||||||
expect(loginRes.status).toBe(200);
|
expect(loginRes.status).toBe(200);
|
||||||
expect(loginRes.body.csrfToken).toBeDefined();
|
expect(loginRes.body.csrfToken).toBeDefined();
|
||||||
expect(loginRes.body.ssoToken).toBe('mock-sso-handoff-token');
|
expect(loginRes.body.ssoToken).toBe('mock-sso-handoff-token');
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
/**
|
||||||
|
* DC-076: Per-service CA cert / private key disclosure hardening
|
||||||
|
*
|
||||||
|
* Bug class:
|
||||||
|
* 1. /api/v1/ca/cert/<domain> and /api/v1/ca/certs were listed in
|
||||||
|
* middleware.js PUBLIC_ROUTES. TOTP/session is the gate; if an
|
||||||
|
* operator ever disables TOTP (ops command, fresh-install setup
|
||||||
|
* state, .disabled-* rename of totp-config.json), an unauthenticated
|
||||||
|
* attacker reaching `https://ca.sami/api/ca/cert/<domain>?format=key`
|
||||||
|
* would receive the per-service RSA private key for any domain whose
|
||||||
|
* cert Caddy has ever signed — that's a per-service key disclosure,
|
||||||
|
* not just a CA fingerprint leak. Even WITH TOTP enabled, any
|
||||||
|
* read-scope credential could pull a private key, which is over-
|
||||||
|
* privileged for "I just want to look at the dashboard".
|
||||||
|
* 2. The route's `password` query param defaulted to the literal string
|
||||||
|
* `'dashcaddy'` — a hardcoded credential published in source. Every
|
||||||
|
* PFX file Caddy signed silently used the same published password.
|
||||||
|
* 3. The route had no rate limit — every request forks an `openssl`
|
||||||
|
* process and writes to disk, so an authenticated admin in a loop
|
||||||
|
* could exhaust CPU/IO.
|
||||||
|
*
|
||||||
|
* Post-fix (this commit):
|
||||||
|
* 1. /api/v1/ca/cert/<domain> + /api/v1/ca/certs removed from
|
||||||
|
* PUBLIC_ROUTES — TOTP/session always required.
|
||||||
|
* 2. The route additionally requires `admin` scope (defense in depth
|
||||||
|
* against future middleware-ordering mistakes and against the case
|
||||||
|
* where TOTP is enabled but a read-scope API key is in use).
|
||||||
|
* 3. PFX format now REQUIRES an explicit 8-64 char password (no
|
||||||
|
* default). Other formats (key, pem, crt, fullchain) reject `=`
|
||||||
|
* in the password arg to keep copy-paste mistakes from
|
||||||
|
* contaminating logs.
|
||||||
|
* 4. Per-IP rate limit: 10 req/min/IP with Retry-After + 429.
|
||||||
|
*
|
||||||
|
* The suite covers:
|
||||||
|
* 1. middleware PUBLIC_ROUTES no longer contains the ca cert/certs paths
|
||||||
|
* 2. /cert/<domain> rejects with 403 when no admin scope (read scope,
|
||||||
|
* missing scope, malformed scope all rejected)
|
||||||
|
* 3. /cert/<domain> rejects with 400 when PFX password missing or weak
|
||||||
|
* 4. /cert/<domain> rejects with 400 when domain is malformed
|
||||||
|
* (path traversal, single label, control chars)
|
||||||
|
* 5. /cert/<domain> returns 200 + cert bytes when admin scope + valid
|
||||||
|
* password supplied (mocked openssl)
|
||||||
|
* 6. Rate limit: 10 req/min/IP allowed, 11th 429 with Retry-After
|
||||||
|
* 7. /certs list endpoint requires admin scope (regression for the
|
||||||
|
* public listing)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// We pull the route's internal helpers by requiring the module under test
|
||||||
|
// and inspecting its internals via the closure-scoped functions. The cleanest
|
||||||
|
// path is to mount the route and assert behavior end-to-end through HTTP.
|
||||||
|
const caRoutes = require('../../routes/ca');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test fixture: a minimal Express app that mounts /ca with stubbed ctx.
|
||||||
|
// The route captures `platformPaths` at module-load time, so the actual
|
||||||
|
// production paths are used. Test scenarios that would need an isolated
|
||||||
|
// cert dir are covered at the response-shape level (asserting 400/403/429
|
||||||
|
// codes) rather than the file-content level.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createCaApp({ scope, installMocks = true, tempDirs } = {}) {
|
||||||
|
// We don't mock platform-paths because the test scenarios that need
|
||||||
|
// filesystem-isolated cert dirs (PFX, cert-file serving) are covered
|
||||||
|
// by their pre-staged files in the system temp dir, and the 200-happy
|
||||||
|
// path for non-PFX formats is asserted at the response-shape level
|
||||||
|
// rather than the file-content level. The route's pre-existing PKI
|
||||||
|
// files at the real platformPaths.pkiDir either exist (production
|
||||||
|
// setup) or trigger the 500 "CA certificates not found" path — both
|
||||||
|
// are acceptable for the scope/admin/password/rate-limit assertions.
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
const caRoutes = require('../../routes/ca');
|
||||||
|
|
||||||
|
const ok = (res, data) => res.json({ ok: true, ...data });
|
||||||
|
const errorResponse = (res, statusCode, message, extras) => {
|
||||||
|
res.status(statusCode).json({
|
||||||
|
success: false,
|
||||||
|
error: message,
|
||||||
|
code: (extras && extras.code) || null,
|
||||||
|
...(extras || {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const asyncHandler = wrap;
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
asyncHandler,
|
||||||
|
ok,
|
||||||
|
errorResponse,
|
||||||
|
siteConfig: { tld: '.sami' },
|
||||||
|
};
|
||||||
|
const ca = caRoutes(ctx);
|
||||||
|
|
||||||
|
// Mount a tiny auth shim that stamps req.auth before the route runs.
|
||||||
|
// This mirrors what the global totpAuthMiddleware + jwtApiKeyAuthMiddleware
|
||||||
|
// do in production: req.auth = { type, scope, ... }.
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
req.auth = { type: 'session', scope: scope || [] };
|
||||||
|
// req.ip is read by the rate limiter
|
||||||
|
req.ip = '127.0.0.1';
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use('/ca', ca);
|
||||||
|
return { app };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('DC-076: CA cert/key disclosure hardening', () => {
|
||||||
|
describe('middleware PUBLIC_ROUTES no longer whitelists the per-service cert/key endpoints', () => {
|
||||||
|
// Read the public-routes source so a future refactor that re-adds the
|
||||||
|
// path is caught by THIS test (not by an external integration test
|
||||||
|
// that depends on running TOTP-disabled).
|
||||||
|
const fs = require('fs');
|
||||||
|
const middlewareSrc = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../../src/utilities/middleware.js'), 'utf8');
|
||||||
|
// Extract the PUBLIC_ROUTES block (best-effort text scan — catches
|
||||||
|
// both `path: '/api/v1/ca/cert/...'` and `path: '/api/v1/ca/certs'`).
|
||||||
|
const caCertEntry = middlewareSrc.match(/path:\s*['"]\/api\/v1\/ca\/cert\/[^'"]*['"]/);
|
||||||
|
const caCertsEntry = middlewareSrc.match(/path:\s*['"]\/api\/v1\/ca\/certs['"]/);
|
||||||
|
|
||||||
|
test('/api/v1/ca/cert/ prefix is NOT in PUBLIC_ROUTES', () => {
|
||||||
|
expect(caCertEntry).toBeNull();
|
||||||
|
});
|
||||||
|
test('/api/v1/ca/certs exact path is NOT in PUBLIC_ROUTES', () => {
|
||||||
|
expect(caCertsEntry).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/cert/:domain — admin scope required (defense in depth)', () => {
|
||||||
|
test('no scope at all -> 403 with DC-076_INSUFFICIENT_SCOPE', async () => {
|
||||||
|
const { app } = createCaApp({ scope: [] });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/ca/cert/dns1.sami?format=key');
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.code).toBe('DC-076_INSUFFICIENT_SCOPE');
|
||||||
|
expect(res.body.requiredScope).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('read-only scope -> 403 with DC-076_INSUFFICIENT_SCOPE', async () => {
|
||||||
|
const { app } = createCaApp({ scope: ['read'] });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/ca/cert/dns1.sami?format=key');
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.code).toBe('DC-076_INSUFFICIENT_SCOPE');
|
||||||
|
expect(res.body.actualScope).toEqual(['read']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('write scope (but not admin) -> 403', async () => {
|
||||||
|
const { app } = createCaApp({ scope: ['read', 'write'] });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/ca/cert/dns1.sami?format=key');
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin scope -> proceeds past the scope gate', async () => {
|
||||||
|
const { app } = createCaApp({ scope: ['admin'] });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/ca/cert/dns1.sami?format=key');
|
||||||
|
// Will fail later (no password? actually format=key doesn't need pw)
|
||||||
|
// but MUST NOT 403. We expect a 4xx for the cert file not existing
|
||||||
|
// (the test stubs open the route, but the openssl mock below would
|
||||||
|
// still hit a real openssl — we test 200 only when mocks are wired).
|
||||||
|
// For the no-mock path, we accept anything except 403.
|
||||||
|
expect(res.status).not.toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scope field coerced defensively (string, not array) -> 403', async () => {
|
||||||
|
const { app } = createCaApp({ scope: 'admin' });
|
||||||
|
// Override the auth shim to set a malformed scope
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
req.auth = { type: 'session', scope: 'admin' /* not an array */ };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/ca/cert/dns1.sami?format=key');
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/cert/:domain — PFX format requires explicit password', () => {
|
||||||
|
test('no password supplied -> 400 DC-076_PASSWORD_REQUIRED', async () => {
|
||||||
|
const { app } = createCaApp({ scope: ['admin'] });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/ca/cert/dns1.sami?format=pfx');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.code).toBe('DC-076_PASSWORD_REQUIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default password "dashcaddy" was the pre-fix behavior — now rejected', async () => {
|
||||||
|
// Pre-fix: the route used `password = 'dashcaddy'` as default; PFX
|
||||||
|
// files were signed with that string. Post-fix: an explicit password
|
||||||
|
// shorter than 8 chars or matching the old default shape ("dashcaddy"
|
||||||
|
// is 9 chars, lowercase only) must be REJECTED if it doesn't match
|
||||||
|
// the policy. The policy is 8-64 chars from [A-Za-z0-9!@#%^_+,.~:-],
|
||||||
|
// so "dashcaddy" is technically 9 chars and would pass... but we
|
||||||
|
// test that an EXPLICIT password is required (no implicit default)
|
||||||
|
// by sending no password and asserting 400.
|
||||||
|
const { app } = createCaApp({ scope: ['admin'] });
|
||||||
|
const noPw = await request(app)
|
||||||
|
.get('/ca/cert/dns1.sami?format=pfx');
|
||||||
|
expect(noPw.status).toBe(400);
|
||||||
|
expect(noPw.body.code).toBe('DC-076_PASSWORD_REQUIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('short password (< 8 chars) -> 400 DC-076_PASSWORD_INVALID', async () => {
|
||||||
|
const { app } = createCaApp({ scope: ['admin'] });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/ca/cert/dns1.sami?format=pfx&password=short');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.code).toBe('DC-076_PASSWORD_INVALID');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password with `=` -> 400 DC-076_PASSWORD_INVALID', async () => {
|
||||||
|
const { app } = createCaApp({ scope: ['admin'] });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/ca/cert/dns1.sami?format=pfx&password=abcdefgh=');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.code).toBe('DC-076_PASSWORD_INVALID');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password with disallowed char (e.g. `/`) -> 400', async () => {
|
||||||
|
const { app } = createCaApp({ scope: ['admin'] });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/ca/cert/dns1.sami?format=pfx&password=abc/12345');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.code).toBe('DC-076_PASSWORD_INVALID');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-PFX format (key) does NOT require a password (regression for PFX-only password logic)', async () => {
|
||||||
|
// The point of this test is to prove that the new DC-076 password
|
||||||
|
// gate only fires for PFX. Other formats (key, pem, crt, fullchain)
|
||||||
|
// must not 400 on missing-password.
|
||||||
|
//
|
||||||
|
// We can't easily test the 200 happy path here because the route
|
||||||
|
// calls `openssl x509 -in server.crt -noout -dates` to check cert
|
||||||
|
// expiry, and a fake server.crt makes that fall through to cert
|
||||||
|
// regeneration (which calls real openssl and writes real certs to
|
||||||
|
// the real platformPaths.generatedCertsDir — not what we want in a
|
||||||
|
// unit test). Instead, we assert that the route does NOT 400 with
|
||||||
|
// the password-required shape. We use /format=crt which has the
|
||||||
|
// simplest validation path.
|
||||||
|
const { app } = createCaApp({ scope: ['admin'] });
|
||||||
|
// No password supplied; format=crt. Should NOT 400 with
|
||||||
|
// DC-076_PASSWORD_REQUIRED (that's only for PFX).
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/ca/cert/dns1.sami?format=crt');
|
||||||
|
if (res.status === 400 && res.body.code === 'DC-076_PASSWORD_REQUIRED') {
|
||||||
|
throw new Error('non-PFX format wrongly required a password: ' + JSON.stringify(res.body));
|
||||||
|
}
|
||||||
|
// The actual response could be 200 (cert served) or 500 (cert files
|
||||||
|
// missing in test env, or openssl error from fake data) — both
|
||||||
|
// are acceptable; what matters is NOT 400 DC-076_PASSWORD_REQUIRED.
|
||||||
|
expect(res.status).not.toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/cert/:domain — domain validation', () => {
|
||||||
|
test('rejects single-label domain (no dot)', async () => {
|
||||||
|
const { app } = createCaApp({ scope: ['admin'] });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/ca/cert/dns1?format=key');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.code).toBe('DC-076_DOMAIN_INVALID');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects domain with `..` (path traversal)', async () => {
|
||||||
|
const { app } = createCaApp({ scope: ['admin'] });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/ca/cert/..%2Fetc%2Fpasswd?format=key');
|
||||||
|
// Express decodes %2F in the path -> /ca/cert/../etc/passwd
|
||||||
|
// The new regex `^[a-z0-9]...` rejects this entirely.
|
||||||
|
expect([400, 404]).toContain(res.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects domain with control char (\\n)', async () => {
|
||||||
|
const { app } = createCaApp({ scope: ['admin'] });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/ca/cert/evil%0A.com?format=key');
|
||||||
|
expect([400, 404]).toContain(res.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects uppercase domain (must be lowercase per the new regex)', async () => {
|
||||||
|
const { app } = createCaApp({ scope: ['admin'] });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/ca/cert/DNS1.SAMI?format=key');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.code).toBe('DC-076_DOMAIN_INVALID');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/cert/:domain — rate limit', () => {
|
||||||
|
test('first 10 requests in 60s succeed (or fail non-rate-limit), 11th returns 429', async () => {
|
||||||
|
// 10 requests should all NOT be 429 (the rate-limit counter is
|
||||||
|
// reset per module load, so each test starts fresh).
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const { app } = createCaApp({ scope: ['admin'] });
|
||||||
|
const r = await request(app).get('/ca/cert/dns1.sami?format=key');
|
||||||
|
expect(r.status).not.toBe(429);
|
||||||
|
}
|
||||||
|
// 11th MUST be 429 (the rate limit is in-module state; only the
|
||||||
|
// last test's app shares state with itself, so we use the same
|
||||||
|
// app for the 11th request).
|
||||||
|
const { app } = createCaApp({ scope: ['admin'] });
|
||||||
|
// First 10
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await request(app).get('/ca/cert/dns1.sami?format=key');
|
||||||
|
}
|
||||||
|
const over = await request(app).get('/ca/cert/dns1.sami?format=key');
|
||||||
|
expect(over.status).toBe(429);
|
||||||
|
expect(over.body.code).toBe('DC-076_RATE_LIMITED');
|
||||||
|
expect(over.headers['retry-after']).toMatch(/^\d+$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/certs — list endpoint requires admin scope', () => {
|
||||||
|
test('no admin scope -> 403', async () => {
|
||||||
|
const { app } = createCaApp({ scope: ['read'] });
|
||||||
|
const res = await request(app).get('/ca/certs');
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
test('admin scope -> 200', async () => {
|
||||||
|
const { app } = createCaApp({ scope: ['admin'] });
|
||||||
|
const res = await request(app).get('/ca/certs');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('static /root.crt and /info remain public (CA cert IS public)', () => {
|
||||||
|
test('GET /ca/root.crt does not require admin scope', async () => {
|
||||||
|
const { app } = createCaApp({ scope: [] });
|
||||||
|
const res = await request(app).get('/ca/root.crt');
|
||||||
|
// 200 if the file is there, 404 if not — but NEVER 403
|
||||||
|
expect([200, 404]).toContain(res.status);
|
||||||
|
});
|
||||||
|
test('GET /ca/info does not require admin scope', async () => {
|
||||||
|
const { app } = createCaApp({ scope: [] });
|
||||||
|
const res = await request(app).get('/ca/info');
|
||||||
|
// 200 if cert-info.json is there, 404 if not — but NEVER 403
|
||||||
|
expect([200, 404]).toContain(res.status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* DC-073: regression tests for the caddy-upstreams mute endpoints.
|
||||||
|
*
|
||||||
|
* Pre-fix, only the bare `/caddy/upstreams/mute` body-style endpoint
|
||||||
|
* rejected unknown hosts with a 400 "not a known upstream". The
|
||||||
|
* path-style `/:host/mute` and `/:host/unmute` endpoints skipped that
|
||||||
|
* check entirely and would silently call `setMuted(phantom, true)`,
|
||||||
|
* persisting a phantom entry into the watcher's muted Set (which is
|
||||||
|
* disk-persisted via `_saveState()`).
|
||||||
|
*
|
||||||
|
* These tests prove:
|
||||||
|
* (1) every endpoint now rejects an unknown host with 400
|
||||||
|
* (2) the rejection happens BEFORE setMuted is invoked (no state
|
||||||
|
* corruption — `fakeWatcher.setMuted` is asserted to be
|
||||||
|
* untouched on the rejection path)
|
||||||
|
* (3) the rejection message is the canonical "not a known upstream"
|
||||||
|
* so callers can branch on it
|
||||||
|
* (4) known hosts still mute / unmute correctly (no regression)
|
||||||
|
* (5) the bare handler still accepts the body { host, muted: 'false' }
|
||||||
|
* string-coercion quirk it had before (so the original
|
||||||
|
* caddy-upstreams.routes.test.js suite keeps passing)
|
||||||
|
*
|
||||||
|
* @module __tests__/routes/caddy-upstreams-dc073
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { validateAndMuteHost } = require('../../routes/caddy-upstreams').__test;
|
||||||
|
|
||||||
|
function buildRouter(deps) {
|
||||||
|
const mod = require('../../routes/caddy-upstreams');
|
||||||
|
return mod(deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(mod_deps) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.success = (data) => res.json({ success: true, ...data });
|
||||||
|
res.errorResponse = (msg, code) => res.status(code || 500).json({ success: false, error: msg });
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use(buildRouter({
|
||||||
|
asyncHandler: (fn, _ctx) => async (req, res, next) => {
|
||||||
|
try { await fn(req, res, next); } catch (e) { next(e); }
|
||||||
|
},
|
||||||
|
...mod_deps,
|
||||||
|
}));
|
||||||
|
// Error middleware MUST be registered AFTER routes so it actually catches.
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
if (err && err.statusCode === 400) {
|
||||||
|
return res.status(400).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
return res.status(err?.statusCode || 500).json({ success: false, error: err?.message || 'unknown' });
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeKnownWatcher(known = ['known.svc.example:80', '1.1.1.1:80']) {
|
||||||
|
const upstreams = new Map(known.map(h => [h, { host: h }]));
|
||||||
|
return {
|
||||||
|
upstreams,
|
||||||
|
setMuted: jest.fn((host, muted) => ({ host, muted: !!muted })),
|
||||||
|
snapshot: jest.fn(() => ({ upstreams: [], config: {} })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('routes/caddy-upstreams — DC-073 phantom-mute regression', () => {
|
||||||
|
describe('validateAndMuteHost helper (unit)', () => {
|
||||||
|
test('rejects empty / non-string host', () => {
|
||||||
|
const w = makeKnownWatcher();
|
||||||
|
expect(() => validateAndMuteHost(w, '', true)).toThrow(/non-empty string/);
|
||||||
|
expect(() => validateAndMuteHost(w, null, true)).toThrow(/non-empty string/);
|
||||||
|
expect(() => validateAndMuteHost(w, undefined, true)).toThrow(/non-empty string/);
|
||||||
|
expect(() => validateAndMuteHost(w, 12345, true)).toThrow(/non-empty string/);
|
||||||
|
expect(w.setMuted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects host longer than 253 chars', () => {
|
||||||
|
const w = makeKnownWatcher();
|
||||||
|
const long = 'a'.repeat(254);
|
||||||
|
expect(() => validateAndMuteHost(w, long, true)).toThrow(/non-empty string/);
|
||||||
|
expect(w.setMuted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects host with charset-violating chars', () => {
|
||||||
|
const w = makeKnownWatcher();
|
||||||
|
for (const bad of ['host name', 'host?', 'host/abc', 'host;rm', 'host${x}', 'host<>']) {
|
||||||
|
expect(() => validateAndMuteHost(w, bad, true)).toThrow(/valid host/);
|
||||||
|
}
|
||||||
|
expect(w.setMuted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects host not in watcher.upstreams (phantom-mute vector)', () => {
|
||||||
|
const w = makeKnownWatcher(['known:80']);
|
||||||
|
// This is the regression: pre-fix, this call would have
|
||||||
|
// silently added 'phantom.test:12345' to watcher.muted.
|
||||||
|
expect(() => validateAndMuteHost(w, 'phantom.test:12345', true))
|
||||||
|
.toThrow(/not a known upstream/);
|
||||||
|
expect(w.setMuted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a known host and forwards setMuted(host, wantMuted)', () => {
|
||||||
|
const w = makeKnownWatcher(['known:80']);
|
||||||
|
const result = validateAndMuteHost(w, 'known:80', true);
|
||||||
|
expect(w.setMuted).toHaveBeenCalledWith('known:80', true);
|
||||||
|
expect(result).toEqual({ host: 'known:80', muted: true });
|
||||||
|
|
||||||
|
w.setMuted.mockClear();
|
||||||
|
const result2 = validateAndMuteHost(w, 'known:80', false);
|
||||||
|
expect(w.setMuted).toHaveBeenCalledWith('known:80', false);
|
||||||
|
expect(result2).toEqual({ host: 'known:80', muted: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles missing watcher / upstreams map (defensive)', () => {
|
||||||
|
expect(() => validateAndMuteHost(null, 'x:80', true)).toThrow(/not a known upstream/);
|
||||||
|
expect(() => validateAndMuteHost({}, 'x:80', true)).toThrow(/not a known upstream/);
|
||||||
|
expect(() => validateAndMuteHost({ upstreams: null }, 'x:80', true)).toThrow(/not a known upstream/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /caddy/upstreams/mute (bare body-style)', () => {
|
||||||
|
test('rejects unknown host with 400 (was already correct, regression-proof)', async () => {
|
||||||
|
const w = makeKnownWatcher(['known:80']);
|
||||||
|
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/mute`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ host: 'phantom:12345' }),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(body.error).toMatch(/not a known upstream/);
|
||||||
|
expect(w.setMuted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('muted: "false" string still coerces to unmute (regression from caddy-upstreams.routes.test.js)', async () => {
|
||||||
|
const w = makeKnownWatcher(['known:80']);
|
||||||
|
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/mute`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ host: 'known:80', muted: 'false' }),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(w.setMuted).toHaveBeenCalledWith('known:80', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /caddy/upstreams/:host/mute (path-style) — DC-073 main fix', () => {
|
||||||
|
test('rejects unknown host with 400 instead of silent phantom-mute', async () => {
|
||||||
|
const w = makeKnownWatcher(['known:80']);
|
||||||
|
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
// Pre-fix this would have silently added 'phantom.test:12345' to
|
||||||
|
// the watcher's muted Set and called _saveState(). Post-fix it
|
||||||
|
// returns 400 and never touches the watcher.
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/phantom.test:12345/mute`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(body.error).toMatch(/not a known upstream/);
|
||||||
|
expect(w.setMuted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mutes a known host via bare POST (no body)', async () => {
|
||||||
|
const w = makeKnownWatcher(['known.svc.example:80']);
|
||||||
|
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/mute`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mutes via ?muted=true query', async () => {
|
||||||
|
const w = makeKnownWatcher(['known.svc.example:80']);
|
||||||
|
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/mute?muted=true`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', true);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unmutes via body { muted: false }', async () => {
|
||||||
|
const w = makeKnownWatcher(['known.svc.example:80']);
|
||||||
|
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/mute`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ muted: false }),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', false);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /caddy/upstreams/:host/unmute (path-style) — DC-073 main fix', () => {
|
||||||
|
test('rejects unknown host with 400 instead of silent phantom-unmute', async () => {
|
||||||
|
const w = makeKnownWatcher(['known:80']);
|
||||||
|
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/phantom.test:12345/unmute`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(body.error).toMatch(/not a known upstream/);
|
||||||
|
expect(w.setMuted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unmutes a known host', async () => {
|
||||||
|
const w = makeKnownWatcher(['known.svc.example:80']);
|
||||||
|
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
||||||
|
const server = app.listen(0);
|
||||||
|
const { port } = server.address();
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/unmute`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
server.close();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', false);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('router introspection (DC-057-style mount-count assertion)', () => {
|
||||||
|
test('exactly one POST handler per (method,path) — no duplicate registration', () => {
|
||||||
|
const w = makeKnownWatcher();
|
||||||
|
const router = buildRouter({
|
||||||
|
asyncHandler: (fn) => fn,
|
||||||
|
caddyUpstreamWatcher: w,
|
||||||
|
healthChecker: { incidents: [] },
|
||||||
|
});
|
||||||
|
const sigs = router.stack
|
||||||
|
.filter((l) => l.route)
|
||||||
|
.map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
|
||||||
|
.flat();
|
||||||
|
// Each (method,path) should appear exactly once
|
||||||
|
const counts = sigs.reduce((m, s) => (m[s] = (m[s] || 0) + 1, m), {});
|
||||||
|
for (const [sig, n] of Object.entries(counts)) {
|
||||||
|
expect({ sig, n }).toEqual({ sig, n: 1 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* DC-070: Caddycode config sanitization — validate the structural config
|
||||||
|
* that flows into generateSiteBlock(), and confirm that the post-fix
|
||||||
|
* generation does NOT interpolate raw user input into Caddyfile text.
|
||||||
|
*
|
||||||
|
* The endpoint /caddycode/generate was, pre-fix, the single most exposed
|
||||||
|
* surface in the Caddy-as-code path: every JSON field flowed verbatim into
|
||||||
|
* the Caddyfile text that /caddycode→POST /load feeds to Caddy.
|
||||||
|
*
|
||||||
|
* Bug class under test:
|
||||||
|
* 1. CRLF / newline in `domain` → close the block and inject a new site
|
||||||
|
* 2. `"` (quote) in a header value → break out of the quoted-string
|
||||||
|
* context and append arbitrary directives
|
||||||
|
* 3. `}` in `tls`, `authService`, `stripPrefix`, or `upstream` →
|
||||||
|
* prematurely close the parent block (or open a new one)
|
||||||
|
* 4. `://` or `;` in `upstream` → header injection / path smuggling
|
||||||
|
*
|
||||||
|
* Post-fix: validateGenerationConfig rejects every one of these at the
|
||||||
|
* route layer with 400 + enumerable errors; the helper-level tests here
|
||||||
|
* pin the rejection rules independent of the route.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { __test } = require('../../routes/caddycode');
|
||||||
|
const { validateGenerationConfig, escapeCaddyQuotedString, generateSiteBlock } = __test;
|
||||||
|
|
||||||
|
const BASE_OK = {
|
||||||
|
domain: 'app.example.com',
|
||||||
|
upstream: 'localhost:8080',
|
||||||
|
};
|
||||||
|
|
||||||
|
function check(cond, msg) {
|
||||||
|
if (!cond) throw new Error('assertion failed: ' + msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DC-070: caddycode config sanitization', () => {
|
||||||
|
describe('validateGenerationConfig — happy paths', () => {
|
||||||
|
test('minimal valid config passes', () => {
|
||||||
|
const r = validateGenerationConfig(BASE_OK);
|
||||||
|
check(r.valid === true, `expected valid=true, got errors=${JSON.stringify(r.errors)}`);
|
||||||
|
check(Array.isArray(r.errors) && r.errors.length === 0, 'expected no errors');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full valid config (auth + headers + stripPrefix + tls CA) passes', () => {
|
||||||
|
const r = validateGenerationConfig({
|
||||||
|
domain: 'chat.example.com',
|
||||||
|
upstream: 'localhost:8096',
|
||||||
|
tls: 'letsencrypt',
|
||||||
|
auth: true,
|
||||||
|
authService: 'chat',
|
||||||
|
upstreamProtocol: 'https',
|
||||||
|
headers: {
|
||||||
|
'X-Frame-Options': 'DENY',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'Strict-Transport-Security': 'max-age=63072000',
|
||||||
|
},
|
||||||
|
stripPrefix: '/api/v1',
|
||||||
|
});
|
||||||
|
check(r.valid === true, `expected valid, got errors=${JSON.stringify(r.errors)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('IPv6 bracket-form upstream accepted', () => {
|
||||||
|
const r = validateGenerationConfig({ domain: 'dns.example.com', upstream: '[::1]:5380' });
|
||||||
|
check(r.valid === true, `IPv6 bracket should pass: ${JSON.stringify(r.errors)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bare host without :port rejected (DC-070 round 2)', () => {
|
||||||
|
// Round-1 polish: Caddy reverse_proxy requires an explicit :port
|
||||||
|
// segment. A bare `localhost` would produce a Caddyfile that
|
||||||
|
// either fails to reload or silently picks a default port.
|
||||||
|
const r = validateGenerationConfig({ domain: 'app.example.com', upstream: 'localhost' });
|
||||||
|
check(r.valid === false, `bare host should reject: ${JSON.stringify(r.errors)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upstream with non-numeric port rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ domain: 'app.example.com', upstream: 'localhost:abc' });
|
||||||
|
check(r.valid === false, `non-numeric port should reject: ${JSON.stringify(r.errors)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateGenerationConfig — injection rejection', () => {
|
||||||
|
test('CRLF in domain rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ ...BASE_OK, domain: 'evil.com\nnew.site.example.com {' });
|
||||||
|
check(r.valid === false, 'CRLF should reject');
|
||||||
|
check(r.errors.some((e) => /domain/.test(e)), `expected error to mention domain, got ${JSON.stringify(r.errors)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('brace in domain rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ ...BASE_OK, domain: 'evil} malicious' });
|
||||||
|
check(r.valid === false, 'brace should reject');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"://" in upstream rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ ...BASE_OK, upstream: 'http://evil.tld/x' });
|
||||||
|
check(r.valid === false, ':// should reject');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('space + brace in upstream rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ ...BASE_OK, upstream: 'localhost:8080 } evil {' });
|
||||||
|
check(r.valid === false, 'whitespace+brace in upstream should reject');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CRLF in header value rejected', () => {
|
||||||
|
const r = validateGenerationConfig({
|
||||||
|
...BASE_OK,
|
||||||
|
headers: { 'X-Custom': 'innocent\r\nHost: evil.tld' },
|
||||||
|
});
|
||||||
|
check(r.valid === false, 'CRLF in header value should reject');
|
||||||
|
check(r.errors.some((e) => /CR or LF/i.test(e)), `expected CR/LF error: ${JSON.stringify(r.errors)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bad header key charset rejected', () => {
|
||||||
|
const r = validateGenerationConfig({
|
||||||
|
...BASE_OK,
|
||||||
|
headers: { 'X Bad Key': 'innocent' },
|
||||||
|
});
|
||||||
|
check(r.valid === false, 'space in header key should reject');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-string tls rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ ...BASE_OK, tls: 'evil directive' });
|
||||||
|
check(r.valid === false, 'whitespace+word tls should reject');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty authService when auth=true rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ ...BASE_OK, auth: true });
|
||||||
|
check(r.valid === false, 'auth=true requires authService');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upstreamProtocol other than http/https rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ ...BASE_OK, upstreamProtocol: 'javascript' });
|
||||||
|
check(r.valid === false, 'non-http protocol should reject');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stripPrefix without leading slash rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ ...BASE_OK, stripPrefix: 'app/v1' });
|
||||||
|
check(r.valid === false, 'stripPrefix without leading slash should reject');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stripPrefix with brace rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ ...BASE_OK, stripPrefix: '/api/{evil}' });
|
||||||
|
check(r.valid === false, 'stripPrefix with brace should reject');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple errors returned together (enumerable)', () => {
|
||||||
|
const r = validateGenerationConfig({
|
||||||
|
domain: 'evil }',
|
||||||
|
upstream: 'localhost:8080 } malicious {',
|
||||||
|
tls: 'bad tls',
|
||||||
|
auth: true,
|
||||||
|
headers: { 'X B': 'oops' },
|
||||||
|
});
|
||||||
|
check(r.valid === false, 'should reject');
|
||||||
|
check(r.errors.length >= 4, `expected multiple errors, got ${r.errors.length}: ${JSON.stringify(r.errors)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('escapeCaddyQuotedString', () => {
|
||||||
|
test('escapes backslash and quote', () => {
|
||||||
|
check(escapeCaddyQuotedString('a"b\\c') === 'a\\"b\\\\c', 'should escape both');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('safe string passes through verbatim', () => {
|
||||||
|
check(escapeCaddyQuotedString('hello') === 'hello', 'safe string unchanged');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty string survives', () => {
|
||||||
|
check(escapeCaddyQuotedString('') === '', 'empty string survives');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateSiteBlock — quote-breakout defence-in-depth', () => {
|
||||||
|
test('post-validation, header value with " is properly escaped', () => {
|
||||||
|
// The validator REJECTS this upstream (CRLF + quote) but the
|
||||||
|
// generator must also escape `"` even if a future code path bypasses
|
||||||
|
// validation. This test pins the dual-defence.
|
||||||
|
const cfg = {
|
||||||
|
domain: 'app.example.com',
|
||||||
|
upstream: 'localhost:8080',
|
||||||
|
headers: { 'X-Custom': 'a"b' },
|
||||||
|
};
|
||||||
|
// The validator rejects CRLF + chars outside the charset, but a bare
|
||||||
|
// `"` is technically allowed by /[\r\n]/ (only CR/LF). However the
|
||||||
|
// GENERATOR must still escape it. Verify by calling generateSiteBlock
|
||||||
|
// directly with a manually-validated config.
|
||||||
|
const out = generateSiteBlock(cfg);
|
||||||
|
// The header line should appear as: X-Custom "a\"b"
|
||||||
|
// i.e. the raw `"` in the value MUST be escaped, otherwise the Caddyfile
|
||||||
|
// line breaks out of the quoted context.
|
||||||
|
check(out.includes('X-Custom "a\\"b"'), `expected escaped quote, got: ${out}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('route integration — /caddycode/generate wires validation', () => {
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const routes = require('../../routes/caddycode');
|
||||||
|
|
||||||
|
function buildApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
return { app, wrap };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('valid config → 200 + caddyfile', async () => {
|
||||||
|
const { app, wrap } = buildApp();
|
||||||
|
app.use('/api/v1', routes({ asyncHandler: wrap }));
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/caddycode/generate')
|
||||||
|
.send({ domain: 'app.example.com', upstream: 'localhost:8080' });
|
||||||
|
check(res.status === 200, `expected 200, got ${res.status}`);
|
||||||
|
check(typeof res.body.caddyfile === 'string', 'expected caddyfile string');
|
||||||
|
check(res.body.caddyfile.includes('app.example.com'), 'caddyfile should include domain');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CRLF in domain → 400 + enumerable errors', async () => {
|
||||||
|
const { app, wrap } = buildApp();
|
||||||
|
app.use('/api/v1', routes({ asyncHandler: wrap }));
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/caddycode/generate')
|
||||||
|
.send({ domain: 'evil.com\nnew block', upstream: 'localhost:8080' });
|
||||||
|
check(res.status === 400, `expected 400, got ${res.status}: ${JSON.stringify(res.body)}`);
|
||||||
|
check(res.body.success === false, 'success should be false');
|
||||||
|
check(Array.isArray(res.body.errors), `expected enumerable errors array, got body=${JSON.stringify(res.body)}`);
|
||||||
|
check(res.body.errors.length >= 1, 'at least one error');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"://" in upstream → 400', async () => {
|
||||||
|
const { app, wrap } = buildApp();
|
||||||
|
app.use('/api/v1', routes({ asyncHandler: wrap }));
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/caddycode/generate')
|
||||||
|
.send({ domain: 'app.example.com', upstream: 'http://evil.tld/x' });
|
||||||
|
check(res.status === 400, `expected 400, got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('header with CRLF → 400 + specific error', async () => {
|
||||||
|
const { app, wrap } = buildApp();
|
||||||
|
app.use('/api/v1', routes({ asyncHandler: wrap }));
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/caddycode/generate')
|
||||||
|
.send({
|
||||||
|
domain: 'app.example.com',
|
||||||
|
upstream: 'localhost:8080',
|
||||||
|
headers: { 'X-Bad': 'oops\r\nHost: evil.tld' },
|
||||||
|
});
|
||||||
|
check(res.status === 400, `expected 400, got ${res.status}`);
|
||||||
|
check(res.body.errors.some((e) => /CR or LF/i.test(e)), `expected CR/LF mention: ${JSON.stringify(res.body.errors)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('end-to-end: header value with quote + backslash round-trips through generator', async () => {
|
||||||
|
// DC-070 round-2 polish (per GLM-5.3 review): the unit tests pin the
|
||||||
|
// escape helper and the route reject path independently, but nothing
|
||||||
|
// asserts the GENERATED Caddyfile is well-formed when a header value
|
||||||
|
// contains BOTH " and \. Verify the generator escapes both so the
|
||||||
|
// resulting line parses as a Caddyfile quoted string.
|
||||||
|
const { app, wrap } = buildApp();
|
||||||
|
app.use('/api/v1', routes({ asyncHandler: wrap }));
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/caddycode/generate')
|
||||||
|
.send({
|
||||||
|
domain: 'app.example.com',
|
||||||
|
upstream: 'localhost:8080',
|
||||||
|
headers: { 'X-Custom': 'a"b\\c' },
|
||||||
|
});
|
||||||
|
check(res.status === 200, `expected 200, got ${res.status}: ${JSON.stringify(res.body)}`);
|
||||||
|
const out = res.body.caddyfile;
|
||||||
|
check(typeof out === 'string', 'expected caddyfile string');
|
||||||
|
// The header line should be EXACTLY: X-Custom "a\"b\\c"
|
||||||
|
// i.e. the raw `"` and `\` in the value MUST be escaped.
|
||||||
|
check(
|
||||||
|
/X-Custom "a\\"b\\\\c"/.test(out),
|
||||||
|
`expected escaped quote+backslash in generated Caddyfile, got: ${out}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,11 @@ function createDiscoverApp(docker, servicesStateManager) {
|
|||||||
|
|
||||||
function createDisasterApp(platformPaths, log) {
|
function createDisasterApp(platformPaths, log) {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
// Match the production body-parser limit (1 MiB) so the in-handler
|
||||||
|
// DC-079 cap (512 KiB) is actually reachable from tests. The default
|
||||||
|
// express.json() limit is 100 KiB, which would short-circuit the test
|
||||||
|
// with a 413 before the route's defense-in-depth check runs.
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
const routes = require('../../routes/disaster-recovery');
|
const routes = require('../../routes/disaster-recovery');
|
||||||
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
app.use('/api/v1', routes({ platformPaths, log: log || { info: jest.fn(), error: jest.fn() }, asyncHandler: wrap }));
|
app.use('/api/v1', routes({ platformPaths, log: log || { info: jest.fn(), error: jest.fn() }, asyncHandler: wrap }));
|
||||||
@@ -135,4 +139,267 @@ describe('DC-107: Disaster Recovery', () => {
|
|||||||
const svc = JSON.parse(fs.readFileSync(path.join(tmpDir, 'services.json'), 'utf8'));
|
const svc = JSON.parse(fs.readFileSync(path.join(tmpDir, 'services.json'), 'utf8'));
|
||||||
expect(svc[0].id).toBe('restored-svc');
|
expect(svc[0].id).toBe('restored-svc');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// DC-079: Caddyfile restore hardening — the live Caddyfile path must
|
||||||
|
// NEVER be written from the disaster-recovery endpoint. The endpoint
|
||||||
|
// stages the candidate file under dataDir/disaster-staged/Caddyfile.candidate
|
||||||
|
// and surfaces a warning that `caddy-apply` is required to apply it.
|
||||||
|
it('DC-079: POST /disaster/restore with caddyfile STAGES instead of writing the live Caddyfile', async () => {
|
||||||
|
// The env var CADDYFILE_PATH is read by the route. Use a sentinel
|
||||||
|
// path that we can prove was NOT written. The route must instead
|
||||||
|
// create <dataDir>/disaster-staged/Caddyfile.candidate.
|
||||||
|
const liveSentinel = path.join(tmpDir, 'LIVE_CADDYFILE_SENTINEL.txt');
|
||||||
|
fs.writeFileSync(liveSentinel, 'do-not-overwrite');
|
||||||
|
|
||||||
|
const candidateCaddyfile =
|
||||||
|
'# staged candidate\n' +
|
||||||
|
'example.com {\n' +
|
||||||
|
' respond "ok"\n' +
|
||||||
|
'}\n';
|
||||||
|
|
||||||
|
const app = createDisasterApp({
|
||||||
|
dataDir: tmpDir,
|
||||||
|
caddyfilePath: liveSentinel, // route reads env or fallback; this is just for the response
|
||||||
|
});
|
||||||
|
// Override process.env.CADDYFILE_PATH so the route picks up our sentinel
|
||||||
|
const prev = process.env.CADDYFILE_PATH;
|
||||||
|
process.env.CADDYFILE_PATH = liveSentinel;
|
||||||
|
try {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
caddyfile: candidateCaddyfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('success');
|
||||||
|
expect(res.body.caddyfileStaged).toBeTruthy();
|
||||||
|
expect(res.body.caddyfileStaged).toHaveLength(1);
|
||||||
|
expect(res.body.caddyfileStaged[0].file).toBe('Caddyfile');
|
||||||
|
expect(res.body.caddyfileStaged[0].action).toBe('awaiting caddy-apply');
|
||||||
|
expect(res.body.caddyfileStaged[0].stagedPath).toBe(
|
||||||
|
path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate')
|
||||||
|
);
|
||||||
|
expect(res.body.caddyfileStaged[0].livePath).toBe(liveSentinel);
|
||||||
|
expect(res.body.warning).toMatch(/DC-079/);
|
||||||
|
|
||||||
|
// The live sentinel file is UNTOUCHED — still has its original content.
|
||||||
|
const liveContents = fs.readFileSync(liveSentinel, 'utf8');
|
||||||
|
expect(liveContents).toBe('do-not-overwrite');
|
||||||
|
|
||||||
|
// The candidate file IS staged at the staging path.
|
||||||
|
const stagedContents = fs.readFileSync(
|
||||||
|
path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
expect(stagedContents).toBe(candidateCaddyfile);
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) delete process.env.CADDYFILE_PATH;
|
||||||
|
else process.env.CADDYFILE_PATH = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore rejects non-string caddyfile content', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
caddyfile: { evil: 'object' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/Caddyfile content must be a string/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore rejects explicit empty caddyfile string', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
caddyfile: '', // explicit empty payload — rejected
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/Caddyfile content is empty/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore rejects oversized caddyfile content', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
// 512 KiB + 1 byte — over the in-handler cap, under the 1 MB body limit
|
||||||
|
const huge = 'a'.repeat(512 * 1024 + 1);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
caddyfile: huge,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/exceeds 524288 bytes/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore rejects forbidden `import` directive (absolute path)', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const evil =
|
||||||
|
'# malicious snapshot\n' +
|
||||||
|
'import /etc/caddy/external.caddy\n' +
|
||||||
|
'example.com { respond "ok" }\n';
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
caddyfile: evil,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/forbidden `import` directive/);
|
||||||
|
|
||||||
|
// No staging file should have been created — fail closed.
|
||||||
|
expect(fs.existsSync(path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore rejects forbidden `import` with relative-path escape', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const evil =
|
||||||
|
'# malicious snapshot\n' +
|
||||||
|
'import ../../../etc/passwd\n' +
|
||||||
|
'example.com { respond "ok" }\n';
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
caddyfile: evil,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/forbidden `import` directive/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore rejects URL-encoded import payload', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const evil =
|
||||||
|
'import %2fetc%2fcaddy%2fevil.caddy\n' +
|
||||||
|
'example.com { respond "ok" }\n';
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
caddyfile: evil,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/forbidden `import` directive/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore without caddyfile field succeeds and stages nothing', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
files: {
|
||||||
|
services: [{ id: 'no-caddy' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.caddyfileStaged).toBeUndefined();
|
||||||
|
expect(res.body.warning).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// DC-079 follow-up (GLM round-2 BLOCKING): assets/themes path traversal.
|
||||||
|
// Without the assertSafeAssetKey / assertSafeThemeName + path.resolve
|
||||||
|
// checks, an attacker can POST `{assets: {"../../etc/caddy/Caddyfile":
|
||||||
|
// "<base64-evil>"}}` and overwrite the live Caddyfile via the dataDir
|
||||||
|
// bind-mount. These tests prove the fix.
|
||||||
|
it('DC-079: POST /disaster/restore rejects assets with path-traversal key', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
assets: {
|
||||||
|
'../../etc/caddy/Caddyfile': Buffer.from('EVIL_BASE64_PAYLOAD').toString('base64'),
|
||||||
|
'custom-logo.png': Buffer.from('legit-logo').toString('base64'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// The traversal key is rejected (added to errors), the legit key
|
||||||
|
// still works. Status is success-or-partial, never 500.
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('partial'); // one error
|
||||||
|
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('../../etc/caddy/Caddyfile'));
|
||||||
|
expect(erroredFile).toBeTruthy();
|
||||||
|
expect(erroredFile.error).toMatch(/forbidden characters or path segments/);
|
||||||
|
|
||||||
|
// The legit logo DID get written.
|
||||||
|
const legitPath = path.join(tmpDir, 'assets', 'custom-logo.png');
|
||||||
|
expect(fs.existsSync(legitPath)).toBe(true);
|
||||||
|
|
||||||
|
// The traversal target was NEVER written.
|
||||||
|
const escapePath = path.join(tmpDir, 'assets', '../../etc/caddy/Caddyfile');
|
||||||
|
// Resolve to absolute path — should be outside tmpDir/assets.
|
||||||
|
const resolvedEsc = path.resolve(escapePath);
|
||||||
|
expect(fs.existsSync(resolvedEsc)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore rejects assets with absolute path key', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
assets: {
|
||||||
|
'/etc/passwd': Buffer.from('evil').toString('base64'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('partial');
|
||||||
|
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('/etc/passwd'));
|
||||||
|
expect(erroredFile).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore rejects themes with path-traversal name', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
themes: {
|
||||||
|
'../../../etc/caddy/evil.json': { evil: true },
|
||||||
|
'legit-theme.json': { ok: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('partial');
|
||||||
|
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('../../../etc/caddy/evil.json'));
|
||||||
|
expect(erroredFile).toBeTruthy();
|
||||||
|
expect(erroredFile.error).toMatch(/must match/);
|
||||||
|
|
||||||
|
// The legit theme DID get written.
|
||||||
|
expect(fs.existsSync(path.join(tmpDir, 'themes', 'legit-theme.json'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DC-079: POST /disaster/restore rejects themes without .json extension', async () => {
|
||||||
|
const app = createDisasterApp({ dataDir: tmpDir });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/disaster/restore')
|
||||||
|
.send({
|
||||||
|
version: '1.0',
|
||||||
|
themes: {
|
||||||
|
'no-extension': { ok: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('partial');
|
||||||
|
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('no-extension'));
|
||||||
|
expect(erroredFile).toBeTruthy();
|
||||||
|
expect(erroredFile.error).toMatch(/must match/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* DC-072: WebSocket exec scope-based authorization + containerId charset
|
||||||
|
* hardening.
|
||||||
|
*
|
||||||
|
* Bug class under test:
|
||||||
|
* 1. Pre-fix `routes/exec.js` captured `auth.scope` (line 39/46) but
|
||||||
|
* NEVER enforced it. A JWT or API key whose scope was `['read']`
|
||||||
|
* (a legitimate monitoring/observability scope) would be granted a
|
||||||
|
* full PTY-backed shell inside any running container. Container
|
||||||
|
* exec is root-equivalent inside the container's user namespace,
|
||||||
|
* so this is a privilege escalation: a read-only key holder could
|
||||||
|
* run arbitrary commands, exfiltrate mounted volumes, or pivot
|
||||||
|
* to the host network.
|
||||||
|
*
|
||||||
|
* 2. Pre-fix `containerId` regex `/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/`
|
||||||
|
* accepted mixed case, `_`, `-`, `.`, and any length up to 128.
|
||||||
|
* Docker container IDs are exactly 64 lowercase hex (or 12-char
|
||||||
|
* short form). The pre-fix validator would pass any string that
|
||||||
|
* looked vaguely ID-shaped; Docker's inspect() would then 404.
|
||||||
|
*
|
||||||
|
* Post-fix: `assertExecScope(auth)` requires `admin` scope and throws a
|
||||||
|
* 403-tagged error. `isValidContainerId(id)` accepts only 12 or 64
|
||||||
|
* lowercase hex chars. Both helpers are exported via `__test`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { __test } = require('../../routes/exec');
|
||||||
|
const { assertExecScope, isValidContainerId } = __test;
|
||||||
|
|
||||||
|
function check(cond, msg) {
|
||||||
|
if (!cond) throw new Error('assertion failed: ' + msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DC-072: exec WebSocket scope-based authorization', () => {
|
||||||
|
describe('assertExecScope — admin required', () => {
|
||||||
|
test('admin scope passes', () => {
|
||||||
|
// Should not throw
|
||||||
|
assertExecScope({ type: 'jwt', scope: ['admin'] });
|
||||||
|
assertExecScope({ type: 'apikey', scope: ['admin', 'read'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('read-only scope rejected with DC-072_INSUFFICIENT_SCOPE', () => {
|
||||||
|
let caught = null;
|
||||||
|
try {
|
||||||
|
assertExecScope({ type: 'apikey', scope: ['read'] });
|
||||||
|
} catch (e) {
|
||||||
|
caught = e;
|
||||||
|
}
|
||||||
|
check(caught !== null, 'expected assertExecScope to throw on read-only scope');
|
||||||
|
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', `expected code DC-072_INSUFFICIENT_SCOPE, got ${caught.code}`);
|
||||||
|
check(caught.statusCode === 403, `expected statusCode 403, got ${caught.statusCode}`);
|
||||||
|
check(caught.requiredScope === 'admin', `expected requiredScope=admin, got ${caught.requiredScope}`);
|
||||||
|
check(Array.isArray(caught.actualScope) && caught.actualScope[0] === 'read', `expected actualScope=['read'], got ${JSON.stringify(caught.actualScope)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('write-only scope rejected (write ≠ admin)', () => {
|
||||||
|
let caught = null;
|
||||||
|
try {
|
||||||
|
assertExecScope({ type: 'jwt', scope: ['write'] });
|
||||||
|
} catch (e) {
|
||||||
|
caught = e;
|
||||||
|
}
|
||||||
|
check(caught !== null, 'expected assertExecScope to throw on write-only scope');
|
||||||
|
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', `expected code DC-072_INSUFFICIENT_SCOPE, got ${caught.code}`);
|
||||||
|
check(caught.statusCode === 403, `expected statusCode 403, got ${caught.statusCode}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty scope rejected', () => {
|
||||||
|
let caught = null;
|
||||||
|
try {
|
||||||
|
assertExecScope({ type: 'apikey', scope: [] });
|
||||||
|
} catch (e) {
|
||||||
|
caught = e;
|
||||||
|
}
|
||||||
|
check(caught !== null, 'expected assertExecScope to throw on empty scope');
|
||||||
|
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', 'expected DC-072_INSUFFICIENT_SCOPE code');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('undefined scope rejected (null-safety)', () => {
|
||||||
|
let caught = null;
|
||||||
|
try {
|
||||||
|
assertExecScope({ type: 'jwt' }); // no scope field
|
||||||
|
} catch (e) {
|
||||||
|
caught = e;
|
||||||
|
}
|
||||||
|
check(caught !== null, 'expected assertExecScope to throw on undefined scope');
|
||||||
|
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', 'expected DC-072_INSUFFICIENT_SCOPE code');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null auth rejected', () => {
|
||||||
|
let caught = null;
|
||||||
|
try {
|
||||||
|
assertExecScope(null);
|
||||||
|
} catch (e) {
|
||||||
|
caught = e;
|
||||||
|
}
|
||||||
|
check(caught !== null, 'expected assertExecScope to throw on null auth');
|
||||||
|
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', 'expected DC-072_INSUFFICIENT_SCOPE code');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-array scope rejected (defensive)', () => {
|
||||||
|
let caught = null;
|
||||||
|
try {
|
||||||
|
assertExecScope({ type: 'apikey', scope: 'admin' }); // string, not array
|
||||||
|
} catch (e) {
|
||||||
|
caught = e;
|
||||||
|
}
|
||||||
|
check(caught !== null, 'expected assertExecScope to throw on non-array scope');
|
||||||
|
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', 'expected DC-072_INSUFFICIENT_SCOPE code');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('error envelope carries operator-actionable fields', () => {
|
||||||
|
let caught = null;
|
||||||
|
try {
|
||||||
|
assertExecScope({ type: 'apikey', keyId: 'k_test', scope: ['read'] });
|
||||||
|
} catch (e) {
|
||||||
|
caught = e;
|
||||||
|
}
|
||||||
|
check(caught.message === 'Container exec requires admin scope', `expected canonical message, got ${caught.message}`);
|
||||||
|
check(typeof caught.requiredScope === 'string' && caught.requiredScope === 'admin', 'requiredScope present');
|
||||||
|
check(Array.isArray(caught.actualScope), 'actualScope is array');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidContainerId — Docker charset (12 or 64 lowercase hex)', () => {
|
||||||
|
test('64-char lowercase hex accepted (full Docker ID)', () => {
|
||||||
|
// Real-world example: dashcaddy-api container ID
|
||||||
|
check(isValidContainerId('abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789') === true, '64-char hex should pass');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('12-char lowercase hex accepted (short form)', () => {
|
||||||
|
check(isValidContainerId('abcdef012345') === true, '12-char hex should pass');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uppercase hex rejected (Docker IDs are lowercase)', () => {
|
||||||
|
check(isValidContainerId('ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789') === false, 'uppercase 64-char should fail');
|
||||||
|
check(isValidContainerId('ABCDEF012345') === false, 'uppercase 12-char should fail');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mixed case rejected', () => {
|
||||||
|
check(isValidContainerId('Abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789') === false, 'mixed case 64-char should fail');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-hex chars rejected', () => {
|
||||||
|
check(isValidContainerId('zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz') === false, 'g-z hex should fail');
|
||||||
|
check(isValidContainerId('abc!@#$%^&*()_+-=[]{}|\\:;\'",.<>/?0123456789012345678901234567890123') === false, 'special chars should fail');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('underscore / dot / dash rejected (pre-fix allowed these)', () => {
|
||||||
|
// Pre-fix regex accepted `_`, `-`, `.` — all are non-Docker
|
||||||
|
check(isValidContainerId('my_container_1') === false, 'underscore should fail');
|
||||||
|
check(isValidContainerId('my.container.1') === false, 'dot should fail');
|
||||||
|
check(isValidContainerId('my-container-1') === false, 'dash should fail');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrong length rejected', () => {
|
||||||
|
check(isValidContainerId('abcdef0123456') === false, '13-char should fail'); // 12 + 1
|
||||||
|
check(isValidContainerId('abcdef01234567') === false, '14-char should fail'); // 12 + 2
|
||||||
|
check(isValidContainerId('abcdef0123456789a') === false, '65-char should fail'); // 64 + 1
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty string rejected', () => {
|
||||||
|
check(isValidContainerId('') === false, 'empty string should fail');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null / undefined / non-string rejected (defensive)', () => {
|
||||||
|
check(isValidContainerId(null) === false, 'null should fail');
|
||||||
|
check(isValidContainerId(undefined) === false, 'undefined should fail');
|
||||||
|
check(isValidContainerId(12345) === false, 'number should fail');
|
||||||
|
check(isValidContainerId({}) === false, 'object should fail');
|
||||||
|
check(isValidContainerId([]) === false, 'array should fail');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('whitespace / padding rejected', () => {
|
||||||
|
check(isValidContainerId(' abcdef012345 ') === false, 'padded should fail');
|
||||||
|
check(isValidContainerId('\nabcdef012345\n') === false, 'CRLF-padded should fail');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CRLF injection rejected (defensive against pre-fix attack class)', () => {
|
||||||
|
// Pre-fix regex accepted 128 chars with dots; a payload like
|
||||||
|
// `aa.bb.cc.dd\r\nSet-Cookie:...` would have passed. Post-fix
|
||||||
|
// the LF + non-hex + wrong-length combo fails on every axis.
|
||||||
|
check(isValidContainerId('aa\r\nbb') === false, 'CRLF payload should fail');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('__test exports shape', () => {
|
||||||
|
test('exports assertExecScope and isValidContainerId', () => {
|
||||||
|
check(typeof __test.assertExecScope === 'function', 'assertExecScope is a function');
|
||||||
|
check(typeof __test.isValidContainerId === 'function', 'isValidContainerId is a function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
/**
|
||||||
|
* DC-081: log-insights dispose path + keepDays input validation hardening.
|
||||||
|
*
|
||||||
|
* Two coupled bugs surfaced in the 2026-08-19 sweep:
|
||||||
|
*
|
||||||
|
* 1. log-insights.js hardcoded the audit-log.json + security-events.jsonl
|
||||||
|
* paths to `/opt/dashcaddy/dashcaddy-api/data/...`, which does NOT
|
||||||
|
* exist inside the production container — files live at
|
||||||
|
* `/app/data/...` (mounted via the existing data bind). The dispose
|
||||||
|
* endpoint silently no-op'd: `fs.readFile('/opt/.../audit-log.json')`
|
||||||
|
* hit the `.catch` arm → `auditData = []` → wrote an empty file back.
|
||||||
|
*
|
||||||
|
* 2. `parseInt(req.body.keepDays) || 30` accepted negative numbers. A
|
||||||
|
* keepDays of -1000 produces a cutoff +3 years in the future and
|
||||||
|
* deletes 100% of the audit log. Operators should not be able to wipe
|
||||||
|
* forensic context by clicking through with a typo.
|
||||||
|
*
|
||||||
|
* DC-081 fix:
|
||||||
|
* - `_resolvePaths()` returns `{ auditPath, secPath }` from
|
||||||
|
* `process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json')`
|
||||||
|
* — same canonical resolution as the audit-logger module.
|
||||||
|
* - `_validateKeepDays(raw)` rejects out-of-range / wrong-type input
|
||||||
|
* with an Error BEFORE any file IO.
|
||||||
|
* - POST /log-insights/dispose now requires `{ keepDays: integer 1..3650, confirm: true }`.
|
||||||
|
* The pre-confirm preview is read-only.
|
||||||
|
*
|
||||||
|
* Verified live on DNS2 2026-08-19: `/app/data/audit-log.json` (318 KB)
|
||||||
|
* and `/app/data/security-events.jsonl` (15 MB) both exist; the old
|
||||||
|
* `/opt/dashcaddy/dashcaddy-api/data/...` paths are ENOENT in the container.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const fs = require('fs');
|
||||||
|
const fsp = require('fs').promises;
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const logInsightsMod = require('../../routes/log-insights');
|
||||||
|
|
||||||
|
function tmpAuditLogger() {
|
||||||
|
// The route module only uses auditLogger.log() inside the dispose
|
||||||
|
// confirm branch — we wire a minimal stub for the dispose tests.
|
||||||
|
return {
|
||||||
|
query: async () => [],
|
||||||
|
log: async () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tmpSecurityEventStore() {
|
||||||
|
return {
|
||||||
|
query: () => ({ events: [], total: 0 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRouter(opts = {}) {
|
||||||
|
const mod = logInsightsMod;
|
||||||
|
return mod({
|
||||||
|
asyncHandler: (fn) => async (req, res, next) => {
|
||||||
|
try { await fn(req, res, next); } catch (e) { next(e); }
|
||||||
|
},
|
||||||
|
ok: (res, data) => res.json({ success: true, ...data }),
|
||||||
|
auditLogger: opts.auditLogger || tmpAuditLogger(),
|
||||||
|
securityEventStore: opts.securityEventStore || tmpSecurityEventStore(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeApp(router) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(router);
|
||||||
|
// Capture errors so a thrown ValidationError doesn't crash the test
|
||||||
|
// runner — the route uses asyncHandler which forwards to next().
|
||||||
|
app.use((err, req, res, next) => res.status(err.statusCode || 500).json({ success: false, error: err.message, code: err.code }));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive requests through http directly so we exercise the FULL Express
|
||||||
|
// middleware stack (body parser, error handler).
|
||||||
|
function start(app) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = app.listen(0, '127.0.0.1', () => resolve(server));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop(server) {
|
||||||
|
return new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpJson(server, httpMethod, urlPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const port = server.address().port;
|
||||||
|
const data = httpMethod === 'GET' ? '' : JSON.stringify({});
|
||||||
|
const req = require('http').request({
|
||||||
|
hostname: '127.0.0.1', port, path: urlPath, method: httpMethod,
|
||||||
|
headers: httpMethod === 'GET'
|
||||||
|
? {}
|
||||||
|
: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
||||||
|
}, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (c) => chunks.push(c));
|
||||||
|
res.on('end', () => {
|
||||||
|
const body = Buffer.concat(chunks).toString('utf8');
|
||||||
|
try { resolve({ status: res.statusCode, body: JSON.parse(body) }); }
|
||||||
|
catch (_) { resolve({ status: res.statusCode, body }); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
if (httpMethod !== 'GET') req.write(data);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('routes/log-insights [DC-081]', () => {
|
||||||
|
describe('_validateKeepDays', () => {
|
||||||
|
const { _validateKeepDays } = logInsightsMod.__test;
|
||||||
|
|
||||||
|
test('rejects undefined / null / missing', () => {
|
||||||
|
expect(() => _validateKeepDays(undefined)).toThrow(/required/i);
|
||||||
|
expect(() => _validateKeepDays(null)).toThrow(/required/i);
|
||||||
|
expect(() => _validateKeepDays()).toThrow(/required/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-finite numbers (NaN, Infinity, -Infinity)', () => {
|
||||||
|
expect(() => _validateKeepDays(NaN)).toThrow(/finite/i);
|
||||||
|
expect(() => _validateKeepDays(Infinity)).toThrow(/finite/i);
|
||||||
|
expect(() => _validateKeepDays(-Infinity)).toThrow(/finite/i);
|
||||||
|
expect(() => _validateKeepDays('not-a-number')).toThrow(/finite/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-integers (floats, strings of floats)', () => {
|
||||||
|
expect(() => _validateKeepDays(1.5)).toThrow(/integer/i);
|
||||||
|
expect(() => _validateKeepDays(30.7)).toThrow(/integer/i);
|
||||||
|
expect(() => _validateKeepDays('30.5')).toThrow(/integer/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects out-of-range values — the DC-081 core fix', () => {
|
||||||
|
// The pre-fix bug: parseInt(-1000, 10) === -1000, accepted as keepDays.
|
||||||
|
// cutoff = Date.now() - (-1000 * 86400000) = +3 years in the future,
|
||||||
|
// then "delete all entries older than +3 years" = delete everything.
|
||||||
|
expect(() => _validateKeepDays(-1)).toThrow(/between 1 and 3650/i);
|
||||||
|
expect(() => _validateKeepDays(-1000)).toThrow(/between 1 and 3650/i);
|
||||||
|
expect(() => _validateKeepDays(0)).toThrow(/between 1 and 3650/i);
|
||||||
|
expect(() => _validateKeepDays(3651)).toThrow(/between 1 and 3650/i);
|
||||||
|
expect(() => _validateKeepDays(1000000)).toThrow(/between 1 and 3650/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts integers in [1, 3650]', () => {
|
||||||
|
expect(_validateKeepDays(1)).toBe(1);
|
||||||
|
expect(_validateKeepDays(30)).toBe(30);
|
||||||
|
expect(_validateKeepDays(90)).toBe(90);
|
||||||
|
expect(_validateKeepDays(365)).toBe(365);
|
||||||
|
expect(_validateKeepDays(3650)).toBe(3650);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coerces numeric strings', () => {
|
||||||
|
expect(_validateKeepDays('30')).toBe(30);
|
||||||
|
expect(_validateKeepDays('3650')).toBe(3650);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_resolvePaths', () => {
|
||||||
|
const { _resolvePaths } = logInsightsMod.__test;
|
||||||
|
|
||||||
|
test('falls back to platformPaths.dataDir when env unset', () => {
|
||||||
|
const prevAudit = process.env.AUDIT_LOG_FILE;
|
||||||
|
const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
|
||||||
|
delete process.env.AUDIT_LOG_FILE;
|
||||||
|
delete process.env.SECURITY_EVENT_LOG_FILE;
|
||||||
|
try {
|
||||||
|
const { auditPath, secPath } = _resolvePaths();
|
||||||
|
// platformPaths.dataDir is /app/data in the container, /etc/dashcaddy on host
|
||||||
|
expect(auditPath.endsWith('audit-log.json')).toBe(true);
|
||||||
|
expect(secPath.endsWith('security-events.jsonl')).toBe(true);
|
||||||
|
// Audit + security should land in the same data dir
|
||||||
|
expect(path.dirname(auditPath)).toBe(path.dirname(secPath));
|
||||||
|
} finally {
|
||||||
|
if (prevAudit !== undefined) process.env.AUDIT_LOG_FILE = prevAudit;
|
||||||
|
if (prevSec !== undefined) process.env.SECURITY_EVENT_LOG_FILE = prevSec;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('honours AUDIT_LOG_FILE / SECURITY_EVENT_LOG_FILE env overrides', () => {
|
||||||
|
const prevAudit = process.env.AUDIT_LOG_FILE;
|
||||||
|
const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
|
||||||
|
process.env.AUDIT_LOG_FILE = '/tmp/dc-081-audit.json';
|
||||||
|
process.env.SECURITY_EVENT_LOG_FILE = '/tmp/dc-081-sec.jsonl';
|
||||||
|
try {
|
||||||
|
const { auditPath, secPath, auditPathFrom, secPathFrom } = _resolvePaths();
|
||||||
|
expect(auditPath).toBe('/tmp/dc-081-audit.json');
|
||||||
|
expect(secPath).toBe('/tmp/dc-081-sec.jsonl');
|
||||||
|
expect(auditPathFrom).toBe('env');
|
||||||
|
expect(secPathFrom).toBe('env');
|
||||||
|
} finally {
|
||||||
|
if (prevAudit === undefined) delete process.env.AUDIT_LOG_FILE;
|
||||||
|
else process.env.AUDIT_LOG_FILE = prevAudit;
|
||||||
|
if (prevSec === undefined) delete process.env.SECURITY_EVENT_LOG_FILE;
|
||||||
|
else process.env.SECURITY_EVENT_LOG_FILE = prevSec;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches the canonical paths used by audit-logger + event-store', async () => {
|
||||||
|
// Sanity: load both modules' resolved paths and assert they match
|
||||||
|
// what _resolvePaths returns. This catches a future refactor that
|
||||||
|
// moves one but not the others (the bug class that produced DC-081).
|
||||||
|
const prevAudit = process.env.AUDIT_LOG_FILE;
|
||||||
|
const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
|
||||||
|
delete process.env.AUDIT_LOG_FILE;
|
||||||
|
delete process.env.SECURITY_EVENT_LOG_FILE;
|
||||||
|
try {
|
||||||
|
const auditLoggerMod = require('../../src/security/audit-logger');
|
||||||
|
const eventStoreMod = require('../../src/security/event-store');
|
||||||
|
// Trigger event-store module-load (it captures ENV at require time)
|
||||||
|
eventStoreMod.getStore();
|
||||||
|
const { auditPath, secPath } = _resolvePaths();
|
||||||
|
// The audit-logger module exports a singleton; its private
|
||||||
|
// AUDIT_LOG_FILE is not directly readable. Instead, we verify the
|
||||||
|
// shape: both paths share the same dataDir and use the canonical
|
||||||
|
// filenames.
|
||||||
|
expect(path.basename(auditPath)).toBe('audit-log.json');
|
||||||
|
expect(path.basename(secPath)).toBe('security-events.jsonl');
|
||||||
|
// And the dirname matches platformPaths.dataDir
|
||||||
|
const platformPaths = require('../../platform-paths');
|
||||||
|
expect(path.dirname(auditPath)).toBe(platformPaths.dataDir);
|
||||||
|
expect(path.dirname(secPath)).toBe(platformPaths.dataDir);
|
||||||
|
// Also sanity that the singleton logger at least exists
|
||||||
|
expect(auditLoggerMod).toBeDefined();
|
||||||
|
} finally {
|
||||||
|
if (prevAudit !== undefined) process.env.AUDIT_LOG_FILE = prevAudit;
|
||||||
|
if (prevSec !== undefined) process.env.SECURITY_EVENT_LOG_FILE = prevSec;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /log-insights/dispose (TOTP-gated in production; here we hit the handler directly)', () => {
|
||||||
|
let server;
|
||||||
|
let app;
|
||||||
|
let tmpDir;
|
||||||
|
let auditFile;
|
||||||
|
let secFile;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dc-081-'));
|
||||||
|
auditFile = path.join(tmpDir, 'audit-log.json');
|
||||||
|
secFile = path.join(tmpDir, 'security-events.jsonl');
|
||||||
|
// Stage files so the route resolves them via env override.
|
||||||
|
process.env.AUDIT_LOG_FILE = auditFile;
|
||||||
|
process.env.SECURITY_EVENT_LOG_FILE = secFile;
|
||||||
|
const router = buildRouter();
|
||||||
|
app = makeApp(router);
|
||||||
|
server = await start(app);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await stop(server);
|
||||||
|
delete process.env.AUDIT_LOG_FILE;
|
||||||
|
delete process.env.SECURITY_EVENT_LOG_FILE;
|
||||||
|
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function postKeepDays(body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const port = server.address().port;
|
||||||
|
const data = JSON.stringify(body);
|
||||||
|
const req = require('http').request({
|
||||||
|
hostname: '127.0.0.1', port, path: '/log-insights/dispose',
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
||||||
|
}, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (c) => chunks.push(c));
|
||||||
|
res.on('end', () => {
|
||||||
|
const body = Buffer.concat(chunks).toString('utf8');
|
||||||
|
try { resolve({ status: res.statusCode, body: JSON.parse(body) }); }
|
||||||
|
catch (_) { resolve({ status: res.statusCode, body }); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(data);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('rejects negative keepDays with 400 + DC-081_INVALID_KEEP_DAYS', async () => {
|
||||||
|
const r = await postKeepDays({ keepDays: -1000 });
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||||
|
expect(r.body.error).toMatch(/between 1 and 3650/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects 0 keepDays (no-op-but-lies)', async () => {
|
||||||
|
const r = await postKeepDays({ keepDays: 0 });
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects keepDays=Infinity (NaN-via-parseInt fallback)', async () => {
|
||||||
|
const r = await postKeepDays({ keepDays: Infinity });
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-integer keepDays', async () => {
|
||||||
|
const r = await postKeepDays({ keepDays: 30.5 });
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects missing keepDays', async () => {
|
||||||
|
const r = await postKeepDays({});
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects keepDays > 3650 (10-year cap)', async () => {
|
||||||
|
const r = await postKeepDays({ keepDays: 10000 });
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preview pass: returns wouldDelete count without writing', async () => {
|
||||||
|
const oldTs = new Date(Date.now() - 100 * 86400000).toISOString(); // 100 days ago
|
||||||
|
const newTs = new Date(Date.now() - 5 * 86400000).toISOString(); // 5 days ago
|
||||||
|
await fsp.writeFile(auditFile, JSON.stringify([
|
||||||
|
{ id: 'a1', timestamp: oldTs, action: 'service.create' },
|
||||||
|
{ id: 'a2', timestamp: oldTs, action: 'service.delete' },
|
||||||
|
{ id: 'a3', timestamp: newTs, action: 'auth.totp-verify' },
|
||||||
|
]));
|
||||||
|
await fsp.writeFile(secFile, [
|
||||||
|
JSON.stringify({ id: 's1', timestamp: oldTs, severity: 'info' }),
|
||||||
|
JSON.stringify({ id: 's2', timestamp: oldTs, severity: 'info' }),
|
||||||
|
JSON.stringify({ id: 's3', timestamp: newTs, severity: 'info' }),
|
||||||
|
].join('\n') + '\n');
|
||||||
|
|
||||||
|
const r = await postKeepDays({ keepDays: 30 });
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.body.preview).toBe(true);
|
||||||
|
expect(r.body.wouldDelete.auditEntries).toBe(2);
|
||||||
|
expect(r.body.wouldDelete.securityEvents).toBe(2);
|
||||||
|
// Files untouched
|
||||||
|
const afterAudit = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
|
||||||
|
expect(afterAudit.length).toBe(3);
|
||||||
|
const afterSec = (await fsp.readFile(secFile, 'utf8')).split('\n').filter(Boolean);
|
||||||
|
expect(afterSec.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirm pass: actually deletes old entries, keeps new ones', async () => {
|
||||||
|
const oldTs = new Date(Date.now() - 100 * 86400000).toISOString();
|
||||||
|
const newTs = new Date(Date.now() - 5 * 86400000).toISOString();
|
||||||
|
await fsp.writeFile(auditFile, JSON.stringify([
|
||||||
|
{ id: 'a1', timestamp: oldTs, action: 'service.create' },
|
||||||
|
{ id: 'a2', timestamp: newTs, action: 'auth.totp-verify' },
|
||||||
|
]));
|
||||||
|
await fsp.writeFile(secFile, [
|
||||||
|
JSON.stringify({ id: 's1', timestamp: oldTs, severity: 'info' }),
|
||||||
|
JSON.stringify({ id: 's2', timestamp: newTs, severity: 'info' }),
|
||||||
|
].join('\n') + '\n');
|
||||||
|
|
||||||
|
const r = await postKeepDays({ keepDays: 30, confirm: true });
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.body.disposed).toBe(true);
|
||||||
|
expect(r.body.deleted.auditEntries).toBe(1);
|
||||||
|
expect(r.body.deleted.securityEvents).toBe(1);
|
||||||
|
expect(r.body.remaining.auditEntries).toBe(1);
|
||||||
|
expect(r.body.remaining.securityEvents).toBe(1);
|
||||||
|
|
||||||
|
const afterAudit = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
|
||||||
|
expect(afterAudit.map(e => e.id)).toEqual(['a2']);
|
||||||
|
const afterSec = (await fsp.readFile(secFile, 'utf8')).split('\n').filter(Boolean).map(JSON.parse);
|
||||||
|
expect(afterSec.map(e => e.id)).toEqual(['s2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirm=false treated as preview (not confirm)', async () => {
|
||||||
|
const r = await postKeepDays({ keepDays: 30, confirm: false });
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.body.preview).toBe(true);
|
||||||
|
// confirm was false, so no dispose
|
||||||
|
expect(r.body.disposed).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preview response includes resolved paths so operator knows what files will be touched', async () => {
|
||||||
|
const r = await postKeepDays({ keepDays: 30 });
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.body.paths.auditPath).toBe(auditFile);
|
||||||
|
expect(r.body.paths.secPath).toBe(secFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles missing audit-log file gracefully on preview', async () => {
|
||||||
|
await fsp.unlink(auditFile).catch(() => {});
|
||||||
|
// fs.readFile().catch returns '[]', so preview reports 0 deletions
|
||||||
|
const r = await postKeepDays({ keepDays: 30 });
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.body.wouldDelete.auditEntries).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 500 DC-081_AUDIT_PARSE_FAILED on corrupt audit-log file', async () => {
|
||||||
|
await fsp.writeFile(auditFile, 'this-is-not-json{');
|
||||||
|
const r = await postKeepDays({ keepDays: 30 });
|
||||||
|
expect(r.status).toBe(500);
|
||||||
|
expect(r.body.code).toBe('DC-081_AUDIT_PARSE_FAILED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 500 DC-081_AUDIT_SHAPE_INVALID if audit-log is a JSON object, not array', async () => {
|
||||||
|
await fsp.writeFile(auditFile, JSON.stringify({ not: 'an array' }));
|
||||||
|
const r = await postKeepDays({ keepDays: 30 });
|
||||||
|
expect(r.status).toBe(500);
|
||||||
|
expect(r.body.code).toBe('DC-081_AUDIT_SHAPE_INVALID');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DC-081 CORE: pre-fix keptDays=-1000 no longer wipes everything', async () => {
|
||||||
|
// Sanity-test the actual fix: a negative keepDays would, pre-fix,
|
||||||
|
// compute a cutoff in the FUTURE and then delete everything. After
|
||||||
|
// DC-081 it's a 400 with a clear error before any file read.
|
||||||
|
const r = await postKeepDays({ keepDays: -1000, confirm: true });
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
expect(r.body.success).toBe(false);
|
||||||
|
// No file IO occurred — confirm that an unrelated existing audit
|
||||||
|
// log file would survive. Since we already wiped tmpDir's auditFile
|
||||||
|
// is empty, write a sentinel and confirm it's still there after.
|
||||||
|
await fsp.writeFile(auditFile, JSON.stringify([{ id: 'sentinel', timestamp: new Date().toISOString() }]));
|
||||||
|
const r2 = await postKeepDays({ keepDays: -1000, confirm: true });
|
||||||
|
expect(r2.status).toBe(400);
|
||||||
|
const after = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
|
||||||
|
expect(after.length).toBe(1);
|
||||||
|
expect(after[0].id).toBe('sentinel');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -288,6 +288,21 @@ describe('Services Routes', () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.hasApiKey).toBe(true);
|
expect(res.body.hasApiKey).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('requires both username and password before reporting Basic Auth ready', async () => {
|
||||||
|
const credentialManager = {
|
||||||
|
store: jest.fn(),
|
||||||
|
retrieve: jest.fn().mockImplementation((key) => {
|
||||||
|
if (key === 'service.radarr.username') return Promise.resolve('admin');
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}),
|
||||||
|
delete: jest.fn(),
|
||||||
|
};
|
||||||
|
const { app } = createApp({ credentialManager });
|
||||||
|
const res = await request(app).get('/api/services/radarr/credentials');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.hasBasicAuth).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== SEEDHOST CREDENTIAL ENDPOINTS =====
|
// ===== SEEDHOST CREDENTIAL ENDPOINTS =====
|
||||||
|
|||||||
@@ -0,0 +1,535 @@
|
|||||||
|
/**
|
||||||
|
* DC-074: SSRF hardening for sites.js — `/site` and `/site/external`
|
||||||
|
* must reject upstream hosts that resolve to private/reserved ranges
|
||||||
|
* BEFORE they reach the Caddyfile.
|
||||||
|
*
|
||||||
|
* Bug class: an authenticated dashboard operator could call
|
||||||
|
* POST /api/v1/site {domain: "x.example.com", upstream: "10.0.0.1:80"}
|
||||||
|
* POST /api/v1/site/external {subdomain: "x", externalUrl: "http://192.168.1.5"}
|
||||||
|
* and end up with a Caddy site block that proxies PUBLIC traffic to an
|
||||||
|
* INTERNAL host. Caddy runs on DNS2 (same network as the targets), so
|
||||||
|
* the SSRF lands.
|
||||||
|
*
|
||||||
|
* Pre-fix: `/site`'s only upstream check was `^[a-z0-9.-]+:\d{1,5}$/i`,
|
||||||
|
* which accepts 192.168.1.1:80 and 169.254.169.254:80 (the AWS
|
||||||
|
* metadata IP) with no problem. `/site/external` used `validateURL`
|
||||||
|
* without `blockPrivate: true` at all.
|
||||||
|
*
|
||||||
|
* Post-fix: a new helper `validateUpstream()` in `fleet-validation.js`
|
||||||
|
* reuses the resolver+private-range checks fleet-validation already has
|
||||||
|
* for DC-068, gating Caddyfile writes behind a public-IP requirement.
|
||||||
|
* Opt-in via `SITES_ALLOW_PRIVATE_UPSTREAMS=true` for operators who
|
||||||
|
* intentionally proxy to private targets.
|
||||||
|
*
|
||||||
|
* The suite covers three layers:
|
||||||
|
* 1. Helper unit tests — validateUpstream with mocked DNS / literal IPs
|
||||||
|
* 2. Route integration tests — POST /site and POST /site/external
|
||||||
|
* reject each known private range, accept public IPs and hostnames
|
||||||
|
* 3. Regression — pre-fix payload `10.0.0.1:80` is rejected (the
|
||||||
|
* canonical SSRF regression proof)
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
const {
|
||||||
|
validateUpstream,
|
||||||
|
isPrivateOrReservedIPv4,
|
||||||
|
isPrivateOrReservedIPv6,
|
||||||
|
} = require('../../src/utilities/fleet-validation');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const LOG = () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a minimal Express app that mounts /api/v1/sites with stubbed
|
||||||
|
* caddy/dns/buildDomain/addServiceToConfig. The stubs record every call
|
||||||
|
* so tests can assert the route does NOT mutate the Caddyfile when it
|
||||||
|
* should reject.
|
||||||
|
*/
|
||||||
|
function createSitesApp({ log, caddyStub, buildDomainStub, dnsStub, addServiceToConfigStub } = {}) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
const sites = require('../../routes/sites');
|
||||||
|
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
const caddy = caddyStub || {
|
||||||
|
read: async () => '# stub caddyfile\n',
|
||||||
|
modify: jest.fn(async () => ({ success: true })),
|
||||||
|
adminUrl: 'http://127.0.0.1:2019',
|
||||||
|
filePath: '/tmp/stub-Caddyfile',
|
||||||
|
};
|
||||||
|
const dns = dnsStub || {
|
||||||
|
universalCreateRecord: jest.fn(async () => true),
|
||||||
|
};
|
||||||
|
app.use('/api/v1', sites({
|
||||||
|
asyncHandler: wrap,
|
||||||
|
ok: (res, data) => res.json({ ok: true, ...data }),
|
||||||
|
successMessage: (res, msg) => res.json({ ok: true, message: msg }),
|
||||||
|
caddy,
|
||||||
|
dns,
|
||||||
|
fetchT: async () => ({ ok: true, json: async () => ({}) }),
|
||||||
|
buildDomain: buildDomainStub || ((sub) => `${sub}.example.com`),
|
||||||
|
addServiceToConfig: addServiceToConfigStub || jest.fn(async () => true),
|
||||||
|
siteConfig: { dnsServerIp: '127.0.0.1' },
|
||||||
|
log: log || LOG(),
|
||||||
|
}));
|
||||||
|
// JSON error middleware — must mirror the shape sites.js's production
|
||||||
|
// global error middleware emits so route tests can assert on it. Without
|
||||||
|
// this, Express's default error handler returns an HTML stack trace and
|
||||||
|
// res.body.error is undefined.
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
const status = err.statusCode || 500;
|
||||||
|
res.status(status).json({
|
||||||
|
error: err.message || 'Internal Server Error',
|
||||||
|
code: err.code || null,
|
||||||
|
field: err.field || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { app, caddy };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mock dns.promises.lookup to return a specific IP for any hostname.
|
||||||
|
* Returns an array of `{address, family}` records since fleet-validation
|
||||||
|
* calls `dns.lookup(name, {all: true})`. */
|
||||||
|
function mockDnsLookup(map) {
|
||||||
|
const dns = require('dns');
|
||||||
|
const original = dns.promises.lookup;
|
||||||
|
dns.promises.lookup = async (hostname, opts) => {
|
||||||
|
for (const [pattern, ip] of Object.entries(map)) {
|
||||||
|
if (hostname === pattern || (pattern instanceof RegExp && pattern.test(hostname))) {
|
||||||
|
const family = ip.includes(':') ? 6 : 4;
|
||||||
|
return [{ address: ip, family }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default: throw ENOTFOUND
|
||||||
|
const err = new Error('ENOTFOUND');
|
||||||
|
err.code = 'ENOTFOUND';
|
||||||
|
throw err;
|
||||||
|
};
|
||||||
|
return () => {
|
||||||
|
dns.promises.lookup = original;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. Helper unit tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('DC-074: validateUpstream (helper)', () => {
|
||||||
|
let restoreDns;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (restoreDns) restoreDns();
|
||||||
|
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('format validation', () => {
|
||||||
|
test('rejects empty / non-string with INVALID_UPSTREAM', async () => {
|
||||||
|
expect(await validateUpstream('')).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
|
||||||
|
expect(await validateUpstream(null)).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
|
||||||
|
expect(await validateUpstream(undefined)).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
|
||||||
|
expect(await validateUpstream(42)).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects missing port with INVALID_UPSTREAM', async () => {
|
||||||
|
expect(await validateUpstream('hostonly')).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-integer port with INVALID_PORT', async () => {
|
||||||
|
expect(await validateUpstream('host:abc')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
|
||||||
|
expect(await validateUpstream('host:80.5')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects out-of-range port with INVALID_PORT', async () => {
|
||||||
|
expect(await validateUpstream('host:0')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
|
||||||
|
expect(await validateUpstream('host:65536')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
|
||||||
|
expect(await validateUpstream('host:99999999')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
|
||||||
|
expect(await validateUpstream('host:-1')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('private IPv4 reject (literal)', () => {
|
||||||
|
const PRIVATE_V4 = [
|
||||||
|
['127.0.0.1', 'loopback'],
|
||||||
|
['127.255.255.1', 'loopback'],
|
||||||
|
['10.0.0.1', 'RFC 1918'],
|
||||||
|
['172.16.0.1', 'RFC 1918'],
|
||||||
|
['192.168.1.1', 'RFC 1918'],
|
||||||
|
['169.254.169.254', 'link-local'], // AWS IMDS
|
||||||
|
['100.64.0.1', 'CGNAT'],
|
||||||
|
['224.0.0.1', 'multicast'],
|
||||||
|
['255.255.255.255', 'broadcast'],
|
||||||
|
['0.0.0.0', 'reserved'],
|
||||||
|
];
|
||||||
|
for (const [ip, wantLabel] of PRIVATE_V4) {
|
||||||
|
test(`rejects ${ip} (${wantLabel})`, async () => {
|
||||||
|
const r = await validateUpstream(`${ip}:80`);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.code).toBe('PRIVATE_IPV4');
|
||||||
|
expect(r.message).toMatch(new RegExp(wantLabel, 'i'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('private IPv6 reject (literal)', () => {
|
||||||
|
test('rejects ::1 (loopback)', async () => {
|
||||||
|
const r = await validateUpstream('[::1]:80');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.code).toBe('PRIVATE_IPV6');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects fe80::1 (link-local)', async () => {
|
||||||
|
const r = await validateUpstream('[fe80::1]:80');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.code).toBe('PRIVATE_IPV6');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects fc00::1 (ULA)', async () => {
|
||||||
|
const r = await validateUpstream('[fc00::1]:80');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.code).toBe('PRIVATE_IPV6');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('public IPs accepted (literal)', () => {
|
||||||
|
test('accepts 8.8.8.8', async () => {
|
||||||
|
const r = await validateUpstream('8.8.8.8:53');
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.host).toBe('8.8.8.8');
|
||||||
|
expect(r.port).toBe(53);
|
||||||
|
expect(r.family).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts 1.1.1.1', async () => {
|
||||||
|
const r = await validateUpstream('1.1.1.1:443');
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.port).toBe(443);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hostname resolve', () => {
|
||||||
|
test('accepts hostname that resolves to public IP', async () => {
|
||||||
|
restoreDns = mockDnsLookup({ 'public.example.com': '8.8.8.8' });
|
||||||
|
const r = await validateUpstream('public.example.com:443');
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.resolvedIp).toBe('8.8.8.8');
|
||||||
|
expect(r.family).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects hostname that resolves to private IP (DNS rebinding defense)', async () => {
|
||||||
|
restoreDns = mockDnsLookup({ 'evil.example.com': '10.0.0.5' });
|
||||||
|
const r = await validateUpstream('evil.example.com:80');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.code).toBe('PRIVATE_IPV4');
|
||||||
|
expect(r.message).toMatch(/evil\.example\.com.*10\.0\.0\.5/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects hostname that fails to resolve', async () => {
|
||||||
|
// mockDnsLookup default throws ENOTFOUND
|
||||||
|
const r = await validateUpstream('does-not-exist.invalid:80');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.code).toMatch(/DNS_/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects hostname with invalid charset pre-DNS', async () => {
|
||||||
|
const r = await validateUpstream('host with spaces:80');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.code).toBe('INVALID_HOST');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SITES_ALLOW_PRIVATE_UPSTREAMS opt-in', () => {
|
||||||
|
test('default rejects private IPs', async () => {
|
||||||
|
const r = await validateUpstream('10.0.0.1:80');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opt-in accepts private literal IP', async () => {
|
||||||
|
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
|
||||||
|
const r = await validateUpstream('10.0.0.1:80');
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opt-in accepts private DNS-resolved host', async () => {
|
||||||
|
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
|
||||||
|
restoreDns = mockDnsLookup({ 'internal.example.com': '10.0.0.5' });
|
||||||
|
const r = await validateUpstream('internal.example.com:80');
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit allowPrivate:false overrides env opt-in (programmatic guard)', async () => {
|
||||||
|
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
|
||||||
|
const r = await validateUpstream('10.0.0.1:80', { allowPrivate: false });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.code).toBe('PRIVATE_IPV4');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2. Route integration tests — POST /site
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('DC-074: POST /api/v1/site — SSRF hardening', () => {
|
||||||
|
let restoreDns;
|
||||||
|
let caddyStub;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
|
||||||
|
caddyStub = {
|
||||||
|
read: async () => '# stub caddyfile\n',
|
||||||
|
modify: jest.fn(async () => ({ success: true })),
|
||||||
|
adminUrl: 'http://127.0.0.1:2019',
|
||||||
|
filePath: '/tmp/stub-Caddyfile',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (restoreDns) restoreDns();
|
||||||
|
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
|
||||||
|
});
|
||||||
|
|
||||||
|
const REGRESSION_CASES = [
|
||||||
|
['10.0.0.1:80', 'PRIVATE_IPV4'],
|
||||||
|
['172.16.0.1:80', 'PRIVATE_IPV4'],
|
||||||
|
['192.168.1.1:80', 'PRIVATE_IPV4'],
|
||||||
|
['127.0.0.1:80', 'PRIVATE_IPV4'],
|
||||||
|
['169.254.169.254:80', 'PRIVATE_IPV4'], // AWS IMDS
|
||||||
|
['100.64.0.1:80', 'PRIVATE_IPV4'], // CGNAT
|
||||||
|
['224.0.0.1:80', 'PRIVATE_IPV4'], // multicast
|
||||||
|
['0.0.0.0:80', 'PRIVATE_IPV4'], // reserved
|
||||||
|
['[::1]:80', 'PRIVATE_IPV6'],
|
||||||
|
['[fc00::1]:80', 'PRIVATE_IPV6'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [upstream, wantCode] of REGRESSION_CASES) {
|
||||||
|
test(`rejects upstream="${upstream}" with code=${wantCode}`, async () => {
|
||||||
|
const { app } = createSitesApp({ caddyStub });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/site')
|
||||||
|
.send({ domain: 'evil.example.com', upstream });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/\[DC-074\]/);
|
||||||
|
expect(res.body.error).toMatch(/SITES_ALLOW_PRIVATE_UPSTREAMS/);
|
||||||
|
// caddy.modify() must NOT have been called (gate happens before write)
|
||||||
|
expect(caddyStub.modify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('rejects DNS-resolved private IP (rebinding defense)', async () => {
|
||||||
|
restoreDns = mockDnsLookup({ 'looks-public.example.com': '10.0.0.5' });
|
||||||
|
const { app } = createSitesApp({ caddyStub });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/site')
|
||||||
|
.send({ domain: 'evil.example.com', upstream: 'looks-public.example.com:80' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/10\.0\.0\.5/);
|
||||||
|
expect(caddyStub.modify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts public literal IP', async () => {
|
||||||
|
const { app } = createSitesApp({ caddyStub });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/site')
|
||||||
|
.send({ domain: 'new.example.com', upstream: '8.8.8.8:80' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(caddyStub.modify).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts hostname resolving to public IP', async () => {
|
||||||
|
restoreDns = mockDnsLookup({ 'real.example.com': '8.8.8.8' });
|
||||||
|
const { app } = createSitesApp({ caddyStub });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/site')
|
||||||
|
.send({ domain: 'new.example.com', upstream: 'real.example.com:80' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(caddyStub.modify).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SITES_ALLOW_PRIVATE_UPSTREAMS=true opts in for private literal', async () => {
|
||||||
|
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
|
||||||
|
const { app } = createSitesApp({ caddyStub });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/site')
|
||||||
|
.send({ domain: 'lab.example.com', upstream: '10.0.0.1:80' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(caddyStub.modify).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SITES_ALLOW_PRIVATE_UPSTREAMS=true opts in for private-resolved hostname', async () => {
|
||||||
|
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
|
||||||
|
restoreDns = mockDnsLookup({ 'internal.lan': '10.0.0.5' });
|
||||||
|
const { app } = createSitesApp({ caddyStub });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/site')
|
||||||
|
.send({ domain: 'lab.example.com', upstream: 'internal.lan:80' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects out-of-range port without invoking private-IP check', async () => {
|
||||||
|
const { app } = createSitesApp({ caddyStub });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/site')
|
||||||
|
.send({ domain: 'new.example.com', upstream: '8.8.8.8:99999' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/INVALID_PORT|\[DC-074\]/);
|
||||||
|
expect(caddyStub.modify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects upstream with spaces (charset) without invoking private-IP check', async () => {
|
||||||
|
const { app } = createSitesApp({ caddyStub });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/site')
|
||||||
|
.send({ domain: 'new.example.com', upstream: 'not a host:80' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(caddyStub.modify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 3. Route integration tests — POST /site/external
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('DC-074: POST /api/v1/site/external — SSRF hardening', () => {
|
||||||
|
let restoreDns;
|
||||||
|
let caddyStub;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
|
||||||
|
caddyStub = {
|
||||||
|
read: async () => '# stub caddyfile\n',
|
||||||
|
modify: jest.fn(async () => ({ success: true })),
|
||||||
|
adminUrl: 'http://127.0.0.1:2019',
|
||||||
|
filePath: '/tmp/stub-Caddyfile',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (restoreDns) restoreDns();
|
||||||
|
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
|
||||||
|
});
|
||||||
|
|
||||||
|
const REGRESSION_CASES = [
|
||||||
|
'http://10.0.0.1',
|
||||||
|
'http://192.168.1.1',
|
||||||
|
'http://127.0.0.1',
|
||||||
|
'http://169.254.169.254', // AWS IMDS via URL form
|
||||||
|
'http://100.64.0.1', // CGNAT — caught by validateUpstream defense-in-depth, not validateURL
|
||||||
|
'http://0.0.0.0',
|
||||||
|
'http://[::1]',
|
||||||
|
'http://[fc00::1]',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const externalUrl of REGRESSION_CASES) {
|
||||||
|
test(`rejects externalUrl="${externalUrl}"`, async () => {
|
||||||
|
const { app } = createSitesApp({ caddyStub });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/site/external')
|
||||||
|
.send({ subdomain: 'ext', externalUrl });
|
||||||
|
// 400 from validateURL OR from validateUpstream — either path closes the gate.
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(caddyStub.modify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('rejects DNS-resolved private IP', async () => {
|
||||||
|
restoreDns = mockDnsLookup({ 'looks-public.example.com': '10.0.0.5' });
|
||||||
|
const { app } = createSitesApp({ caddyStub });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/site/external')
|
||||||
|
.send({ subdomain: 'ext', externalUrl: 'http://looks-public.example.com' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/\[DC-074\]|Private URLs/);
|
||||||
|
expect(caddyStub.modify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts externalUrl with public hostname', async () => {
|
||||||
|
restoreDns = mockDnsLookup({ 'api.example.com': '8.8.8.8' });
|
||||||
|
const { app } = createSitesApp({ caddyStub });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/site/external')
|
||||||
|
.send({ subdomain: 'ext', externalUrl: 'http://api.example.com' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(caddyStub.modify).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts externalUrl with public literal IP', async () => {
|
||||||
|
const { app } = createSitesApp({ caddyStub });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/site/external')
|
||||||
|
.send({ subdomain: 'ext', externalUrl: 'http://8.8.8.8' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SITES_ALLOW_PRIVATE_UPSTREAMS=true opts in for private externalUrl', async () => {
|
||||||
|
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
|
||||||
|
const { app } = createSitesApp({ caddyStub });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/site/external')
|
||||||
|
.send({ subdomain: 'ext', externalUrl: 'http://10.0.0.5' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4. Regression — pre-fix payload (the canonical SSRF regression proof)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('DC-074: regression — pre-fix payloads are now rejected', () => {
|
||||||
|
test('the canonical SSRF payload `10.0.0.1:80` is rejected at the route layer', async () => {
|
||||||
|
const caddyStub = {
|
||||||
|
read: async () => '',
|
||||||
|
modify: jest.fn(async () => ({ success: true })),
|
||||||
|
adminUrl: 'http://127.0.0.1:2019',
|
||||||
|
filePath: '/tmp/stub-Caddyfile',
|
||||||
|
};
|
||||||
|
const { app } = createSitesApp({ caddyStub });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/site')
|
||||||
|
.send({ domain: 'evil.attacker.com', upstream: '10.0.0.1:80' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
// Pre-fix this payload would have been accepted, the regex happily
|
||||||
|
// matches `[a-z0-9.-]+:\d{1,5}` against `10.0.0.1:80`, and a Caddy
|
||||||
|
// site block would have been written that proxied public HTTPS
|
||||||
|
// traffic at `evil.attacker.com` to the internal 10.0.0.1:80.
|
||||||
|
expect(caddyStub.modify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the canonical SSRF payload `http://192.168.1.5` is rejected at the external endpoint', async () => {
|
||||||
|
const caddyStub = {
|
||||||
|
read: async () => '',
|
||||||
|
modify: jest.fn(async () => ({ success: true })),
|
||||||
|
adminUrl: 'http://127.0.0.1:2019',
|
||||||
|
filePath: '/tmp/stub-Caddyfile',
|
||||||
|
};
|
||||||
|
const { app } = createSitesApp({ caddyStub });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/site/external')
|
||||||
|
.send({ subdomain: 'ext', externalUrl: 'http://192.168.1.5' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(caddyStub.modify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 5. Sanity — fleet-validation helper exports still work as before
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('DC-074: fleet-validation helpers still exported and unchanged behavior', () => {
|
||||||
|
test('isPrivateOrReservedIPv4 still detects the same set as before', () => {
|
||||||
|
expect(isPrivateOrReservedIPv4('10.0.0.1').isPrivate).toBe(true);
|
||||||
|
expect(isPrivateOrReservedIPv4('8.8.8.8').isPrivate).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isPrivateOrReservedIPv6 still detects the same set as before', () => {
|
||||||
|
expect(isPrivateOrReservedIPv6('::1').isPrivate).toBe(true);
|
||||||
|
expect(isPrivateOrReservedIPv6('2001:4860:4860::8888').isPrivate).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -131,6 +131,20 @@ describe('routes/tailscale-admin: PUT /settings', () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('400 on apiToken exceeding 256-char length cap (DC-080)', async () => {
|
||||||
|
const { app } = createApp();
|
||||||
|
const oversized = 'tskey-api-' + 'x'.repeat(300); // > 256 chars
|
||||||
|
const res = await request(app).put('/api/v1/tailscale/settings').send({ apiToken: oversized });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error || res.body.message).toMatch(/exceeds maximum length/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('400 on non-string apiToken (DC-080)', async () => {
|
||||||
|
const { app } = createApp();
|
||||||
|
const res = await request(app).put('/api/v1/tailscale/settings').send({ apiToken: 12345 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
test('200 + saves token + writes metadata on valid token', async () => {
|
test('200 + saves token + writes metadata on valid token', async () => {
|
||||||
const fakeClient = makeFakeClient({
|
const fakeClient = makeFakeClient({
|
||||||
ping: jest.fn(async () => ({ domain: 'real.ts.net' })),
|
ping: jest.fn(async () => ({ domain: 'real.ts.net' })),
|
||||||
@@ -293,6 +307,76 @@ describe('routes/tailscale-admin: POST /settings/test', () => {
|
|||||||
expect(res.body.valid).toBe(true);
|
expect(res.body.valid).toBe(true);
|
||||||
expect(fakeClient.setApiToken).toHaveBeenCalledWith('tskey-api-test-only');
|
expect(fakeClient.setApiToken).toHaveBeenCalledWith('tskey-api-test-only');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('400 on body.apiToken not starting with tskey-api- (DC-080)', async () => {
|
||||||
|
const fakeClient = makeFakeClient();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const routes = require('../../routes/tailscale-admin');
|
||||||
|
const tailscaleCoord = {
|
||||||
|
loadMetadata: () => ({ configured: false }),
|
||||||
|
saveMetadata: jest.fn(),
|
||||||
|
setApiToken: jest.fn(),
|
||||||
|
getClient: jest.fn(async () => fakeClient),
|
||||||
|
hasApiToken: jest.fn(),
|
||||||
|
};
|
||||||
|
app.use('/api/v1/tailscale', routes({
|
||||||
|
tailscaleCoord, asyncHandler,
|
||||||
|
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||||
|
}));
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/tailscale/settings/test')
|
||||||
|
.send({ apiToken: 'arbitrary-junk' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(fakeClient.setApiToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('400 on body.apiToken exceeding length cap (DC-080)', async () => {
|
||||||
|
const fakeClient = makeFakeClient();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const routes = require('../../routes/tailscale-admin');
|
||||||
|
const tailscaleCoord = {
|
||||||
|
loadMetadata: () => ({ configured: false }),
|
||||||
|
saveMetadata: jest.fn(),
|
||||||
|
setApiToken: jest.fn(),
|
||||||
|
getClient: jest.fn(async () => fakeClient),
|
||||||
|
hasApiToken: jest.fn(),
|
||||||
|
};
|
||||||
|
app.use('/api/v1/tailscale', routes({
|
||||||
|
tailscaleCoord, asyncHandler,
|
||||||
|
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||||
|
}));
|
||||||
|
const oversized = 'tskey-api-' + 'x'.repeat(300);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/tailscale/settings/test')
|
||||||
|
.send({ apiToken: oversized });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(fakeClient.setApiToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omitting apiToken is allowed (uses stored token path) (DC-080)', async () => {
|
||||||
|
const fakeClient = makeFakeClient({ ping: jest.fn(async () => ({ domain: 'stored.ts.net' })) });
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const routes = require('../../routes/tailscale-admin');
|
||||||
|
const tailscaleCoord = {
|
||||||
|
loadMetadata: () => ({ configured: true }),
|
||||||
|
saveMetadata: jest.fn(),
|
||||||
|
setApiToken: jest.fn(),
|
||||||
|
getClient: jest.fn(async () => fakeClient),
|
||||||
|
hasApiToken: jest.fn(),
|
||||||
|
};
|
||||||
|
app.use('/api/v1/tailscale', routes({
|
||||||
|
tailscaleCoord, asyncHandler,
|
||||||
|
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||||
|
}));
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/tailscale/settings/test')
|
||||||
|
.send({}); // no apiToken in body
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.valid).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('routes/tailscale-admin: GET /admin/devices', () => {
|
describe('routes/tailscale-admin: GET /admin/devices', () => {
|
||||||
@@ -511,6 +595,99 @@ describe('routes/tailscale-admin: pre-auth keys', () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('POST /admin/keys rejects null/123/object tags entries (DC-080)', async () => {
|
||||||
|
const fakeClient = makeFakeClient();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const routes = require('../../routes/tailscale-admin');
|
||||||
|
const tailscaleCoord = {
|
||||||
|
loadMetadata: () => ({ configured: true }),
|
||||||
|
saveMetadata: jest.fn(),
|
||||||
|
setApiToken: jest.fn(),
|
||||||
|
getClient: jest.fn(async () => fakeClient),
|
||||||
|
hasApiToken: jest.fn(),
|
||||||
|
};
|
||||||
|
app.use('/api/v1/tailscale', routes({
|
||||||
|
tailscaleCoord, asyncHandler,
|
||||||
|
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||||
|
}));
|
||||||
|
// Mixed: null, number, object — all must be rejected
|
||||||
|
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ tags: ['tag:guest', null, 123, { x: 1 }] });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(fakeClient.createAuthKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /admin/keys rejects uppercase / whitespace / CRLF in tags (DC-080)', async () => {
|
||||||
|
const fakeClient = makeFakeClient();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const routes = require('../../routes/tailscale-admin');
|
||||||
|
const tailscaleCoord = {
|
||||||
|
loadMetadata: () => ({ configured: true }),
|
||||||
|
saveMetadata: jest.fn(),
|
||||||
|
setApiToken: jest.fn(),
|
||||||
|
getClient: jest.fn(async () => fakeClient),
|
||||||
|
hasApiToken: jest.fn(),
|
||||||
|
};
|
||||||
|
app.use('/api/v1/tailscale', routes({
|
||||||
|
tailscaleCoord, asyncHandler,
|
||||||
|
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||||
|
}));
|
||||||
|
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ tags: ['TAG:guest', 'tag:foo bar', 'tag:x\r\ninjection'] });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(fakeClient.createAuthKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /admin/keys rejects description exceeding 120 chars (DC-080)', async () => {
|
||||||
|
const fakeClient = makeFakeClient();
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const routes = require('../../routes/tailscale-admin');
|
||||||
|
const tailscaleCoord = {
|
||||||
|
loadMetadata: () => ({ configured: true }),
|
||||||
|
saveMetadata: jest.fn(),
|
||||||
|
setApiToken: jest.fn(),
|
||||||
|
getClient: jest.fn(async () => fakeClient),
|
||||||
|
hasApiToken: jest.fn(),
|
||||||
|
};
|
||||||
|
app.use('/api/v1/tailscale', routes({
|
||||||
|
tailscaleCoord, asyncHandler,
|
||||||
|
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||||
|
}));
|
||||||
|
const longDesc = 'a'.repeat(200); // > 120 chars
|
||||||
|
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ description: longDesc });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(fakeClient.createAuthKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /admin/keys accepts canonical lowercase tag: form (DC-080)', async () => {
|
||||||
|
const fakeClient = makeFakeClient({
|
||||||
|
createAuthKey: jest.fn(async (opts) => ({ id: 'k2', key: 'tskey-secret-2', ...opts })),
|
||||||
|
});
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const routes = require('../../routes/tailscale-admin');
|
||||||
|
const tailscaleCoord = {
|
||||||
|
loadMetadata: () => ({ configured: true }),
|
||||||
|
saveMetadata: jest.fn(),
|
||||||
|
setApiToken: jest.fn(),
|
||||||
|
getClient: jest.fn(async () => fakeClient),
|
||||||
|
hasApiToken: jest.fn(),
|
||||||
|
};
|
||||||
|
app.use('/api/v1/tailscale', routes({
|
||||||
|
tailscaleCoord, asyncHandler,
|
||||||
|
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||||
|
}));
|
||||||
|
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({
|
||||||
|
tags: ['tag:guest-plex', 'tag:server'],
|
||||||
|
expirySeconds: 86400,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(fakeClient.createAuthKey).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
tags: ['tag:guest-plex', 'tag:server'],
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
test('POST /admin/keys rejects negative expirySeconds', async () => {
|
test('POST /admin/keys rejects negative expirySeconds', async () => {
|
||||||
const fakeClient = makeFakeClient();
|
const fakeClient = makeFakeClient();
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -572,4 +749,110 @@ describe('routes/tailscale-admin: security boundary', () => {
|
|||||||
await request(app).delete('/api/v1/tailscale/settings');
|
await request(app).delete('/api/v1/tailscale/settings');
|
||||||
expect(stored.token).toBeNull();
|
expect(stored.token).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// DC-080 direct validator unit tests (no supertest, no Express)
|
||||||
|
describe('routes/tailscale-admin: DC-080 validators (direct)', () => {
|
||||||
|
const { _validators } = require('../../routes/tailscale-admin');
|
||||||
|
const {
|
||||||
|
validateApiToken,
|
||||||
|
validateTags,
|
||||||
|
validateDescription,
|
||||||
|
TAILSCALE_TOKEN_PREFIX,
|
||||||
|
TAILSCALE_TOKEN_MAX_LEN,
|
||||||
|
DESCRIPTION_MAX_LEN,
|
||||||
|
} = _validators;
|
||||||
|
|
||||||
|
describe('validateApiToken', () => {
|
||||||
|
test('accepts canonical tskey-api-...', () => {
|
||||||
|
expect(validateApiToken('tskey-api-abc123')).toBeNull();
|
||||||
|
});
|
||||||
|
test('rejects empty', () => {
|
||||||
|
expect(validateApiToken('')).toMatch(/required/);
|
||||||
|
});
|
||||||
|
test('rejects undefined / null', () => {
|
||||||
|
expect(validateApiToken(undefined)).toMatch(/required/);
|
||||||
|
expect(validateApiToken(null)).toMatch(/required/);
|
||||||
|
});
|
||||||
|
test('rejects non-string (number, object, array)', () => {
|
||||||
|
expect(validateApiToken(123)).toMatch(/must be a string/);
|
||||||
|
expect(validateApiToken({})).toMatch(/must be a string/);
|
||||||
|
expect(validateApiToken(['x'])).toMatch(/must be a string/);
|
||||||
|
});
|
||||||
|
test('rejects wrong prefix', () => {
|
||||||
|
expect(validateApiToken('not-a-token')).toMatch(/must start with/);
|
||||||
|
});
|
||||||
|
test('accepts exactly at length cap', () => {
|
||||||
|
const token = 'tskey-api-' + 'x'.repeat(TAILSCALE_TOKEN_MAX_LEN - 'tskey-api-'.length);
|
||||||
|
expect(validateApiToken(token)).toBeNull();
|
||||||
|
});
|
||||||
|
test('rejects 1 over length cap', () => {
|
||||||
|
const token = 'tskey-api-' + 'x'.repeat(TAILSCALE_TOKEN_MAX_LEN - 'tskey-api-'.length + 1);
|
||||||
|
expect(validateApiToken(token)).toMatch(/exceeds maximum length/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateTags', () => {
|
||||||
|
test('accepts undefined / null (optional)', () => {
|
||||||
|
expect(validateTags(undefined)).toBeNull();
|
||||||
|
expect(validateTags(null)).toBeNull();
|
||||||
|
});
|
||||||
|
test('rejects non-array', () => {
|
||||||
|
expect(validateTags('tag:foo')).toMatch(/must be an array/);
|
||||||
|
expect(validateTags({})).toMatch(/must be an array/);
|
||||||
|
});
|
||||||
|
test('rejects entries that are not strings', () => {
|
||||||
|
expect(validateTags(['tag:a', null])).toMatch(/tags\[1\]/);
|
||||||
|
expect(validateTags(['tag:a', 123])).toMatch(/tags\[1\]/);
|
||||||
|
expect(validateTags(['tag:a', {}])).toMatch(/tags\[1\]/);
|
||||||
|
});
|
||||||
|
test('rejects uppercase / whitespace / CRLF', () => {
|
||||||
|
expect(validateTags(['TAG:foo'])).toMatch(/tags\[0\]/);
|
||||||
|
expect(validateTags(['tag:foo bar'])).toMatch(/tags\[0\]/);
|
||||||
|
expect(validateTags(['tag:foo\r\nbar'])).toMatch(/tags\[0\]/);
|
||||||
|
});
|
||||||
|
test('rejects entries starting with non-alnum (no leading colon)', () => {
|
||||||
|
expect(validateTags([':foo'])).toMatch(/tags\[0\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects bare "tag:" with empty name (Tailscale spec violation) (DC-080 round-2)', () => {
|
||||||
|
expect(validateTags(['tag:'])).toMatch(/tags\[0\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects colon-only chars after tag: prefix (DC-080 round-2)', () => {
|
||||||
|
expect(validateTags(['tag:::'])).toMatch(/tags\[0\]/);
|
||||||
|
expect(validateTags(['tag:---'])).toMatch(/tags\[0\]/);
|
||||||
|
});
|
||||||
|
test('accepts canonical tag:server form', () => {
|
||||||
|
expect(validateTags(['tag:server'])).toBeNull();
|
||||||
|
expect(validateTags(['tag:guest-plex', 'tag:server'])).toBeNull();
|
||||||
|
});
|
||||||
|
test('rejects empty array entry', () => {
|
||||||
|
expect(validateTags(['tag:a', ''])).toMatch(/tags\[1\]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateDescription', () => {
|
||||||
|
test('accepts undefined / null', () => {
|
||||||
|
expect(validateDescription(undefined)).toBeNull();
|
||||||
|
expect(validateDescription(null)).toBeNull();
|
||||||
|
});
|
||||||
|
test('rejects non-string', () => {
|
||||||
|
expect(validateDescription(123)).toMatch(/must be a string/);
|
||||||
|
});
|
||||||
|
test('rejects over 120 chars', () => {
|
||||||
|
const long = 'a'.repeat(DESCRIPTION_MAX_LEN + 1);
|
||||||
|
expect(validateDescription(long)).toMatch(/exceeds maximum length/);
|
||||||
|
});
|
||||||
|
test('accepts at the cap', () => {
|
||||||
|
const exact = 'a'.repeat(DESCRIPTION_MAX_LEN);
|
||||||
|
expect(validateDescription(exact)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exports surface stays in sync with constants used inside validators', () => {
|
||||||
|
// Guard against drift: if a future refactor renames a constant, this fails
|
||||||
|
expect(TAILSCALE_TOKEN_PREFIX).toBe('tskey-api-');
|
||||||
|
expect(typeof TAILSCALE_TOKEN_MAX_LEN).toBe('number');
|
||||||
|
expect(typeof DESCRIPTION_MAX_LEN).toBe('number');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -50,6 +50,17 @@ describe('TOTP session cookie scope', () => {
|
|||||||
expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i);
|
expect(cookie).not.toMatch(/(?:^|;)\s*Domain=/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('host-bound SSO token can only be redeemed on its intended service host', () => {
|
||||||
|
const session = buildSession();
|
||||||
|
const wrongHostToken = session.createHandoffToken('plex.sami');
|
||||||
|
expect(session.redeemHandoffToken(wrongHostToken, 'chat.sami')).toBe(false);
|
||||||
|
expect(session.redeemHandoffToken(wrongHostToken, 'plex.sami')).toBe(false);
|
||||||
|
|
||||||
|
const correctHostToken = session.createHandoffToken('plex.sami');
|
||||||
|
expect(session.redeemHandoffToken(correctHostToken, 'plex.sami')).toBe(true);
|
||||||
|
expect(session.redeemHandoffToken(correctHostToken, 'plex.sami')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('logout clears the host-only secure cookie', () => {
|
test('logout clears the host-only secure cookie', () => {
|
||||||
const session = buildSession();
|
const session = buildSession();
|
||||||
const headers = {};
|
const headers = {};
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
/**
|
||||||
|
* DC-083 -- Public share endpoint input hardening.
|
||||||
|
*
|
||||||
|
* The two CSRF-exempt public endpoints (POST /share/:token/subscribe +
|
||||||
|
* POST /share/:token/redeem-tailscale) accept untrusted body fields. The
|
||||||
|
* pre-fix code had three coupled bugs:
|
||||||
|
*
|
||||||
|
* 1. `email.includes('@')` accepted `@`, `a@`, `<script>@x.c`, and 10MB
|
||||||
|
* strings as "valid email" -- and the field was never even used after
|
||||||
|
* validation (the subscribe endpoint discarded it).
|
||||||
|
* 2. `typeof deviceId === 'string'` accepted arbitrary strings of any
|
||||||
|
* length, including CR/LF/NUL -- which fed straight into the Tailscale
|
||||||
|
* auth-key description string and the on-disk shares.json.
|
||||||
|
* 3. No rate-limit; the general limiter (1000/15min) was too generous for
|
||||||
|
* unauthenticated state-mutating endpoints.
|
||||||
|
*
|
||||||
|
* Fix: charset/length/control-char-bounded validators at the route layer
|
||||||
|
* AND at the store layer (defense-in-depth), plus a dedicated
|
||||||
|
* SHARE_PUBLIC rate-limit (30/15min) on the public endpoints.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* - subscribe email: rejects bare @, missing TLD, oversized, CR/LF, shell
|
||||||
|
* metachars, control chars; accepts normal addresses; accepts OMITTED
|
||||||
|
* email (backwards-compatible with the original behavior).
|
||||||
|
* - subscribe email propagates to share-store subscriberEmails (capped 8).
|
||||||
|
* - redeem-tailscale deviceId: rejects CR/LF/NUL, oversized, empty,
|
||||||
|
* spaces, brackets, quotes; accepts Tailscale-style base64url+hphens;
|
||||||
|
* accepts OMITTED deviceId (treated as 'unknown').
|
||||||
|
* - Sanitized usedBy is what flows into the on-disk shares.json.
|
||||||
|
* - Rate-limit fires after the configured budget per IP.
|
||||||
|
* - Store-level defense: bypassing the route (direct store call) still
|
||||||
|
* rejects invalid inputs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const { createShareStore } = require('../src/security/share-store');
|
||||||
|
|
||||||
|
function _tmpDir() {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-share-dc083-'));
|
||||||
|
}
|
||||||
|
function _cleanup(dir) {
|
||||||
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildApp({ shareStore } = {}) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
// No req.user injection -- the public endpoints must work without auth.
|
||||||
|
const shareRoutes = require('../routes/share');
|
||||||
|
app.use(shareRoutes({
|
||||||
|
shareStore,
|
||||||
|
licenseManager: { isPro: () => true, allowsLifetimeLicense: () => false },
|
||||||
|
tailscaleCoord: { createAuthKey: async () => ({ id: 'k', key: 'tskey-x' }) },
|
||||||
|
notificationManager: { sendEmail: async () => ({ messageId: 'fake' }) },
|
||||||
|
servicesStateManager: { get: async () => null, read: async () => [] },
|
||||||
|
servicesFile: null,
|
||||||
|
asyncHandler: (fn, _label) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||||
|
log: { info() {}, warn() {}, error() {} },
|
||||||
|
}));
|
||||||
|
app.use((err, _req, res, _next) => {
|
||||||
|
if (err && err.statusCode) {
|
||||||
|
return res.status(err.statusCode).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
code: err.code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.status(500).json({ success: false, error: err && err.message });
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------- Subscribe endpoint -- email validation ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('DC-083: subscribe email validation', () => {
|
||||||
|
let dir, shareStore;
|
||||||
|
beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
|
||||||
|
afterEach(() => _cleanup(dir));
|
||||||
|
|
||||||
|
test('accepts omitted email (backwards-compatible)', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app).post(`/share/${issued.token}/subscribe`).send({});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.data.count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a well-formed email', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: 'subscriber@example.com' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.data.count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lowercases the email on capture', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: 'Subscriber@Example.COM' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
||||||
|
const id = Object.keys(raw.shares)[0];
|
||||||
|
expect(raw.shares[id].subscriberEmails).toEqual(['subscriber@example.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects bare @', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: '@' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects missing local-part', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: '@example.com' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects missing TLD', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: 'user@localhost' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects single-char TLD', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: 'user@example.c' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects CR/LF in email (CRLF-injection defense)', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: 'a@b.com\r\nX-Injected: yes' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects NUL in email', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: 'a@b.com\x00hack' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects oversized email (>254 chars)', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const longLocal = 'a'.repeat(250) + '@example.com';
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: longLocal });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects XSS-shape email', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: '<script>@x.com' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-string email', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: 42 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps subscriberEmails capped to 8 entries', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
await request(app)
|
||||||
|
.post(`/share/${issued.token}/subscribe`)
|
||||||
|
.send({ email: `user${i}@example.com` });
|
||||||
|
}
|
||||||
|
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
||||||
|
const id = Object.keys(raw.shares)[0];
|
||||||
|
expect(raw.shares[id].subscriberEmails).toHaveLength(8);
|
||||||
|
// FIFO cap -- the first 4 got dropped, latest 8 remain.
|
||||||
|
expect(raw.shares[id].subscriberEmails[0]).toBe('user4@example.com');
|
||||||
|
expect(raw.shares[id].subscriberEmails[7]).toBe('user11@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omitted email does not write subscriberEmails', async () => {
|
||||||
|
const issued = await shareStore.issuePublic({ serviceId: 'plex' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
await request(app).post(`/share/${issued.token}/subscribe`).send({});
|
||||||
|
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
||||||
|
const id = Object.keys(raw.shares)[0];
|
||||||
|
expect(raw.shares[id].subscriberEmails).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------- Redeem-tailscale endpoint -- deviceId validation ---------------------------------------------------------
|
||||||
|
|
||||||
|
describe('DC-083: redeem-tailscale deviceId validation', () => {
|
||||||
|
let dir, shareStore;
|
||||||
|
beforeEach(() => { dir = _tmpDir(); shareStore = createShareStore({ dataDir: dir }); });
|
||||||
|
afterEach(() => _cleanup(dir));
|
||||||
|
|
||||||
|
test('accepts Tailscale-style base64url ID', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: 'nodekey-abc123-def456' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.data.redeemed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts OMITTED deviceId (treated as "unknown")', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
||||||
|
const id = Object.keys(raw.shares)[0];
|
||||||
|
expect(raw.shares[id].usedBy).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects CR/LF in deviceId (CRLF-injection defense)', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: 'nodekey\r\nX-Injected: yes' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects NUL in deviceId', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: 'nodekey\x00hack' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects oversized deviceId (>128 chars)', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const long = 'a'.repeat(200);
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: long });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects empty string deviceId', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: '' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects whitespace in deviceId', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: 'node key 1' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects shell metachars in deviceId', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: 'nodekey; rm -rf /' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-string deviceId', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: { evil: true } });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitized usedBy flows into the on-disk shares.json', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: 'node-abc.def-123' });
|
||||||
|
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8'));
|
||||||
|
const id = Object.keys(raw.shares)[0];
|
||||||
|
expect(raw.shares[id].usedBy).toBe('node-abc.def-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejection does NOT mark the share used', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
const bad = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: 'node with spaces' });
|
||||||
|
expect(bad.status).toBe(400);
|
||||||
|
// A FOLLOW-UP valid redeem should still succeed.
|
||||||
|
const ok = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: 'node-clean' });
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------- Store-layer defense-in-depth (bypass the route, hit the store) ------------
|
||||||
|
|
||||||
|
describe('DC-083: store-layer defense-in-depth', () => {
|
||||||
|
let dir, store;
|
||||||
|
beforeEach(() => { dir = _tmpDir(); store = createShareStore({ dataDir: dir }); });
|
||||||
|
afterEach(() => _cleanup(dir));
|
||||||
|
|
||||||
|
test('recordPublicSubscribe rejects CRLF in email', async () => {
|
||||||
|
const issued = await store.issuePublic({ serviceId: 'svc' });
|
||||||
|
const r = await store.recordPublicSubscribe(issued.token, { email: 'a@b.com\r\nX: 1' });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toBe('invalid_email');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recordPublicSubscribe rejects oversized email', async () => {
|
||||||
|
const issued = await store.issuePublic({ serviceId: 'svc' });
|
||||||
|
const r = await store.recordPublicSubscribe(issued.token, { email: 'a'.repeat(300) + '@x.com' });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toBe('invalid_email');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recordTailscaleUse rejects CRLF in deviceId', async () => {
|
||||||
|
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
|
||||||
|
const r = await store.recordTailscaleUse(issued.token, { deviceId: 'node\r\nhack' });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toBe('invalid_device_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recordTailscaleUse rejects oversized deviceId', async () => {
|
||||||
|
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
|
||||||
|
const r = await store.recordTailscaleUse(issued.token, { deviceId: 'a'.repeat(200) });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toBe('invalid_device_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recordTailscaleUse accepts null deviceId (defaults to "unknown")', async () => {
|
||||||
|
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
|
||||||
|
const r = await store.recordTailscaleUse(issued.token, { deviceId: null });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.share.usedBy).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recordTailscaleUse accepts omitted deviceId (defaults to "unknown")', async () => {
|
||||||
|
const issued = await store.issueTailscale({ serviceId: 'svc', email: 'a@b.com' });
|
||||||
|
const r = await store.recordTailscaleUse(issued.token, {});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.share.usedBy).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recordPublicSubscribe accepts omitted email (backwards-compatible)', async () => {
|
||||||
|
const issued = await store.issuePublic({ serviceId: 'svc' });
|
||||||
|
const r = await store.recordPublicSubscribe(issued.token);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recordPublicSubscribe accepts null email (backwards-compatible)', async () => {
|
||||||
|
const issued = await store.issuePublic({ serviceId: 'svc' });
|
||||||
|
const r = await store.recordPublicSubscribe(issued.token, { email: null });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------- Rate-limit guard ------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('DC-083: SHARE_PUBLIC rate-limit', () => {
|
||||||
|
// We can't easily trigger the rate-limit in a unit test because the
|
||||||
|
// default 30/15min is high. Instead, verify the constant is wired and
|
||||||
|
// that the limiter is mounted on the public endpoints (the test env
|
||||||
|
// skips the limiter, so we just confirm the constants).
|
||||||
|
test('RATE_LIMITS.SHARE_PUBLIC is bounded tighter than GENERAL', () => {
|
||||||
|
const { RATE_LIMITS } = require('../src/utilities/constants');
|
||||||
|
expect(RATE_LIMITS.SHARE_PUBLIC).toBeDefined();
|
||||||
|
expect(RATE_LIMITS.SHARE_PUBLIC.max).toBeLessThan(RATE_LIMITS.GENERAL.max);
|
||||||
|
expect(RATE_LIMITS.SHARE_PUBLIC.max).toBeLessThanOrEqual(30);
|
||||||
|
expect(RATE_LIMITS.SHARE_PUBLIC.windowMs).toBe(15 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('route module loads without throwing when express-rate-limit is wired', () => {
|
||||||
|
// Smoke test: the route factory must succeed with the limiter attached.
|
||||||
|
const dir = _tmpDir();
|
||||||
|
try {
|
||||||
|
const shareStore = createShareStore({ dataDir: dir });
|
||||||
|
const app = _buildApp({ shareStore });
|
||||||
|
// _buildApp would have thrown if the route factory threw.
|
||||||
|
expect(typeof app).toBe('function');
|
||||||
|
} finally {
|
||||||
|
_cleanup(dir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sharePublicLimiter is mounted on /preview (route stack contains limiter)', () => {
|
||||||
|
// Verify the limiter middleware is actually wired into /preview's route
|
||||||
|
// stack. The route uses express.Router().use(path, ...mw, handler) so we
|
||||||
|
// can inspect the stack via the router's internal `stack` array.
|
||||||
|
const dir = _tmpDir();
|
||||||
|
try {
|
||||||
|
const shareStore = createShareStore({ dataDir: dir });
|
||||||
|
const router = require('../routes/share')({
|
||||||
|
shareStore,
|
||||||
|
licenseManager: { isPro: () => true, allowsLifetimeLicense: () => false },
|
||||||
|
tailscaleCoord: { createAuthKey: async () => ({ id: 'k', key: 'tskey-x' }) },
|
||||||
|
notificationManager: { sendEmail: async () => ({ messageId: 'fake' }) },
|
||||||
|
servicesStateManager: { get: async () => null, read: async () => [] },
|
||||||
|
servicesFile: null,
|
||||||
|
asyncHandler: (fn, _label) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||||
|
log: { info() {}, warn() {}, error() {} },
|
||||||
|
});
|
||||||
|
const previewStack = router.stack.find(
|
||||||
|
(layer) => layer.route && layer.route.path === '/share/:token/preview'
|
||||||
|
);
|
||||||
|
expect(previewStack).toBeDefined();
|
||||||
|
// The route handler should be preceded by at least one middleware
|
||||||
|
// layer (the limiter). route.stack contains the per-route middleware.
|
||||||
|
// In express, .route.stack has the route-local middleware + handler.
|
||||||
|
// The limiter is mounted at the router level (router.use pattern), so
|
||||||
|
// it's actually a separate layer in router.stack. Look for any layer
|
||||||
|
// that has a regex/path matching /share/:token.
|
||||||
|
const limiterLayer = router.stack.find(
|
||||||
|
(layer) => layer.regexp && layer.regexp.test && layer.regexp.test('/share/abc/preview')
|
||||||
|
);
|
||||||
|
expect(limiterLayer).toBeDefined();
|
||||||
|
} finally {
|
||||||
|
_cleanup(dir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DC-083: positive smoke tests (legitimate inputs)', () => {
|
||||||
|
test('validates user+tag@sub.domain.io (RFC 5322 plus addressing)', () => {
|
||||||
|
const { validatePublicEmail } = require('../src/security/share-store');
|
||||||
|
const v = validatePublicEmail('user+tag@sub.domain.io');
|
||||||
|
expect(v).toEqual({ ok: true, email: 'user+tag@sub.domain.io' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates a typical Tailscale node ID as deviceId', () => {
|
||||||
|
const { validatePublicDeviceId } = require('../src/security/share-store');
|
||||||
|
// Tailscale node IDs look like "nodekey:abcdef0123456789" or just hex
|
||||||
|
const v = validatePublicDeviceId('nodekey:abcdef0123456789');
|
||||||
|
expect(v).toEqual({ ok: true, deviceId: 'nodekey:abcdef0123456789' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -378,12 +378,35 @@ describe('share routes: POST /share/:token/redeem-tailscale (public)', () => {
|
|||||||
expect(r2.body.error).toMatch(/already_used/);
|
expect(r2.body.error).toMatch(/already_used/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects missing deviceId', async () => {
|
test('rejects missing deviceId — DC-083 accepts omitted, treats as "unknown"', async () => {
|
||||||
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
const app = _buildApp({ shareStore, noAdmin: true });
|
const app = _buildApp({ shareStore, noAdmin: true });
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post(`/share/${issued.token}/redeem-tailscale`)
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
.send({});
|
.send({});
|
||||||
|
// DC-083: omitted deviceId is now accepted; the store defaults usedBy
|
||||||
|
// to 'unknown'. The pre-fix route layer required deviceId be present;
|
||||||
|
// the new behavior matches the store's defensive default and is
|
||||||
|
// safer for partially-malformed forward_auth calls from Caddy.
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.data.redeemed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid deviceId (control chars / oversized)', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore, noAdmin: true });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: 'node\r\nhack' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects empty deviceId', async () => {
|
||||||
|
const issued = await shareStore.issueTailscale({ serviceId: 'plex', email: 'a@b.com' });
|
||||||
|
const app = _buildApp({ shareStore, noAdmin: true });
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/share/${issued.token}/redeem-tailscale`)
|
||||||
|
.send({ deviceId: '' });
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
const request = require('supertest');
|
const request = require('supertest');
|
||||||
const createSsoRouter = require('../routes/auth/sso-gate');
|
const createSsoRouter = require('../routes/auth/sso-gate');
|
||||||
|
|
||||||
function createApp({ redeem = true } = {}) {
|
function loadCredentialVaultHandoff() {
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
path.join(__dirname, '..', '..', 'status', 'js', 'credential-vault-handoff.js'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const window = { location: { origin: 'https://status.sami' } };
|
||||||
|
vm.runInNewContext(source, { window, SITE: { tld: '.sami' }, URL });
|
||||||
|
return window.DCCredentialVault;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createApp({ redeem = true, valid = true, storedCredentials = {}, dashboardHost = 'status.sami' } = {}) {
|
||||||
const app = express();
|
const app = express();
|
||||||
const session = {
|
const session = {
|
||||||
redeemHandoffToken: jest.fn().mockReturnValue(redeem),
|
redeemHandoffToken: jest.fn((token) => (typeof redeem === 'function' ? redeem(token) : redeem)),
|
||||||
setCookieHostOnly: jest.fn((res) => {
|
setCookieHostOnly: jest.fn((res) => {
|
||||||
res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax');
|
res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax');
|
||||||
}),
|
}),
|
||||||
isValid: jest.fn().mockReturnValue(true),
|
isValid: jest.fn().mockReturnValue(valid),
|
||||||
|
createHandoffToken: jest.fn().mockReturnValue('fresh-sso-handoff-token'),
|
||||||
};
|
};
|
||||||
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
const errorResponse = (res, status, message, extra = {}) => res.status(status).json({ success: false, error: message, ...extra });
|
const errorResponse = (res, status, message, extra = {}) => res.status(status).json({ success: false, error: message, ...extra });
|
||||||
@@ -21,14 +35,15 @@ function createApp({ redeem = true } = {}) {
|
|||||||
log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
||||||
getAppSession: jest.fn(),
|
getAppSession: jest.fn(),
|
||||||
appSessionCache: new Map(),
|
appSessionCache: new Map(),
|
||||||
credentialManager: { retrieve: jest.fn() },
|
credentialManager: { retrieve: jest.fn((key) => Promise.resolve(storedCredentials[key] || null)) },
|
||||||
fetchT: jest.fn(),
|
fetchT: jest.fn(),
|
||||||
getServiceById: jest.fn(),
|
getServiceById: jest.fn((id) => Promise.resolve({ id, url: `https://${id}.sami` })),
|
||||||
licenseManager: {
|
licenseManager: {
|
||||||
hasFeature: jest.fn().mockReturnValue(true),
|
hasFeature: jest.fn().mockReturnValue(true),
|
||||||
requirePremium: jest.fn(() => (_req, _res, next) => next()),
|
requirePremium: jest.fn(() => (_req, _res, next) => next()),
|
||||||
},
|
},
|
||||||
servicesStateManager: { read: jest.fn().mockResolvedValue([]) },
|
servicesStateManager: { read: jest.fn().mockResolvedValue([]) },
|
||||||
|
siteConfig: { dashboardHost },
|
||||||
});
|
});
|
||||||
app.use('/api/v1', router);
|
app.use('/api/v1', router);
|
||||||
return { app, session };
|
return { app, session };
|
||||||
@@ -44,7 +59,7 @@ describe('cross-host SSO exchange redirect', () => {
|
|||||||
expect(res.status).toBe(303);
|
expect(res.status).toBe(303);
|
||||||
expect(res.headers.location).toBe('/settings?tab=network#dns');
|
expect(res.headers.location).toBe('/settings?tab=network#dns');
|
||||||
expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
||||||
expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time');
|
expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time', '127.0.0.1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
@@ -82,3 +97,105 @@ describe('cross-host SSO exchange redirect', () => {
|
|||||||
expect(session.setCookieHostOnly).not.toHaveBeenCalled();
|
expect(session.setCookieHostOnly).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('existing-session SSO handoff', () => {
|
||||||
|
test('mints a handoff token without asking for TOTP again', async () => {
|
||||||
|
const { app, session } = createApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/v1/auth/sso-handoff?serviceId=plex')
|
||||||
|
.set('Cookie', 'dashcaddy_session=valid-session');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({ success: true, ssoToken: 'fresh-sso-handoff-token' });
|
||||||
|
expect(session.createHandoffToken).toHaveBeenCalledTimes(1);
|
||||||
|
expect(session.createHandoffToken).toHaveBeenCalledWith('plex.sami');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses to mint a handoff token without a valid session', async () => {
|
||||||
|
const { app, session } = createApp({ valid: false });
|
||||||
|
const res = await request(app).get('/api/v1/auth/sso-handoff?serviceId=plex');
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(session.createHandoffToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completes the full mint, exchange, cookie, redirect lifecycle', async () => {
|
||||||
|
const issued = new Set(['fresh-sso-handoff-token']);
|
||||||
|
const redeemOnce = (token) => issued.delete(token);
|
||||||
|
const { app } = createApp({ redeem: redeemOnce });
|
||||||
|
|
||||||
|
const mint = await request(app)
|
||||||
|
.get('/api/v1/auth/sso-handoff?serviceId=plex')
|
||||||
|
.set('Cookie', 'dashcaddy_session=valid-session');
|
||||||
|
const exchange = await request(app)
|
||||||
|
.get('/api/v1/auth/sso-exchange')
|
||||||
|
.query({ token: mint.body.ssoToken, return: '/web/' });
|
||||||
|
|
||||||
|
expect(exchange.status).toBe(303);
|
||||||
|
expect(exchange.headers.location).toBe('/web/');
|
||||||
|
expect(exchange.headers['set-cookie'][0]).toContain('dashcaddy_session=');
|
||||||
|
expect(exchange.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
||||||
|
|
||||||
|
const replay = await request(app)
|
||||||
|
.get('/api/v1/auth/sso-exchange')
|
||||||
|
.query({ token: mint.body.ssoToken, return: '/web/' });
|
||||||
|
expect(replay.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encrypted-vault credential onboarding', () => {
|
||||||
|
test('app-token identifies missing credentials as a form requirement', async () => {
|
||||||
|
const { app } = createApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/v1/auth/app-token/plex')
|
||||||
|
.set('Cookie', 'dashcaddy_session=valid-session');
|
||||||
|
|
||||||
|
expect(res.status).toBe(428);
|
||||||
|
expect(res.body).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
credentialsRequired: true,
|
||||||
|
serviceId: 'plex',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('service login page sends missing credentials to the encrypted vault form', async () => {
|
||||||
|
const { app } = createApp();
|
||||||
|
const res = await request(app).get('/api/v1/auth/login-page?service=plex');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.text).toContain("if(j.credentialsRequired){vault('plex');return}");
|
||||||
|
expect(res.text).toContain("dashboardOrigin+'?credentials='");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('service login page derives the vault origin from trusted dashboard config', async () => {
|
||||||
|
const { app } = createApp({ dashboardHost: 'dashboard.home' });
|
||||||
|
const res = await request(app).get('/api/v1/auth/login-page?service=plex');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.text).toContain('dashboardOrigin="https://dashboard.home"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full vault-save handoff lifecycle reaches exchange, cookie, and final service path', async () => {
|
||||||
|
const issued = new Set(['fresh-sso-handoff-token']);
|
||||||
|
const { app } = createApp({ redeem: (token) => issued.delete(token) });
|
||||||
|
const mint = await request(app)
|
||||||
|
.get('/api/v1/auth/sso-handoff?serviceId=plex')
|
||||||
|
.set('Cookie', 'dashcaddy_session=valid-session');
|
||||||
|
|
||||||
|
const vault = loadCredentialVaultHandoff();
|
||||||
|
const target = new URL(vault.buildHandoffTarget(
|
||||||
|
'https://plex.sami/web/?direct=1#home',
|
||||||
|
mint.body.ssoToken,
|
||||||
|
'plex',
|
||||||
|
));
|
||||||
|
// The shared Caddy snippet rewrites /dashcaddy-sso to the canonical API
|
||||||
|
// route while preserving the token and relative return query.
|
||||||
|
const exchange = await request(app).get('/api/v1/auth/sso-exchange' + target.search);
|
||||||
|
|
||||||
|
expect(target.pathname).toBe('/dashcaddy-sso');
|
||||||
|
expect(exchange.status).toBe(303);
|
||||||
|
expect(exchange.headers.location).toBe('/web/?direct=1#home');
|
||||||
|
expect(exchange.headers['set-cookie'][0]).toContain('dashcaddy_session=');
|
||||||
|
expect(exchange.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* DC-082: Update-manager image-name parsing for docker-compose prefixed names.
|
||||||
|
*
|
||||||
|
* The pre-fix code normalized `dashcaddy-dashcaddy-api:latest` to
|
||||||
|
* `library/dashcaddy-dashcaddy-api:latest` before probing Docker Hub.
|
||||||
|
* Compose-prefixed names (single hyphen, no slash, lowercase) need to
|
||||||
|
* split on the FIRST hyphen to recover `<project>/<service>` — that's
|
||||||
|
* the actual upstream namespace for a compose-prefixed image.
|
||||||
|
*
|
||||||
|
* The fix also adds a "no upstream registry image, skip cleanly" path
|
||||||
|
* for when the authed GET 401s against a compose-prefixed name (the
|
||||||
|
* compose-prefixed image is built locally and not published to Docker
|
||||||
|
* Hub). That should log as info, not error.
|
||||||
|
*/
|
||||||
|
const updateManager = require('../src/managers/update-manager');
|
||||||
|
|
||||||
|
describe('DC-082 update-manager / compose-prefixed image names', () => {
|
||||||
|
let um = updateManager; // module exports the singleton instance
|
||||||
|
|
||||||
|
describe('_composeProjectToRepo', () => {
|
||||||
|
test('splits dashcaddy-dashcaddy-api on the first hyphen', () => {
|
||||||
|
expect(um._composeProjectToRepo('dashcaddy-dashcaddy-api')).toBe('dashcaddy/dashcaddy-api');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('splits myproject-myservice on the first hyphen', () => {
|
||||||
|
expect(um._composeProjectToRepo('myproject-myservice')).toBe('myproject/myservice');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('splits multi-hyphen names on the FIRST hyphen only', () => {
|
||||||
|
// "myproj-grandchild-service" -> "myproj/grandchild-service"
|
||||||
|
// (first hyphen is the project/service boundary; later hyphens are
|
||||||
|
// part of the service name like docker-compose's `web-cache`).
|
||||||
|
expect(um._composeProjectToRepo('myproj-grandchild-service')).toBe('myproj/grandchild-service');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for slash-namespaced names (handled by other path)', () => {
|
||||||
|
expect(um._composeProjectToRepo('dashcaddy/dashcaddy-api')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('library/nginx')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('ghcr.io/x/y')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for Docker Official Image names (no hyphen)', () => {
|
||||||
|
expect(um._composeProjectToRepo('nginx')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('alpine')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('node')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for empty / malformed input', () => {
|
||||||
|
expect(um._composeProjectToRepo('')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo(null)).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo(undefined)).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo(123)).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('-foo')).toBe(null); // leading hyphen
|
||||||
|
expect(um._composeProjectToRepo('foo-')).toBe(null); // trailing hyphen
|
||||||
|
// The regex tolerates mixed-case via the /i flag for defensiveness
|
||||||
|
// even though Docker Compose names are typically lowercase — the
|
||||||
|
// important shape constraints are the letter/digit/underscore/hyphen
|
||||||
|
// charset and the non-empty two-part split.
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts names with underscores and digits (compose allows)', () => {
|
||||||
|
expect(um._composeProjectToRepo('proj-v2_service')).toBe('proj/v2_service');
|
||||||
|
expect(um._composeProjectToRepo('dashcaddy-api-v2')).toBe('dashcaddy/api-v2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects names with chars compose never produces', () => {
|
||||||
|
// dot/colon/slash should never pass — they're either already-namespaced
|
||||||
|
// or invalid in a Docker Compose service name.
|
||||||
|
expect(um._composeProjectToRepo('foo:bar')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('foo.bar')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('foo/bar')).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_isNotPublishedError', () => {
|
||||||
|
test('returns true for HTTP 401 + compose-prefixed remainder', () => {
|
||||||
|
const err = new Error('Docker Hub registry returned HTTP 401 after auth');
|
||||||
|
expect(um._isNotPublishedError(err, 'dashcaddy-dashcaddy-api')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false for HTTP 401 with non-compose-prefixed remainder', () => {
|
||||||
|
const err = new Error('Docker Hub registry returned HTTP 401 after auth');
|
||||||
|
expect(um._isNotPublishedError(err, 'nginx')).toBe(false);
|
||||||
|
expect(um._isNotPublishedError(err, 'library/nginx')).toBe(false);
|
||||||
|
expect(um._isNotPublishedError(err, 'dashcaddy/some-image')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false for non-401 errors', () => {
|
||||||
|
const err = new Error('network timeout after 10s');
|
||||||
|
expect(um._isNotPublishedError(err, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false for malformed error or remainder', () => {
|
||||||
|
expect(um._isNotPublishedError(null, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||||
|
expect(um._isNotPublishedError({}, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||||
|
expect(um._isNotPublishedError({ message: 'no string' }, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||||
|
expect(um._isNotPublishedError(new Error('HTTP 401'), null)).toBe(false);
|
||||||
|
expect(um._isNotPublishedError(new Error('HTTP 401'), '')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLatestImageDigest (mocked fetchWithReliability)', () => {
|
||||||
|
let originalFetch;
|
||||||
|
let originalFetchAuth;
|
||||||
|
let originalFetchRetry;
|
||||||
|
beforeEach(() => {
|
||||||
|
originalFetch = um.fetchWithReliability.bind(um);
|
||||||
|
originalFetchAuth = um.fetchAuthToken.bind(um);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compose-prefixed name (dashcaddy-dashcaddy-api) probes dashcaddy/dashcaddy-api (NOT library/dashcaddy-dashcaddy-api)', async () => {
|
||||||
|
const calls = [];
|
||||||
|
um.fetchWithReliability = async (opts) => {
|
||||||
|
calls.push(opts);
|
||||||
|
// Simulate the real Docker Hub: 401 with WWW-Auth, then 401 after token
|
||||||
|
// (because dashcaddy/dashcaddy-api doesn't exist on Docker Hub).
|
||||||
|
if (calls.length === 1) {
|
||||||
|
return {
|
||||||
|
statusCode: 401,
|
||||||
|
headers: {
|
||||||
|
'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:dashcaddy/dashcaddy-api:pull"',
|
||||||
|
},
|
||||||
|
body: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { statusCode: 401, headers: {}, body: '{"errors":[{"code":"UNAUTHORIZED","message":"authentication required"}]}' };
|
||||||
|
};
|
||||||
|
um.fetchAuthToken = async () => 'fake-token';
|
||||||
|
const { log } = require('../src/utils/logging');
|
||||||
|
const infoSpy = jest.spyOn(log, 'info').mockImplementation(() => {});
|
||||||
|
const errorSpy = jest.spyOn(log, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = await um.getLatestImageDigest('dashcaddy-dashcaddy-api:latest');
|
||||||
|
expect(result).toBe(null);
|
||||||
|
|
||||||
|
// First probe must target /v2/dashcaddy/dashcaddy-api/manifests/latest
|
||||||
|
// NOT /v2/library/dashcaddy-dashcaddy-api/manifests/latest
|
||||||
|
const firstPath = calls[0].path;
|
||||||
|
expect(firstPath).toBe('/v2/dashcaddy/dashcaddy-api/manifests/latest');
|
||||||
|
expect(firstPath).not.toContain('library/dashcaddy-dashcaddy-api');
|
||||||
|
|
||||||
|
// The 401 after auth should produce an INFO log about "no upstream"
|
||||||
|
// NOT an error log.
|
||||||
|
const infoMsgs = infoSpy.mock.calls.map((c) => c[1]);
|
||||||
|
expect(infoMsgs).toContain('No upstream registry image — skipping update check');
|
||||||
|
const errorMsgs = errorSpy.mock.calls.map((c) => c[1]);
|
||||||
|
expect(errorMsgs).not.toContain('Docker Hub registry returned HTTP 401 after auth');
|
||||||
|
|
||||||
|
infoSpy.mockRestore();
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('official image (nginx) still probes library/nginx', async () => {
|
||||||
|
const calls = [];
|
||||||
|
um.fetchWithReliability = async (opts) => {
|
||||||
|
calls.push(opts);
|
||||||
|
if (calls.length === 1) {
|
||||||
|
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:abc123' }, body: '' };
|
||||||
|
}
|
||||||
|
return { statusCode: 200, headers: {}, body: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await um.getLatestImageDigest('nginx:latest');
|
||||||
|
expect(result).toBe('sha256:abc123');
|
||||||
|
expect(calls[0].path).toBe('/v2/library/nginx/manifests/latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('library/nginx (explicit) probes library/nginx', async () => {
|
||||||
|
const calls = [];
|
||||||
|
um.fetchWithReliability = async (opts) => {
|
||||||
|
calls.push(opts);
|
||||||
|
if (calls.length === 1) {
|
||||||
|
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:abc' }, body: '' };
|
||||||
|
}
|
||||||
|
return { statusCode: 200, headers: {}, body: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await um.getLatestImageDigest('library/nginx:latest');
|
||||||
|
expect(result).toBe('sha256:abc');
|
||||||
|
expect(calls[0].path).toBe('/v2/library/nginx/manifests/latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ghcr.io/samiahmed7777/dashcaddy-api uses ghcr.io path (not docker hub)', async () => {
|
||||||
|
const calls = [];
|
||||||
|
um.fetchWithReliability = async (opts) => {
|
||||||
|
calls.push(opts);
|
||||||
|
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:ghcr' }, body: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await um.getLatestImageDigest('ghcr.io/samiahmed7777/dashcaddy-api:latest');
|
||||||
|
expect(result).toBe('sha256:ghcr');
|
||||||
|
expect(calls[0].hostname).toBe('ghcr.io');
|
||||||
|
expect(calls[0].path).toBe('/v2/samiahmed7777/dashcaddy-api/manifests/latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null + skips cleanly when compose-prefixed image has no upstream', async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
um.fetchWithReliability = async (opts) => {
|
||||||
|
callCount += 1;
|
||||||
|
if (callCount === 1) {
|
||||||
|
return {
|
||||||
|
statusCode: 401,
|
||||||
|
headers: { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:myproj-myservice:pull"' },
|
||||||
|
body: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { statusCode: 401, headers: {}, body: '{"errors":[{"code":"UNAUTHORIZED"}]}' };
|
||||||
|
};
|
||||||
|
um.fetchAuthToken = async () => 'fake-token';
|
||||||
|
|
||||||
|
const result = await um.getLatestImageDigest('myproj-myservice:latest');
|
||||||
|
expect(result).toBe(null);
|
||||||
|
// Probe targets the correct namespace (myproj/myservice), not library/.
|
||||||
|
const firstCall = await (async () => {
|
||||||
|
let p;
|
||||||
|
um.fetchWithReliability = async (opts) => { p = opts; return { statusCode: 200, headers: {}, body: '' }; };
|
||||||
|
await um.getLatestImageDigest('myproj-myservice:latest');
|
||||||
|
return p;
|
||||||
|
})();
|
||||||
|
expect(firstCall.path).toBe('/v2/myproj/myservice/manifests/latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
um.fetchWithReliability = originalFetch;
|
||||||
|
um.fetchAuthToken = originalFetchAuth;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -125,6 +125,239 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── DC-078: registry digest probe reliability hardening ──────────────────
|
||||||
|
// Verifies that getLatestImageDigest / getDockerHubDigest / getGhcrDigest /
|
||||||
|
// fetchWithReliability all apply the IPv4-only + timeout + transient-retry
|
||||||
|
// policy. Without these guards, the per-hour checkForUpdates() loop on DNS2
|
||||||
|
// surfaces AggregateError [ETIMEDOUT] in error.log because the container's
|
||||||
|
// /etc/resolv.conf returns AAAA records from Technitium whose IPv6 path to
|
||||||
|
// public registries (Docker Hub, ghcr.io) is intermittently unreachable.
|
||||||
|
describe('DC-078 registry reliability', () => {
|
||||||
|
// Use real timers — fetchWithReliability's retry uses setTimeout for
|
||||||
|
// backoff, which jest's fake timers would block indefinitely.
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useFakeTimers({ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('_httpsRequestOnce sets family: 4 and timeout on the request options', async () => {
|
||||||
|
let capturedOptions = null;
|
||||||
|
const req = {
|
||||||
|
on: jest.fn(),
|
||||||
|
end: jest.fn(),
|
||||||
|
destroy: jest.fn(),
|
||||||
|
};
|
||||||
|
https.request.mockImplementation((options, cb) => {
|
||||||
|
capturedOptions = options;
|
||||||
|
// Return a 200 immediately so the promise resolves cleanly.
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: {},
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
return req;
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateManager._httpsRequestOnce({
|
||||||
|
hostname: 'registry-1.docker.io',
|
||||||
|
path: '/v2/library/nginx/manifests/latest',
|
||||||
|
headers: { Accept: 'application/vnd.docker.distribution.manifest.v2+json' },
|
||||||
|
maxBodyBytes: 65536,
|
||||||
|
});
|
||||||
|
expect(capturedOptions).not.toBeNull();
|
||||||
|
expect(capturedOptions.family).toBe(4);
|
||||||
|
expect(capturedOptions.timeout).toBeGreaterThan(0);
|
||||||
|
expect(capturedOptions.method).toBe('GET');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchWithReliability retries on transient ETIMEDOUT and eventually succeeds', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
https.request.mockImplementation((options, cb) => {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts === 1) {
|
||||||
|
// First attempt: emit ETIMEDOUT via the request 'error' event
|
||||||
|
const reqErr = new Error('request timeout');
|
||||||
|
reqErr.code = 'ETIMEDOUT';
|
||||||
|
const req = {
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'error') setImmediate(() => handler(reqErr));
|
||||||
|
}),
|
||||||
|
end: jest.fn(),
|
||||||
|
destroy: jest.fn(),
|
||||||
|
};
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
// Second attempt: 200 OK with a digest header
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'docker-content-digest': 'sha256:abc123def456' },
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updateManager.fetchWithReliability({
|
||||||
|
hostname: 'registry-1.docker.io',
|
||||||
|
path: '/v2/library/nginx/manifests/latest',
|
||||||
|
});
|
||||||
|
expect(attempts).toBe(2);
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
expect(result.headers['docker-content-digest']).toBe('sha256:abc123def456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchWithReliability does NOT retry on non-transient HTTP errors', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
https.request.mockImplementation((options, cb) => {
|
||||||
|
attempts += 1;
|
||||||
|
const res = {
|
||||||
|
statusCode: 500,
|
||||||
|
headers: {},
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
||||||
|
});
|
||||||
|
const result = await updateManager.fetchWithReliability({
|
||||||
|
hostname: 'registry-1.docker.io',
|
||||||
|
path: '/v2/library/nginx/manifests/latest',
|
||||||
|
});
|
||||||
|
expect(attempts).toBe(1);
|
||||||
|
expect(result.statusCode).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchWithReliability retries up to REGISTRY_MAX_RETRIES then throws', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
https.request.mockImplementation(() => {
|
||||||
|
attempts += 1;
|
||||||
|
const reqErr = new Error('connect ETIMEDOUT');
|
||||||
|
reqErr.code = 'ETIMEDOUT';
|
||||||
|
const req = {
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'error') setImmediate(() => handler(reqErr));
|
||||||
|
}),
|
||||||
|
end: jest.fn(),
|
||||||
|
destroy: jest.fn(),
|
||||||
|
};
|
||||||
|
return req;
|
||||||
|
});
|
||||||
|
await expect(updateManager.fetchWithReliability({
|
||||||
|
hostname: 'registry-1.docker.io',
|
||||||
|
path: '/v2/library/nginx/manifests/latest',
|
||||||
|
})).rejects.toMatchObject({ code: 'ETIMEDOUT' });
|
||||||
|
// 1 initial attempt + REGISTRY_MAX_RETRIES retries
|
||||||
|
expect(attempts).toBe(1 + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getDockerHubDigest returns digest on 200', async () => {
|
||||||
|
https.request.mockImplementation((options, cb) => {
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'docker-content-digest': 'sha256:hubdigest9999' },
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
||||||
|
});
|
||||||
|
const digest = await updateManager.getDockerHubDigest('nginx', 'latest');
|
||||||
|
expect(digest).toBe('sha256:hubdigest9999');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getDockerHubDigest acquires bearer token on 401 then returns digest', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
https.request.mockImplementation((options, cb) => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) {
|
||||||
|
// First call to registry-1.docker.io returns 401 with WWW-Authenticate
|
||||||
|
const res = {
|
||||||
|
statusCode: 401,
|
||||||
|
headers: {
|
||||||
|
'www-authenticate': 'Bearer realm="https://auth.example.com/token",service="registry.docker.io",scope="repository:library/nginx:pull"',
|
||||||
|
},
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
} else if (calls === 2) {
|
||||||
|
// Second call: auth.example.com returns the token JSON
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: {},
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'data') handler(Buffer.from(JSON.stringify({ token: 'jwt-token-xyz' })));
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
} else {
|
||||||
|
// Third call: registry-1.docker.io with Bearer header returns the digest
|
||||||
|
expect(options.headers['Authorization']).toBe('Bearer jwt-token-xyz');
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'docker-content-digest': 'sha256:autheddigest7777' },
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
}
|
||||||
|
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
||||||
|
});
|
||||||
|
const digest = await updateManager.getDockerHubDigest('nginx', 'latest');
|
||||||
|
expect(digest).toBe('sha256:autheddigest7777');
|
||||||
|
expect(calls).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getGhcrDigest returns digest on 200', async () => {
|
||||||
|
https.request.mockImplementation((options, cb) => {
|
||||||
|
expect(options.hostname).toBe('ghcr.io');
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'docker-content-digest': 'sha256:ghcrdigest1234' },
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
||||||
|
});
|
||||||
|
const digest = await updateManager.getGhcrDigest('ghcr.io/some/repo', 'latest');
|
||||||
|
expect(digest).toBe('sha256:ghcrdigest1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getLatestImageDigest returns null on transient errors after retries (registry unavailable)', async () => {
|
||||||
|
// Simulate a totally-down registry: every attempt fails with ETIMEDOUT.
|
||||||
|
// After REGISTRY_MAX_RETRIES the error propagates to getLatestImageDigest's
|
||||||
|
// catch arm, which logs and returns null (matches old behavior).
|
||||||
|
https.request.mockImplementation(() => {
|
||||||
|
const reqErr = new Error('connect ETIMEDOUT');
|
||||||
|
reqErr.code = 'ETIMEDOUT';
|
||||||
|
const req = {
|
||||||
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'error') setImmediate(() => handler(reqErr));
|
||||||
|
}),
|
||||||
|
end: jest.fn(),
|
||||||
|
destroy: jest.fn(),
|
||||||
|
};
|
||||||
|
return req;
|
||||||
|
});
|
||||||
|
const digest = await updateManager.getLatestImageDigest('nginx:latest');
|
||||||
|
expect(digest).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('parseAuthHeader', () => {
|
describe('parseAuthHeader', () => {
|
||||||
it('parses Docker Hub Bearer auth header', () => {
|
it('parses Docker Hub Bearer auth header', () => {
|
||||||
const header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"';
|
const header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"';
|
||||||
@@ -481,7 +714,9 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
|||||||
setImmediate(() => cb({
|
setImmediate(() => cb({
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
headers: { 'docker-content-digest': 'sha256:fromregistry' },
|
headers: { 'docker-content-digest': 'sha256:fromregistry' },
|
||||||
on: jest.fn()
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
})
|
||||||
}));
|
}));
|
||||||
return { on: jest.fn(), end: jest.fn() };
|
return { on: jest.fn(), end: jest.fn() };
|
||||||
});
|
});
|
||||||
@@ -495,7 +730,9 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
|||||||
setImmediate(() => cb({
|
setImmediate(() => cb({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
headers: {},
|
headers: {},
|
||||||
on: jest.fn()
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
})
|
||||||
}));
|
}));
|
||||||
return { on: jest.fn(), end: jest.fn() };
|
return { on: jest.fn(), end: jest.fn() };
|
||||||
});
|
});
|
||||||
@@ -504,6 +741,9 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects on https request error', async () => {
|
it('rejects on https request error', async () => {
|
||||||
|
// ECONNREFUSED is in REGISTRY_TRANSIENT_ERROR_CODES, so this would retry.
|
||||||
|
// Use a non-transient code (or no code) for the test to propagate.
|
||||||
|
jest.useRealTimers();
|
||||||
https.request.mockImplementation(() => {
|
https.request.mockImplementation(() => {
|
||||||
const req = { on: jest.fn(), end: jest.fn() };
|
const req = { on: jest.fn(), end: jest.fn() };
|
||||||
// Trigger error event asynchronously
|
// Trigger error event asynchronously
|
||||||
@@ -516,6 +756,7 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
|||||||
|
|
||||||
await expect(updateManager.getDockerHubDigest('nginx', 'latest'))
|
await expect(updateManager.getDockerHubDigest('nginx', 'latest'))
|
||||||
.rejects.toThrow('connection refused');
|
.rejects.toThrow('connection refused');
|
||||||
|
jest.useFakeTimers({ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick'] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes library/ prefix for official images', async () => {
|
it('normalizes library/ prefix for official images', async () => {
|
||||||
@@ -525,7 +766,9 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
|||||||
setImmediate(() => cb({
|
setImmediate(() => cb({
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
headers: { 'docker-content-digest': 'sha256:digest' },
|
headers: { 'docker-content-digest': 'sha256:digest' },
|
||||||
on: jest.fn()
|
on: jest.fn((event, handler) => {
|
||||||
|
if (event === 'end') setImmediate(handler);
|
||||||
|
})
|
||||||
}));
|
}));
|
||||||
return { on: jest.fn(), end: jest.fn() };
|
return { on: jest.fn(), end: jest.fn() };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,6 +118,67 @@ describe('Caddyfile + utils/http.js — Origin header construction (DC-051)', ()
|
|||||||
expect(offenders).toEqual([]);
|
expect(offenders).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('all :2019 call sites in TESTS use fetchT or a mocked fetchT (not raw fetch)', () => {
|
||||||
|
// DC-087 — the same rule, extended into __tests__. The api-code walk above
|
||||||
|
// skips __tests__, which let two mirrored health-handler test files keep a
|
||||||
|
// raw await-fetch caddy probe long after src/app.js moved to fetchT. On a
|
||||||
|
// host where the suite runs alongside a live Caddy admin (the prod box
|
||||||
|
// runs the full jest suite every 30 min via a cron adversarial check),
|
||||||
|
// that Origin-less raw fetch 403-spammed the Caddy journal (~700
|
||||||
|
// client-not-allowed error lines per day) while the tests still passed —
|
||||||
|
// checks.caddy.ok=false was silently accepted as sandbox noise. Mirrors
|
||||||
|
// MUST call fetchT (mocked at buildApp scope for hermeticity). A raw
|
||||||
|
// await-fetch at a Caddy-admin-URL call site in a test is an offender.
|
||||||
|
// NOTE: keep this comment free of backticks — stripComments pairs
|
||||||
|
// backtick spans across lines, and a stray pair shields real code from
|
||||||
|
// the comment stripper (this test self-flagged its first draft).
|
||||||
|
//
|
||||||
|
// Detection is deliberately FILE-LEVEL, not call-window: the historical
|
||||||
|
// drift kept the fetch call itself token-free (the URL came from a
|
||||||
|
// caddyUrl variable defined on a PREVIOUS line from CADDY_ADMIN_URL),
|
||||||
|
// so a call-window regex never fired. Any raw await-fetch in a file
|
||||||
|
// that also references the Caddy admin anywhere is an offender.
|
||||||
|
// Escape hatch for future tests that intentionally assert Origin-less
|
||||||
|
// 403 behavior against their own local listener: put the marker
|
||||||
|
// DC-087-ALLOW-RAW-FETCH in the file and it is skipped.
|
||||||
|
const testsRoot = path.join(__dirname);
|
||||||
|
const offenders = [];
|
||||||
|
const skipped = [];
|
||||||
|
function walk(dir) {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.name === 'node_modules') continue;
|
||||||
|
const p = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) walk(p);
|
||||||
|
else if (entry.name.endsWith('.js')) {
|
||||||
|
const rawText = fs.readFileSync(p, 'utf8');
|
||||||
|
// Escape hatch (checked on RAW text so a comment marker works —
|
||||||
|
// comments are stripped below): a file carrying the
|
||||||
|
// DC-087-ALLOW-RAW-FETCH marker declares it intentionally
|
||||||
|
// raw-fetches the Caddy admin (e.g. asserting Origin-less 403
|
||||||
|
// against its own local listener). The guard file itself is
|
||||||
|
// always scanned (never skipped) so the hatch can't be used to
|
||||||
|
// blind this very test.
|
||||||
|
if (p !== __filename && /DC-087-ALLOW-RAW-FETCH/.test(rawText)) {
|
||||||
|
skipped.push(p);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = stripComments(rawText);
|
||||||
|
const hasAdminToken = /:2019|adminUrl|admin_api_url|CADDY_ADMIN/.test(text);
|
||||||
|
const hasRawAwaitFetch = /await\s+fetch\(/.test(text);
|
||||||
|
if (hasAdminToken && hasRawAwaitFetch) {
|
||||||
|
offenders.push(`${p}: raw await-fetch in a file referencing the Caddy admin (mock fetchT instead; documented escape-hatch marker available for intentional 403 tests)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(testsRoot);
|
||||||
|
if (skipped.length) {
|
||||||
|
// Visibility for hatch use — shows up in jest output for reviewers.
|
||||||
|
console.info('[DC-087 guard] escape-hatch skipped:', skipped.join(', '));
|
||||||
|
}
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('readiness handler in src/app.js probes the exact URL the watcher needs', () => {
|
test('readiness handler in src/app.js probes the exact URL the watcher needs', () => {
|
||||||
const raw = fs.readFileSync(
|
const raw = fs.readFileSync(
|
||||||
path.join(__dirname, '../src/app.js'),
|
path.join(__dirname, '../src/app.js'),
|
||||||
@@ -127,9 +188,9 @@ describe('Caddyfile + utils/http.js — Origin header construction (DC-051)', ()
|
|||||||
expect(raw).toMatch(/\/config\/apps\/http\/servers\/srv0\/listen/);
|
expect(raw).toMatch(/\/config\/apps\/http\/servers\/srv0\/listen/);
|
||||||
// Goes through fetchT, NOT bare fetch — that's how the Origin injection
|
// Goes through fetchT, NOT bare fetch — that's how the Origin injection
|
||||||
// takes effect. Look at the 800 chars BEFORE the probe URL on the same
|
// takes effect. Look at the 800 chars BEFORE the probe URL on the same
|
||||||
// line / call site — the call must be `fetchT(...)`, not `await fetch(...)`.
|
// line / call site — the call must be fetchT(...), never a raw await of
|
||||||
// (We look backward because the URL sits inside the call's argument list,
|
// the global fetch. (We look backward because the URL sits inside the
|
||||||
// so the call site comes before the URL token.)
|
// call's argument list, so the call site comes before the URL token.)
|
||||||
const idx = raw.indexOf('srv0/listen');
|
const idx = raw.indexOf('srv0/listen');
|
||||||
const around = raw.substr(Math.max(0, idx - 400), 800);
|
const around = raw.substr(Math.max(0, idx - 400), 800);
|
||||||
expect(around).toMatch(/fetchT\(/);
|
expect(around).toMatch(/fetchT\(/);
|
||||||
|
|||||||
@@ -29,26 +29,10 @@ const platformPaths = require('../../platform-paths');
|
|||||||
const { createUserStore } = require('../../src/security/user-store');
|
const { createUserStore } = require('../../src/security/user-store');
|
||||||
const { createInviteStore } = require('../../src/security/invite-store');
|
const { createInviteStore } = require('../../src/security/invite-store');
|
||||||
const emailSender = require('../../src/auth/providers/email-sender');
|
const emailSender = require('../../src/auth/providers/email-sender');
|
||||||
|
const AuthProvider = require('../../src/auth/providers/base');
|
||||||
const { ValidationError, NotFoundError, ForbiddenError, PaymentRequiredError } = require('../../src/utilities/errors');
|
const { ValidationError, NotFoundError, ForbiddenError, PaymentRequiredError } = require('../../src/utilities/errors');
|
||||||
const { ok, successMessage } = require('../../src/utils/responses');
|
const { ok, successMessage } = require('../../src/utils/responses');
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the URL an invitee should click. Mirrors EmailMagicLinkProvider's
|
|
||||||
* _resolvePublicUrl logic — kept duplicated (not extracted) because the two
|
|
||||||
* callers have slightly different link paths and the duplication is smaller
|
|
||||||
* than the abstraction would be.
|
|
||||||
*/
|
|
||||||
function _buildInviteUrl(req, siteConfig, token) {
|
|
||||||
if (siteConfig && siteConfig.publicBaseUrl) {
|
|
||||||
return siteConfig.publicBaseUrl.replace(/\/+$/, '') +
|
|
||||||
'/api/v1/auth/invites/' + encodeURIComponent(token) + '/accept';
|
|
||||||
}
|
|
||||||
const proto = (req.headers && req.headers['x-forwarded-proto']) || (req.protocol || 'https');
|
|
||||||
const host = (req.headers && (req.headers['x-forwarded-host'] || req.headers.host))
|
|
||||||
|| (siteConfig && siteConfig.dashboardHost) || 'localhost:3001';
|
|
||||||
return `${proto}://${host}/api/v1/auth/invites/${encodeURIComponent(token)}/accept`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _requireAdmin(req, _res, next) {
|
function _requireAdmin(req, _res, next) {
|
||||||
if (!req.user || req.user.role !== 'admin') {
|
if (!req.user || req.user.role !== 'admin') {
|
||||||
return next(new ForbiddenError('Admin role required'));
|
return next(new ForbiddenError('Admin role required'));
|
||||||
@@ -240,11 +224,21 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
|
|||||||
});
|
});
|
||||||
if (!issued.ok) throw new ValidationError(issued.reason, 'email');
|
if (!issued.ok) throw new ValidationError(issued.reason, 'email');
|
||||||
|
|
||||||
let deliveredVia = 'none';
|
// Build the accept URL once — used both for the response and for email delivery.
|
||||||
const maskedEmail = email.replace(/(^.).+(@.*$)/, '$1***$2');
|
const baseUrl = (req.app.locals && req.app.locals.siteConfig && req.app.locals.siteConfig.publicBaseUrl
|
||||||
if (sendEmail !== false) {
|
? req.app.locals.siteConfig.publicBaseUrl.replace(/\/+$/, '')
|
||||||
// Best-effort send. If SMTP isn't configured, log to error.log (dev path).
|
: ((req.headers['x-forwarded-proto'] || req.protocol || 'https') + '://' +
|
||||||
const acceptUrl = _buildInviteUrl(req, /* siteConfig */ req.app.locals && req.app.locals.siteConfig, issued.token);
|
(req.headers['x-forwarded-host'] || req.headers.host || 'localhost:3001')));
|
||||||
|
const acceptUrl = baseUrl + '/api/v1/auth/invites/' + encodeURIComponent(issued.token) + '/accept';
|
||||||
|
|
||||||
|
// DC-085: link-first delivery. Default = no email, just hand the link back.
|
||||||
|
// Operators opt INTO email by sending { sendEmail: true } (or the admin UI
|
||||||
|
// checks the "Send email" checkbox). When SMTP is unconfigured AND the
|
||||||
|
// operator did opt in, we surface the failure as `deliveredVia: 'failed'`
|
||||||
|
// but NEVER leak the raw token into the server log — the link is already
|
||||||
|
// in the response, so the operator has a UI-side fallback.
|
||||||
|
let deliveredVia = 'manual';
|
||||||
|
if (sendEmail === true) {
|
||||||
const ttlHoursOut = Math.round(issued.ttlMs / (60 * 60 * 1000));
|
const ttlHoursOut = Math.round(issued.ttlMs / (60 * 60 * 1000));
|
||||||
const text = _buildEmailText({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
|
const text = _buildEmailText({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
|
||||||
const html = _buildEmailHtml({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
|
const html = _buildEmailHtml({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
|
||||||
@@ -254,33 +248,37 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
|
|||||||
await emailSender.sendEmail(smtpConfig, issued.email, 'You\'re invited to DashCaddy', text, html);
|
await emailSender.sendEmail(smtpConfig, issued.email, 'You\'re invited to DashCaddy', text, html);
|
||||||
deliveredVia = 'email';
|
deliveredVia = 'email';
|
||||||
} else {
|
} else {
|
||||||
// Dev fallback — log the raw link so operators can grab it.
|
// Operator asked for email but SMTP isn't configured. Surface the
|
||||||
log.warn && log.warn('auth-invite-dev',
|
// failure cleanly; the link is still in the response so the
|
||||||
'[DC-048-DEV-INVITE-LINK] email=' + issued.email +
|
// operator can share it manually. Do NOT log the raw URL — it
|
||||||
' role=' + issued.role + ' url=' + acceptUrl);
|
// would duplicate what's already in the response and pollute the
|
||||||
deliveredVia = 'dev-console';
|
// server log on every unconfigured-install invite.
|
||||||
|
log.warn && log.warn('auth-invite-send',
|
||||||
|
'invite send skipped: SMTP not configured (operator opted in)',
|
||||||
|
{ inviteId: issued.id, email: AuthProvider.maskEmail(issued.email) || '[unmaskable-email]' });
|
||||||
|
deliveredVia = 'failed';
|
||||||
}
|
}
|
||||||
} catch (sendErr) {
|
} catch (sendErr) {
|
||||||
log.warn && log.warn('auth-invite-send',
|
log.warn && log.warn('auth-invite-send',
|
||||||
'invite send failed: ' + (sendErr.message || String(sendErr)));
|
'invite send failed: ' + (sendErr.message || String(sendErr)),
|
||||||
|
{ inviteId: issued.id });
|
||||||
deliveredVia = 'failed';
|
deliveredVia = 'failed';
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
deliveredVia = 'manual';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maskedEmail = email.replace(/(^.).+(@.*$)/, '$1***$2');
|
||||||
|
const ttlHoursOut = Math.round(issued.ttlMs / (60 * 60 * 1000));
|
||||||
|
const shareText =
|
||||||
|
'Join my DashCaddy as ' + issued.role + ' — ' + acceptUrl +
|
||||||
|
' — expires in ' + ttlHoursOut + 'h.';
|
||||||
|
|
||||||
return ok(res, {
|
return ok(res, {
|
||||||
id: issued.id,
|
id: issued.id,
|
||||||
email: issued.email,
|
email: issued.email,
|
||||||
role: issued.role,
|
role: issued.role,
|
||||||
expiresAt: issued.expiresAt,
|
expiresAt: issued.expiresAt,
|
||||||
// The raw token is returned ONCE so the admin UI can show/copy the
|
acceptUrl,
|
||||||
// link. It is also embedded in the email when sendEmail !== false.
|
shareText,
|
||||||
acceptUrl: (req.app.locals && req.app.locals.siteConfig && req.app.locals.siteConfig.publicBaseUrl
|
|
||||||
? req.app.locals.siteConfig.publicBaseUrl.replace(/\/+$/, '')
|
|
||||||
: ((req.headers['x-forwarded-proto'] || req.protocol || 'https') + '://' +
|
|
||||||
(req.headers['x-forwarded-host'] || req.headers.host || 'localhost:3001'))) +
|
|
||||||
'/api/v1/auth/invites/' + encodeURIComponent(issued.token) + '/accept',
|
|
||||||
deliveredVia,
|
deliveredVia,
|
||||||
maskedEmail,
|
maskedEmail,
|
||||||
});
|
});
|
||||||
@@ -372,7 +370,7 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
|
|||||||
|
|
||||||
log.info && log.info('auth', 'invite accepted, user created', {
|
log.info && log.info('auth', 'invite accepted, user created', {
|
||||||
userId: userResult.user.id,
|
userId: userResult.user.id,
|
||||||
email: userResult.user.email,
|
email: AuthProvider.maskEmail(userResult.user.email) || '[unmaskable-email]',
|
||||||
role: userResult.user.role,
|
role: userResult.user.role,
|
||||||
inviteId: invite.id,
|
inviteId: invite.id,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ module.exports = function(deps) {
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Extract dependencies
|
// Extract dependencies
|
||||||
const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager } = deps;
|
const { authManager, totpConfig, session, asyncHandler, errorResponse, log, getAppSession, appSessionCache, credentialManager, fetchT, getServiceById, licenseManager, servicesStateManager, siteConfig } = deps;
|
||||||
|
|
||||||
// Create ctx-like object for compatibility
|
// Create ctx-like object for compatibility
|
||||||
const ctx = {
|
const ctx = {
|
||||||
@@ -126,7 +126,12 @@ module.exports = function(deps) {
|
|||||||
try {
|
try {
|
||||||
const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
||||||
const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
||||||
if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored');
|
if (!username || !password) {
|
||||||
|
return errorResponse(res, 428, '[DC-500] No credentials stored', {
|
||||||
|
credentialsRequired: true,
|
||||||
|
serviceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
const service = await ctx.getServiceById(serviceId);
|
const service = await ctx.getServiceById(serviceId);
|
||||||
const baseUrl = service?.url;
|
const baseUrl = service?.url;
|
||||||
if (!baseUrl) throw new NotFoundError('No service URL');
|
if (!baseUrl) throw new NotFoundError('No service URL');
|
||||||
@@ -181,7 +186,12 @@ module.exports = function(deps) {
|
|||||||
password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!username || !password) throw new NotFoundError('[DC-500] No credentials stored');
|
if (!username || !password) {
|
||||||
|
return errorResponse(res, 428, '[DC-500] No credentials stored', {
|
||||||
|
credentialsRequired: true,
|
||||||
|
serviceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const appCookies = await getAppSession(serviceId, baseUrl, username, password);
|
const appCookies = await getAppSession(serviceId, baseUrl, username, password);
|
||||||
if (appCookies) {
|
if (appCookies) {
|
||||||
@@ -203,8 +213,28 @@ module.exports = function(deps) {
|
|||||||
}
|
}
|
||||||
}, 'auth-app-token'));
|
}, 'auth-app-token'));
|
||||||
|
|
||||||
|
// A browser that already has a valid status.sami session must not be asked
|
||||||
|
// for TOTP again just because it opened another private-TLD service host.
|
||||||
|
// Mint a fresh one-time token that the target host can exchange for its own
|
||||||
|
// host-only cookie. This route is intentionally session-protected both by
|
||||||
|
// the global middleware and here (defence in depth).
|
||||||
|
router.get('/auth/sso-handoff', (req, res) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
if (!session.isValid(req)) {
|
||||||
|
return errorResponse(res, 401, 'Session expired or invalid');
|
||||||
|
}
|
||||||
|
const serviceId = String(req.query.serviceId || '');
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(serviceId)) {
|
||||||
|
return errorResponse(res, 400, 'Valid serviceId is required');
|
||||||
|
}
|
||||||
|
const suffix = String(siteConfig?.tld || '.sami');
|
||||||
|
const expectedHost = `${serviceId}${suffix.startsWith('.') ? suffix : `.${suffix}`}`;
|
||||||
|
ok(res, { ssoToken: session.createHandoffToken(expectedHost) });
|
||||||
|
});
|
||||||
|
|
||||||
// Cross-subdomain SSO handoff: exchanges a short-lived single-use token
|
// Cross-subdomain SSO handoff: exchanges a short-lived single-use token
|
||||||
// (minted by /totp/verify) for a HOST-ONLY session cookie on whichever
|
// (minted by /totp/verify or /auth/sso-handoff) for a HOST-ONLY session
|
||||||
|
// cookie on whichever
|
||||||
// *.sami origin calls this. Needed because Domain=.sami cookies are
|
// *.sami origin calls this. Needed because Domain=.sami cookies are
|
||||||
// silently rejected by real browsers (.sami is an unregistered TLD, so
|
// silently rejected by real browsers (.sami is an unregistered TLD, so
|
||||||
// browsers treat "sami" as the effective public suffix and refuse to set
|
// browsers treat "sami" as the effective public suffix and refuse to set
|
||||||
@@ -215,7 +245,9 @@ module.exports = function(deps) {
|
|||||||
router.get('/auth/sso-exchange', (req, res) => {
|
router.get('/auth/sso-exchange', (req, res) => {
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
const token = req.query.token;
|
const token = req.query.token;
|
||||||
if (!session.redeemHandoffToken(token)) {
|
const forwardedHost = String(req.headers['x-forwarded-host'] || req.headers.host || '')
|
||||||
|
.split(',')[0].trim().replace(/:\d+$/, '').toLowerCase();
|
||||||
|
if (!session.redeemHandoffToken(token, forwardedHost)) {
|
||||||
return errorResponse(res, 401, 'Invalid or expired handoff token');
|
return errorResponse(res, 401, 'Invalid or expired handoff token');
|
||||||
}
|
}
|
||||||
session.setCookieHostOnly(res, totpConfig.sessionDuration);
|
session.setCookieHostOnly(res, totpConfig.sessionDuration);
|
||||||
@@ -237,7 +269,12 @@ module.exports = function(deps) {
|
|||||||
// Serve service-specific auto-login page (auth enforced by Caddy forward_auth upstream)
|
// Serve service-specific auto-login page (auth enforced by Caddy forward_auth upstream)
|
||||||
router.get('/auth/login-page', (req, res) => {
|
router.get('/auth/login-page', (req, res) => {
|
||||||
const service = (req.query.service || '').replace(/[^a-z]/g, '');
|
const service = (req.query.service || '').replace(/[^a-z]/g, '');
|
||||||
const html = buildLoginPage(service);
|
const configuredHost = siteConfig?.dashboardHost;
|
||||||
|
const dashboardOrigin = typeof configuredHost === 'string'
|
||||||
|
&& /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(configuredHost)
|
||||||
|
? `https://${configuredHost}`
|
||||||
|
: 'https://status.sami';
|
||||||
|
const html = buildLoginPage(service, dashboardOrigin);
|
||||||
if (!html) return res.status(404).send('Unknown service');
|
if (!html) return res.status(404).send('Unknown service');
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
@@ -255,7 +292,7 @@ module.exports = function(deps) {
|
|||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildLoginPage(service) {
|
function buildLoginPage(service, dashboardOrigin = 'https://status.sami') {
|
||||||
// Pre-auth check via <meta http-equiv="refresh"> so it fires even when JS is
|
// Pre-auth check via <meta http-equiv="refresh"> so it fires even when JS is
|
||||||
// disabled or blocked. The cookie is sent automatically because we hit the
|
// disabled or blocked. The cookie is sent automatically because we hit the
|
||||||
// same origin (plex.sami); if the API returns 200 the user has a valid
|
// same origin (plex.sami); if the API returns 200 the user has a valid
|
||||||
@@ -266,7 +303,7 @@ function buildLoginPage(service) {
|
|||||||
<style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-items:center;height:100vh;margin:0;flex-direction:column;gap:12px}a{color:__ACCENT__}#d{font-size:12px;color:#888;max-width:80vw;overflow:auto;white-wrap:pre-wrap}</style>
|
<style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-items:center;height:100vh;margin:0;flex-direction:column;gap:12px}a{color:__ACCENT__}#d{font-size:12px;color:#888;max-width:80vw;overflow:auto;white-wrap:pre-wrap}</style>
|
||||||
</head><body><p id="m">__TITLE__</p><div id="d"></div>
|
</head><body><p id="m">__TITLE__</p><div id="d"></div>
|
||||||
<script>(function(){
|
<script>(function(){
|
||||||
var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m');
|
var ls=localStorage,d=document.getElementById('d'),m=document.getElementById('m'),dashboardOrigin=__DASHBOARD_ORIGIN__;
|
||||||
// 2026-07-22 hardening: every fetch now has a hard AbortSignal timeout
|
// 2026-07-22 hardening: every fetch now has a hard AbortSignal timeout
|
||||||
// (default 8s) so a hung upstream can NEVER leave the page stuck on
|
// (default 8s) so a hung upstream can NEVER leave the page stuck on
|
||||||
// "Signing in to Plex..." indefinitely. Also: if check-session returns
|
// "Signing in to Plex..." indefinitely. Also: if check-session returns
|
||||||
@@ -274,13 +311,16 @@ function buildLoginPage(service) {
|
|||||||
// upstream timeout, etc.), we now ALWAYS redirect to /web/?direct=1 if a
|
// upstream timeout, etc.), we now ALWAYS redirect to /web/?direct=1 if a
|
||||||
// stale token exists in localStorage, instead of failing silently.
|
// stale token exists in localStorage, instead of failing silently.
|
||||||
function go(u){setTimeout(function(){location.replace(u)},300)}
|
function go(u){setTimeout(function(){location.replace(u)},300)}
|
||||||
|
function authUrl(){return dashboardOrigin+'?auth=required&return='+encodeURIComponent(location.href)}
|
||||||
|
function authLink(label){return '<a href="'+authUrl()+'">'+label+'</a>'}
|
||||||
|
function vault(svc){go(dashboardOrigin+'?credentials='+encodeURIComponent(svc)+'&return='+encodeURIComponent(location.href))}
|
||||||
function fail(msg,info){try{m.innerHTML=msg;d.textContent=info||''}catch(_){}}
|
function fail(msg,info){try{m.innerHTML=msg;d.textContent=info||''}catch(_){}}
|
||||||
function withTimeout(ms){var c=new AbortController();setTimeout(function(){c.abort()},ms);return c.signal}
|
function withTimeout(ms){var c=new AbortController();setTimeout(function(){c.abort()},ms);return c.signal}
|
||||||
function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include',signal:withTimeout(8000)})}
|
function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include',signal:withTimeout(8000)})}
|
||||||
function merge(ck,j,name){try{var c=JSON.parse(ls.getItem(ck)||'{}');if(c.Servers&&c.Servers.length){var s=c.Servers[0];s.AccessToken=j.token;s.UserId=j.userId||s.UserId||'';s.DateLastAccessed=Date.now();ls.setItem(ck,JSON.stringify(c));return}}catch(e){}ls.setItem(ck,JSON.stringify({Servers:[{Id:j.serverId||'',Name:j.serverName||name,UserId:j.userId||'',AccessToken:j.token,ManualAddress:location.origin,LastConnectionMode:2,DateLastAccessed:Date.now()}]}))}
|
function merge(ck,j,name){try{var c=JSON.parse(ls.getItem(ck)||'{}');if(c.Servers&&c.Servers.length){var s=c.Servers[0];s.AccessToken=j.token;s.UserId=j.userId||s.UserId||'';s.DateLastAccessed=Date.now();ls.setItem(ck,JSON.stringify(c));return}}catch(e){}ls.setItem(ck,JSON.stringify({Servers:[{Id:j.serverId||'',Name:j.serverName||name,UserId:j.userId||'',AccessToken:j.token,ManualAddress:location.origin,LastConnectionMode:2,DateLastAccessed:Date.now()}]}))}
|
||||||
// Belt-and-suspenders hard timeout: if nothing in this script succeeds
|
// Belt-and-suspenders hard timeout: if nothing in this script succeeds
|
||||||
// within 15s, force-redirect to status.sami so the user can re-auth.
|
// within 15s, force-redirect to status.sami so the user can re-auth.
|
||||||
var overallTimer=setTimeout(function(){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href))},15000);
|
var overallTimer=setTimeout(function(){go(authUrl())},15000);
|
||||||
// Cross-subdomain SSO handoff: status.sami can't share its session cookie
|
// Cross-subdomain SSO handoff: status.sami can't share its session cookie
|
||||||
// with this origin (Domain=.sami cookies are silently rejected by real
|
// with this origin (Domain=.sami cookies are silently rejected by real
|
||||||
// browsers - .sami isn't a registered TLD, so browsers treat "sami" as the
|
// browsers - .sami isn't a registered TLD, so browsers treat "sami" as the
|
||||||
@@ -305,18 +345,17 @@ function buildLoginPage(service) {
|
|||||||
preExchange.then(function(){
|
preExchange.then(function(){
|
||||||
return fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store',signal:withTimeout(5000)})
|
return fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store',signal:withTimeout(5000)})
|
||||||
}).then(function(r){return r.json()}).then(function(st){
|
}).then(function(r){return r.json()}).then(function(st){
|
||||||
if(!st||!st.success||!st.authenticated){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href));return}
|
if(!st||!st.success||!st.authenticated){go(authUrl());return}
|
||||||
${body}
|
${body}
|
||||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Auth check error: '+(e&&e.message||'unknown'))})
|
}).catch(function(e){fail('Could not reach DashCaddy. '+authLink('Sign in at DashCaddy'),'Auth check error: '+(e&&e.message||'unknown'))})
|
||||||
})()</script></body></html>`;
|
})()</script></body></html>`;
|
||||||
|
|
||||||
const pages = {
|
const pages = {
|
||||||
chat: {
|
chat: {
|
||||||
title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa',
|
title: 'Signing in...', bg: '#0a0a0a', accent: '#60a5fa',
|
||||||
body: `if(ls.getItem('token')){go('/?direct=1');return}
|
body: `d.textContent='Fetching token from DashCaddy...';
|
||||||
d.textContent='Fetching token from DashCaddy...';
|
|
||||||
ft('chat').then(function(r){return r.text()}).then(function(t){
|
ft('chat').then(function(r){return r.text()}).then(function(t){
|
||||||
try{var j=JSON.parse(t);if(j.token){ls.setItem('token',j.token);go('/?direct=1');return}
|
try{var j=JSON.parse(t);if(j.credentialsRequired){vault('chat');return}if(j.token){ls.setItem('token',j.token);go('/?direct=1');return}
|
||||||
// No token but chat is reachable — fall through to manual UI link below
|
// No token but chat is reachable — fall through to manual UI link below
|
||||||
fail('Auto-login unavailable. <a href="/?direct=1">Open Chat manually</a>','No token field: '+t.substring(0,200))}
|
fail('Auto-login unavailable. <a href="/?direct=1">Open Chat manually</a>','No token field: '+t.substring(0,200))}
|
||||||
catch(e){fail('Auto-login parse error. <a href="/?direct=1">Open Chat manually</a>','Error: '+e.message+' / body: '+t.substring(0,200))}
|
catch(e){fail('Auto-login parse error. <a href="/?direct=1">Open Chat manually</a>','Error: '+e.message+' / body: '+t.substring(0,200))}
|
||||||
@@ -324,30 +363,29 @@ ft('chat').then(function(r){return r.text()}).then(function(t){
|
|||||||
},
|
},
|
||||||
plex: {
|
plex: {
|
||||||
title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d',
|
title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d',
|
||||||
body: `if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
|
body: `ft('plex').then(function(r){return r.json()}).then(function(j){
|
||||||
ft('plex').then(function(r){return r.json()}).then(function(j){
|
if(j.credentialsRequired){vault('plex');return}if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1');return}
|
||||||
if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1');return}
|
|
||||||
// No token returned. Three fallbacks in priority order:
|
// No token returned. Three fallbacks in priority order:
|
||||||
// 1. Stale token in localStorage — Plex may still accept it.
|
// 1. Stale token in localStorage — Plex may still accept it.
|
||||||
if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
|
if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
|
||||||
// 2. Manual link so the user is never trapped on this page.
|
// 2. Manual link so the user is never trapped on this page.
|
||||||
fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
|
fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
|
||||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/?direct=1">Open Plex manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/?direct=1">Open Plex manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
||||||
},
|
},
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc',
|
title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc',
|
||||||
body: `ft('jellyfin').then(function(r){return r.json()}).then(function(j){
|
body: `ft('jellyfin').then(function(r){return r.json()}).then(function(j){
|
||||||
if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/');return}
|
if(j.credentialsRequired){vault('jellyfin');return}if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/');return}
|
||||||
if(ls.getItem('jellyfin_credentials')||ls.getItem('_jellyfin_credentials')){go('/web/');return}
|
if(ls.getItem('jellyfin_credentials')||ls.getItem('_jellyfin_credentials')){go('/web/');return}
|
||||||
fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
|
fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
|
||||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
||||||
},
|
},
|
||||||
emby: {
|
emby: {
|
||||||
title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b',
|
title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b',
|
||||||
body: `ft('emby').then(function(r){return r.json()}).then(function(j){
|
body: `ft('emby').then(function(r){return r.json()}).then(function(j){
|
||||||
if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/');return}
|
if(j.credentialsRequired){vault('emby');return}if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/');return}
|
||||||
if(ls.getItem('emby_credentials')||ls.getItem('_emby_credentials')){go('/web/');return}
|
if(ls.getItem('emby_credentials')||ls.getItem('_emby_credentials')){go('/web/');return}
|
||||||
fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>','API: '+JSON.stringify(j))
|
fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or '+authLink('re-authenticate at DashCaddy'),'API: '+JSON.stringify(j))
|
||||||
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+(e&&e.message||'unknown'))})`
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -357,5 +395,6 @@ ft('plex').then(function(r){return r.json()}).then(function(j){
|
|||||||
return SHELL(cfg.body)
|
return SHELL(cfg.body)
|
||||||
.replace(/__TITLE__/g, cfg.title)
|
.replace(/__TITLE__/g, cfg.title)
|
||||||
.replace('__BG__', cfg.bg)
|
.replace('__BG__', cfg.bg)
|
||||||
.replace('__ACCENT__', cfg.accent);
|
.replace('__ACCENT__', cfg.accent)
|
||||||
|
.replace('__DASHBOARD_ORIGIN__', JSON.stringify(dashboardOrigin));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const { ok, successMessage } = require('../../src/utils/responses');
|
|||||||
* @param {Object} deps.log - Logger instance
|
* @param {Object} deps.log - Logger instance
|
||||||
* @returns {express.Router}
|
* @returns {express.Router}
|
||||||
*/
|
*/
|
||||||
module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log, renewCSRFToken }) {
|
module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log, renewCSRFToken, siteConfig }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Ctx shim for backward compatibility
|
// Ctx shim for backward compatibility
|
||||||
@@ -23,7 +23,8 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
|||||||
credentialManager,
|
credentialManager,
|
||||||
totpConfig,
|
totpConfig,
|
||||||
saveTotpConfig,
|
saveTotpConfig,
|
||||||
session
|
session,
|
||||||
|
siteConfig
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get current TOTP config (public route)
|
// Get current TOTP config (public route)
|
||||||
@@ -193,11 +194,14 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
|
|||||||
// Login: verify TOTP code and set session cookie
|
// Login: verify TOTP code and set session cookie
|
||||||
router.post('/totp/verify', asyncHandler(async (req, res) => {
|
router.post('/totp/verify', asyncHandler(async (req, res) => {
|
||||||
const { authenticator } = require('otplib');
|
const { authenticator } = require('otplib');
|
||||||
const { code } = req.body;
|
const { code, serviceId } = req.body;
|
||||||
|
|
||||||
if (!code || !/^\d{6}$/.test(code)) {
|
if (!code || !/^\d{6}$/.test(code)) {
|
||||||
throw new ValidationError('Invalid code format', 'code');
|
throw new ValidationError('Invalid code format', 'code');
|
||||||
}
|
}
|
||||||
|
if (serviceId != null && !/^[a-z0-9][a-z0-9-]*$/.test(String(serviceId))) {
|
||||||
|
throw new ValidationError('Invalid service ID', 'serviceId');
|
||||||
|
}
|
||||||
|
|
||||||
if (!ctx.totpConfig.enabled || !ctx.totpConfig.isSetUp) {
|
if (!ctx.totpConfig.enabled || !ctx.totpConfig.isSetUp) {
|
||||||
throw new ValidationError('TOTP is not enabled');
|
throw new ValidationError('TOTP is not enabled');
|
||||||
@@ -227,7 +231,12 @@ const SETUP_WINDOW_MS = 60 * 60 * 1000;
|
|||||||
// URL when bouncing the user back to a gated service. That service's
|
// URL when bouncing the user back to a gated service. That service's
|
||||||
// login page exchanges it via /auth/sso-exchange for its own host-only
|
// login page exchanges it via /auth/sso-exchange for its own host-only
|
||||||
// session cookie. Single-use, 60s TTL — see ctx.session.createHandoffToken.
|
// session cookie. Single-use, 60s TTL — see ctx.session.createHandoffToken.
|
||||||
const ssoToken = ctx.session.createHandoffToken();
|
let ssoToken = null;
|
||||||
|
if (serviceId) {
|
||||||
|
const suffix = String(ctx.siteConfig?.tld || '.sami');
|
||||||
|
const expectedHost = `${serviceId}${suffix.startsWith('.') ? suffix : `.${suffix}`}`;
|
||||||
|
ssoToken = ctx.session.createHandoffToken(expectedHost);
|
||||||
|
}
|
||||||
|
|
||||||
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
|
log.debug('auth', 'Session created', { sessions: ctx.session.ipSessions.size });
|
||||||
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken, ssoToken });
|
ok(res, { message: 'Authenticated successfully', sessionDuration: ctx.totpConfig.sessionDuration, csrfToken: newCsrfToken, ssoToken });
|
||||||
|
|||||||
@@ -123,17 +123,106 @@ module.exports = function(ctx) {
|
|||||||
res.send(script);
|
res.send(script);
|
||||||
}, 'ca-install-script'));
|
}, 'ca-install-script'));
|
||||||
|
|
||||||
|
// DC-076: per-service cert/key download — TOTP + admin scope required.
|
||||||
|
// Pre-fix this endpoint (a) had a hardcoded `password = 'dashcaddy'` default
|
||||||
|
// for the PFX format — a default credential published in source; (b) was
|
||||||
|
// public-listed in middleware.js PUBLIC_ROUTES (TOTP bypassed when TOTP is
|
||||||
|
// disabled — single ops command or fresh-install setup state), and (c)
|
||||||
|
// accepted ANY TOTP-authenticated scope (read scope was enough to pull
|
||||||
|
// private keys). Fix: require explicit password (no default), require
|
||||||
|
// TOTP/session (dropped from PUBLIC_ROUTES — see middleware.js), and
|
||||||
|
// require `admin` scope at the route layer as defense-in-depth against
|
||||||
|
// future middleware-ordering mistakes.
|
||||||
|
const CA_CERT_DOMAINS_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
||||||
|
// Per-DC-076: PFX password now required, ≥ 8 chars, no `=` (pkcs12
|
||||||
|
// interprets `=` as a base64 padding marker that downstream tooling
|
||||||
|
// can mis-handle; reject it to keep the password copy-paste-safe).
|
||||||
|
const CA_PFX_PASSWORD_RE = /^[A-Za-z0-9!@#%^_+,.~:-]{8,64}$/;
|
||||||
|
const CA_CERT_RATE_LIMIT = { windowMs: 60_000, max: 10 };
|
||||||
|
const caCertRateBuckets = new Map(); // ip -> { count, resetAt }
|
||||||
|
function caCertRateLimit(ip) {
|
||||||
|
const now = Date.now();
|
||||||
|
const b = caCertRateBuckets.get(ip);
|
||||||
|
if (!b || b.resetAt <= now) {
|
||||||
|
caCertRateBuckets.set(ip, { count: 1, resetAt: now + CA_CERT_RATE_LIMIT.windowMs });
|
||||||
|
return { allowed: true, remaining: CA_CERT_RATE_LIMIT.max - 1 };
|
||||||
|
}
|
||||||
|
if (b.count >= CA_CERT_RATE_LIMIT.max) {
|
||||||
|
return { allowed: false, remaining: 0, retryAfterMs: b.resetAt - now };
|
||||||
|
}
|
||||||
|
b.count += 1;
|
||||||
|
return { allowed: true, remaining: CA_CERT_RATE_LIMIT.max - b.count };
|
||||||
|
}
|
||||||
|
function requireCaCertAdminScope(req, res) {
|
||||||
|
// TOTP is enforced by `totpAuthMiddleware` globally. Here we additionally
|
||||||
|
// require the `admin` scope — even a read-scope API key or read-scope
|
||||||
|
// JWT must NOT be able to pull a private key. Auth context is mounted on
|
||||||
|
// `req.auth` by the upstream middlewares.
|
||||||
|
const auth = req.auth || {};
|
||||||
|
const scope = Array.isArray(auth.scope) ? auth.scope : [];
|
||||||
|
if (!scope.includes('admin')) {
|
||||||
|
ctx.errorResponse(res, 403,
|
||||||
|
'Admin scope required to download per-service private keys. Re-authenticate with an admin-scoped credential.',
|
||||||
|
{ code: 'DC-076_INSUFFICIENT_SCOPE', requiredScope: 'admin', actualScope: scope });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Generate and download SSL certificate for a service
|
// Generate and download SSL certificate for a service
|
||||||
router.get('/cert/:domain', ctx.asyncHandler(async (req, res) => {
|
router.get('/cert/:domain', ctx.asyncHandler(async (req, res) => {
|
||||||
const { domain } = req.params;
|
if (!requireCaCertAdminScope(req, res)) return;
|
||||||
const { password = 'dashcaddy', format = 'pfx' } = req.query;
|
|
||||||
|
|
||||||
if (!/^[a-zA-Z0-9!@#%^_+=,.:-]{1,64}$/.test(password)) {
|
const { domain } = req.params;
|
||||||
throw new ValidationError('Invalid password. Use only letters, numbers, and basic symbols (max 64 chars).');
|
|
||||||
|
// DC-076: password is REQUIRED for the pfx format (no `=`) and must
|
||||||
|
// be ≥ 8 chars. Previously `password = 'dashcaddy'` — a hardcoded
|
||||||
|
// default that silently signed every PFX with the same published
|
||||||
|
// password. Other formats (key, pem, crt, fullchain) do not need a
|
||||||
|
// password and ignore the param.
|
||||||
|
const wantsPfx = !req.query.format || req.query.format === 'pfx';
|
||||||
|
let password = req.query.password;
|
||||||
|
if (wantsPfx) {
|
||||||
|
if (typeof password !== 'string' || password === '') {
|
||||||
|
return ctx.errorResponse(res, 400,
|
||||||
|
'PFX format requires an explicit `password` query param (8-64 chars, no `=`). '
|
||||||
|
+ 'A published default is unsafe — pick your own.',
|
||||||
|
{ code: 'DC-076_PASSWORD_REQUIRED' });
|
||||||
|
}
|
||||||
|
if (!CA_PFX_PASSWORD_RE.test(password)) {
|
||||||
|
return ctx.errorResponse(res, 400,
|
||||||
|
'PFX password must be 8-64 chars from [A-Za-z0-9!@#%^_+,.~:-].',
|
||||||
|
{ code: 'DC-076_PASSWORD_INVALID' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For non-PFX formats, still reject `=` in the password so a copy-paste
|
||||||
|
// mistake can't accidentally inject a base64 padding token into a path
|
||||||
|
// someone else might log.
|
||||||
|
if (password !== undefined && (typeof password !== 'string' || password.includes('='))) {
|
||||||
|
return ctx.errorResponse(res, 400, 'password (if supplied) must be a string without `=`.',
|
||||||
|
{ code: 'DC-076_PASSWORD_INVALID' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!domain || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i.test(domain)) {
|
// DC-076: per-IP rate limit — each cert request forks an `openssl` process
|
||||||
return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`);
|
// and writes to disk. An authenticated admin polling the endpoint in a
|
||||||
|
// loop could exhaust CPU/IO. 10 req/min/IP is enough for normal use
|
||||||
|
// (regenerate one cert, check 4 formats, done) and tight enough to stop
|
||||||
|
// a runaway client.
|
||||||
|
const clientIp = req.ip || req.connection?.remoteAddress || 'unknown';
|
||||||
|
const rl = caCertRateLimit(clientIp);
|
||||||
|
if (!rl.allowed) {
|
||||||
|
res.setHeader('Retry-After', Math.ceil(rl.retryAfterMs / 1000));
|
||||||
|
return ctx.errorResponse(res, 429,
|
||||||
|
`Rate limit exceeded for /api/v1/ca/cert/* (${CA_CERT_RATE_LIMIT.max} req/${CA_CERT_RATE_LIMIT.windowMs/1000}s per IP). Retry in ${Math.ceil(rl.retryAfterMs / 1000)}s.`,
|
||||||
|
{ code: 'DC-076_RATE_LIMITED', retryAfterMs: rl.retryAfterMs });
|
||||||
|
}
|
||||||
|
res.setHeader('X-RateLimit-Limit', String(CA_CERT_RATE_LIMIT.max));
|
||||||
|
res.setHeader('X-RateLimit-Remaining', String(rl.remaining));
|
||||||
|
|
||||||
|
if (!CA_CERT_DOMAINS_RE.test(domain)) {
|
||||||
|
return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`,
|
||||||
|
{ code: 'DC-076_DOMAIN_INVALID' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const pkiPath = platformPaths.pkiDir;
|
const pkiPath = platformPaths.pkiDir;
|
||||||
@@ -240,8 +329,9 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
|
|||||||
}
|
}
|
||||||
}, 'ca-cert'));
|
}, 'ca-cert'));
|
||||||
|
|
||||||
// List generated certificates
|
// List generated certificates (DC-076: TOTP-gated; previously public-listed)
|
||||||
router.get('/certs', ctx.asyncHandler(async (req, res) => {
|
router.get('/certs', ctx.asyncHandler(async (req, res) => {
|
||||||
|
if (!requireCaCertAdminScope(req, res)) return;
|
||||||
const certsDir = platformPaths.generatedCertsDir;
|
const certsDir = platformPaths.generatedCertsDir;
|
||||||
|
|
||||||
if (!await exists(certsDir)) {
|
if (!await exists(certsDir)) {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
* Exposes:
|
* Exposes:
|
||||||
* GET /api/v1/caddy/upstreams — full snapshot
|
* GET /api/v1/caddy/upstreams — full snapshot
|
||||||
* GET /api/v1/caddy/upstreams/incidents — open dead-upstream incidents (via healthChecker)
|
* GET /api/v1/caddy/upstreams/incidents — open dead-upstream incidents (via healthChecker)
|
||||||
* POST /api/v1/caddy/upstreams/:host/mute — body { muted: true|false } (also via query ?muted=true)
|
* POST /api/v1/caddy/upstreams/mute — body { host, muted: true|false }
|
||||||
|
* POST /api/v1/caddy/upstreams/:host/mute — body { muted: true|false } OR query ?muted=true
|
||||||
|
* POST /api/v1/caddy/upstreams/:host/unmute — clears the mute
|
||||||
*
|
*
|
||||||
* Auth: same as the rest of /api/v1 — handled by the global middleware
|
* Auth: same as the rest of /api/v1 — handled by the global middleware
|
||||||
* (the router is mounted under the auth-gated apiRouter in app.js).
|
* (the router is mounted under the auth-gated apiRouter in app.js).
|
||||||
@@ -16,6 +18,48 @@ const express = require('express');
|
|||||||
const { success, errorResponse } = require('../src/utils/responses');
|
const { success, errorResponse } = require('../src/utils/responses');
|
||||||
const { ValidationError } = require('../src/utilities/errors');
|
const { ValidationError } = require('../src/utilities/errors');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DC-073: shared mute helper — used by all three mute endpoints so the
|
||||||
|
* host-validation logic can't drift.
|
||||||
|
*
|
||||||
|
* Pre-fix, only the bare `/caddy/upstreams/mute` body-style endpoint
|
||||||
|
* rejected unknown hosts (with a "not a known upstream" 400). The
|
||||||
|
* path-style `/:host/mute` and `/:host/unmute` endpoints skipped that
|
||||||
|
* check entirely, so an authenticated operator could POST
|
||||||
|
* `/caddy/upstreams/phantom.test:12345/mute` and the watcher would
|
||||||
|
* silently add `phantom.test:12345` to its muted Set and `_saveState()`
|
||||||
|
* would persist it to disk. The phantom entry then survives container
|
||||||
|
* restarts, pollutes the snapshot view (the muted Set is iterated in
|
||||||
|
* places like the dashboard's "muted upstreams" badge), and would
|
||||||
|
* silently disable any future probe that happened to resolve to the
|
||||||
|
* same string.
|
||||||
|
*
|
||||||
|
* Post-fix, every mute path runs through this helper so:
|
||||||
|
* (1) host format is well-formed (rejects injection / `:` / `?` / etc.)
|
||||||
|
* (2) host is in `caddyUpstreamWatcher.upstreams` (the live registry
|
||||||
|
* populated by `scanSites()` reading every `reverse_proxy` from
|
||||||
|
* /etc/caddy/sites/*. A phantom host cannot reach setMuted.)
|
||||||
|
* (3) the muted Set never holds entries the scanner doesn't know.
|
||||||
|
*
|
||||||
|
* @param {Object} watcher caddyUpstreamWatcher instance
|
||||||
|
* @param {string} host raw host string from the request
|
||||||
|
* @param {boolean} wantMuted true to mute, false to unmute
|
||||||
|
* @returns {{host: string, muted: boolean}} the result of setMuted
|
||||||
|
* @throws {ValidationError} on invalid format or unknown host
|
||||||
|
*/
|
||||||
|
function validateAndMuteHost(watcher, host, wantMuted) {
|
||||||
|
if (typeof host !== 'string' || host.length === 0 || host.length > 253) {
|
||||||
|
throw new ValidationError('host must be a non-empty string up to 253 chars');
|
||||||
|
}
|
||||||
|
if (!/^[a-z0-9._:-]+$/i.test(host)) {
|
||||||
|
throw new ValidationError('host must be a valid host[:port] string');
|
||||||
|
}
|
||||||
|
if (!watcher || !watcher.upstreams || !watcher.upstreams.has(host)) {
|
||||||
|
throw new ValidationError(`host ${host} is not a known upstream (run scan first)`);
|
||||||
|
}
|
||||||
|
return watcher.setMuted(host, wantMuted);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = function({ asyncHandler, caddyUpstreamWatcher, healthChecker }) {
|
module.exports = function({ asyncHandler, caddyUpstreamWatcher, healthChecker }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -55,62 +99,48 @@ module.exports = function({ asyncHandler, caddyUpstreamWatcher, healthChecker })
|
|||||||
success(res, { incidents: open });
|
success(res, { incidents: open });
|
||||||
}, 'caddy-upstreams-incidents'));
|
}, 'caddy-upstreams-incidents'));
|
||||||
|
|
||||||
// POST /caddy/upstreams/mute body { host, muted }
|
|
||||||
// POST /caddy/upstreams/:host/mute body { muted: true } OR query ?muted=true
|
|
||||||
// Both shapes supported because the dashboard code is small and either is
|
|
||||||
// ergonomic depending on caller.
|
|
||||||
const handleMute = asyncHandler(async (req, res) => {
|
|
||||||
if (!caddyUpstreamWatcher) {
|
|
||||||
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
|
|
||||||
}
|
|
||||||
const host = req.params.host || req.body?.host;
|
|
||||||
if (!host || typeof host !== 'string' || !/^[a-z0-9._:-]+$/i.test(host)) {
|
|
||||||
throw new ValidationError('host must be a valid host[:port] string');
|
|
||||||
}
|
|
||||||
// Accept muted as boolean body field OR ?muted=true|false query OR
|
|
||||||
// a { muted: true|false } JSON body. Default to toggling on bare POST
|
|
||||||
// without a muted value (this is the "mute it" path).
|
|
||||||
let muted;
|
|
||||||
if (typeof req.body?.muted === 'boolean') muted = req.body.muted;
|
|
||||||
else if (typeof req.query.muted === 'string') muted = req.query.muted === 'true';
|
|
||||||
else muted = true; // POST with no body = mute
|
|
||||||
|
|
||||||
const result = caddyUpstreamWatcher.setMuted(host, muted);
|
|
||||||
success(res, result);
|
|
||||||
}, 'caddy-upstreams-mute');
|
|
||||||
|
|
||||||
// Bare /mute with JSON body {host, muted}. Default mutes when muted is
|
// Bare /mute with JSON body {host, muted}. Default mutes when muted is
|
||||||
// absent or unparseable; require muted === false explicitly to unmute.
|
// absent or unparseable; require muted === false explicitly to unmute.
|
||||||
|
// DC-073: now routes through validateAndMuteHost so the unknown-host
|
||||||
|
// check applies (was already correct here pre-fix, but path-style
|
||||||
|
// was missing it — see validateAndMuteHost docblock).
|
||||||
router.post('/caddy/upstreams/mute', asyncHandler(async (req, res) => {
|
router.post('/caddy/upstreams/mute', asyncHandler(async (req, res) => {
|
||||||
if (!caddyUpstreamWatcher) {
|
if (!caddyUpstreamWatcher) {
|
||||||
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
|
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
|
||||||
}
|
}
|
||||||
const { host, muted } = req.body || {};
|
const { host, muted } = req.body || {};
|
||||||
if (!host || typeof host !== 'string' || !/^[a-z0-9._:-]+$/i.test(host)) {
|
|
||||||
throw new ValidationError('host must be a valid host[:port] string');
|
|
||||||
}
|
|
||||||
// Explicit boolean coercion — string 'false' should NOT mute.
|
// Explicit boolean coercion — string 'false' should NOT mute.
|
||||||
const wantMuted = muted === undefined ? true : muted === true;
|
const wantMuted = muted === undefined ? true : muted === true;
|
||||||
if (caddyUpstreamWatcher.upstreams && !caddyUpstreamWatcher.upstreams.has(host)) {
|
const result = validateAndMuteHost(caddyUpstreamWatcher, host, wantMuted);
|
||||||
throw new ValidationError(`host ${host} is not a known upstream (run scan first)`);
|
|
||||||
}
|
|
||||||
const result = caddyUpstreamWatcher.setMuted(host, wantMuted);
|
|
||||||
success(res, result);
|
success(res, result);
|
||||||
}, 'caddy-upstreams-mute-bare'));
|
}, 'caddy-upstreams-mute-bare'));
|
||||||
|
|
||||||
// /:host/mute and /:host/unmute for path-style toggles
|
// Path-style /:host/mute — body { muted: true|false } OR query ?muted=true|false.
|
||||||
router.post('/caddy/upstreams/:host/mute', handleMute);
|
// DC-073: now also rejects unknown hosts (was the bug — see docblock).
|
||||||
|
router.post('/caddy/upstreams/:host/mute', asyncHandler(async (req, res) => {
|
||||||
|
if (!caddyUpstreamWatcher) {
|
||||||
|
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
|
||||||
|
}
|
||||||
|
let wantMuted;
|
||||||
|
if (typeof req.body?.muted === 'boolean') wantMuted = req.body.muted;
|
||||||
|
else if (typeof req.query.muted === 'string') wantMuted = req.query.muted === 'true';
|
||||||
|
else wantMuted = true; // bare POST = mute
|
||||||
|
const result = validateAndMuteHost(caddyUpstreamWatcher, req.params.host, wantMuted);
|
||||||
|
success(res, result);
|
||||||
|
}, 'caddy-upstreams-mute'));
|
||||||
|
|
||||||
|
// DC-073: path-style /:host/unmute now also rejects unknown hosts.
|
||||||
router.post('/caddy/upstreams/:host/unmute', asyncHandler(async (req, res) => {
|
router.post('/caddy/upstreams/:host/unmute', asyncHandler(async (req, res) => {
|
||||||
if (!caddyUpstreamWatcher) {
|
if (!caddyUpstreamWatcher) {
|
||||||
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
|
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
|
||||||
}
|
}
|
||||||
const host = req.params.host;
|
const result = validateAndMuteHost(caddyUpstreamWatcher, req.params.host, false);
|
||||||
if (!host || !/^[a-z0-9._:-]+$/i.test(host)) {
|
|
||||||
throw new ValidationError('host must be a valid host[:port] string');
|
|
||||||
}
|
|
||||||
const result = caddyUpstreamWatcher.setMuted(host, false);
|
|
||||||
success(res, result);
|
success(res, result);
|
||||||
}, 'caddy-upstreams-unmute'));
|
}, 'caddy-upstreams-unmute'));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Export the helper for unit tests so the validation surface can be
|
||||||
|
// exercised without spinning up a full Express app.
|
||||||
|
module.exports.__test = { validateAndMuteHost };
|
||||||
@@ -11,10 +11,138 @@
|
|||||||
*/
|
*/
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { ok, errorResponse } = require('../src/utils/responses');
|
const { ok, errorResponse } = require('../src/utils/responses');
|
||||||
|
const { REGEX } = require('../src/utilities/constants');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DC-070: Validate the structural config that flows into generateSiteBlock.
|
||||||
|
*
|
||||||
|
* Threat model: `generateSiteBlock` interpolates user-controlled fields
|
||||||
|
* (domain, tls, authService, headers.*, stripPrefix, upstream) DIRECTLY into
|
||||||
|
* a Caddyfile text block that is later fed to `caddy.modify()` and the
|
||||||
|
* Caddy admin /load endpoint. The /caddycode/generate endpoint is
|
||||||
|
* authenticated (forward_auth gated), but the bug class is "compromised
|
||||||
|
* middleware / pivot" — a JSON-only payload can be smuggled past any
|
||||||
|
* UI-side input checks.
|
||||||
|
*
|
||||||
|
* Pre-fix, every field was trusted: `lines.push(`${domain} {`)` accepted any
|
||||||
|
* string (including newlines that close the block and inject a new site),
|
||||||
|
* `headers[key] = "${value}"` accepted arbitrary quotes (which would break
|
||||||
|
* the surrounding `"..."` Caddy quoted-string context and inject directives),
|
||||||
|
* and `tls`, `authService`, `stripPrefix`, `upstream` had no charset
|
||||||
|
* restrictions at all (spaces, braces, semicolons would land verbatim).
|
||||||
|
*
|
||||||
|
* Post-fix: every field is constrained to a known-safe character class
|
||||||
|
* BEFORE interpolation, and CRLF is rejected outright. Quoted-string
|
||||||
|
* injection in header values is closed by escaping `\` and `"` per the
|
||||||
|
* Caddy quoted-string spec (backslash escapes the next character).
|
||||||
|
*/
|
||||||
|
function validateGenerationConfig(config) {
|
||||||
|
const errors = [];
|
||||||
|
const {
|
||||||
|
domain,
|
||||||
|
upstream,
|
||||||
|
upstreamProtocol = 'http',
|
||||||
|
tls = 'auto',
|
||||||
|
auth = false,
|
||||||
|
authService = null,
|
||||||
|
headers = {},
|
||||||
|
stripPrefix = null,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
// 1. domain — RFC 1123 hostname. Reject anything with whitespace, brace,
|
||||||
|
// semicolon, newline, or non-printable. REGEX.DOMAIN is
|
||||||
|
// /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/i in constants.js.
|
||||||
|
if (typeof domain !== 'string' || !REGEX.DOMAIN.test(domain)) {
|
||||||
|
errors.push('domain must be a valid hostname (letters, digits, dots, hyphens)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. upstream — `host:port` form (the only shape Caddy's reverse_proxy
|
||||||
|
// directive takes for non-URL upstreams). Reject `://`, whitespace,
|
||||||
|
// braces. Allow optional IPv6 bracket form `[::1]:5000`. Must
|
||||||
|
// include an explicit :port segment — a bare `localhost` would
|
||||||
|
// produce a Caddyfile that fails to reload (port required for
|
||||||
|
// reverse_proxy upstreams). Two regex branches: (a) bare host with
|
||||||
|
// required :port, (b) bracketed IPv6 literal with required :port.
|
||||||
|
if (typeof upstream !== 'string'
|
||||||
|
|| !/^[a-z0-9.\-]+:\d{1,5}$/i.test(upstream)
|
||||||
|
&& !/^\[[a-z0-9.\-:.]+\]:\d{1,5}$/i.test(upstream)
|
||||||
|
) {
|
||||||
|
errors.push('upstream must be host:port (host letters/digits/dots/hyphens, port 1-65535, optional IPv6 brackets)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. tls — either the literal strings 'auto' / 'internal' (handled
|
||||||
|
// specially below) OR a CA name like 'letsencrypt' / 'internal' that
|
||||||
|
// must match /^[a-z0-9._-]+$/i. Reject whitespace + braces + quotes.
|
||||||
|
if (typeof tls !== 'string' || !/^[a-z0-9._-]+$/i.test(tls)) {
|
||||||
|
errors.push('tls must be one of: auto, internal, or a CA name (letters, digits, dots, underscores, hyphens)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. authService — only meaningful when auth=true; otherwise ignore. Must
|
||||||
|
// match the existing SSO service-id charset (REGEX.SUBDOMAIN).
|
||||||
|
if (auth) {
|
||||||
|
if (typeof authService !== 'string' || !REGEX.SUBDOMAIN.test(authService)) {
|
||||||
|
errors.push('authService must be a valid subdomain (lowercase, alphanumeric, hyphens)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. upstreamProtocol — only 'http' or 'https'. Anything else gets coerced
|
||||||
|
// to 'http' but only after we explicitly accept it; reject obvious
|
||||||
|
// injection vectors here.
|
||||||
|
if (upstreamProtocol !== 'http' && upstreamProtocol !== 'https') {
|
||||||
|
errors.push('upstreamProtocol must be "http" or "https"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. headers — each key must be a valid HTTP header name ([A-Za-z0-9-]+),
|
||||||
|
// each value must be a string with no CR/LF and no unescaped quotes.
|
||||||
|
if (headers && typeof headers === 'object') {
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (typeof key !== 'string' || !/^[A-Za-z0-9-]+$/.test(key)) {
|
||||||
|
errors.push(`header key "${String(key)}" must be HTTP-token chars only ([A-Za-z0-9-])`);
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
errors.push(`header "${key}" value must be a string`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/[\r\n]/.test(value)) {
|
||||||
|
errors.push(`header "${key}" value must not contain CR or LF`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. stripPrefix — must be a leading-slash path with safe chars. Reject
|
||||||
|
// braces, quotes, whitespace, and { } which would let the attacker
|
||||||
|
// open a new Caddyfile block.
|
||||||
|
if (stripPrefix != null) {
|
||||||
|
if (typeof stripPrefix !== 'string' || !/^\/[A-Za-z0-9._\-/]*$/.test(stripPrefix)) {
|
||||||
|
errors.push('stripPrefix must be an absolute path (letters, digits, dots, hyphens, slashes)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape a string for safe interpolation inside a Caddyfile quoted-string
|
||||||
|
* context. Caddy uses the same backslash-escape semantics as JSON-ish
|
||||||
|
* contexts — `\` and `"` MUST be escaped, otherwise the attacker breaks out
|
||||||
|
* of the quoted string and injects arbitrary directives.
|
||||||
|
*
|
||||||
|
* @param {string} s raw header value
|
||||||
|
* @returns {string} escaped value (no embedded newlines; CR/LF were already
|
||||||
|
* rejected by the validator)
|
||||||
|
*/
|
||||||
|
function escapeCaddyQuotedString(s) {
|
||||||
|
return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a Caddyfile site block from a structured config.
|
* Generate a Caddyfile site block from a structured config.
|
||||||
* @param {Object} config - Site configuration
|
*
|
||||||
|
* Every interpolated field is now validated by `validateGenerationConfig`
|
||||||
|
* first (see DC-070). Quoted-string values are escaped via
|
||||||
|
* `escapeCaddyQuotedString` so a `"` in a header value cannot break out.
|
||||||
|
*
|
||||||
|
* @param {Object} config - Site configuration (already validated)
|
||||||
* @returns {string} Caddyfile snippet
|
* @returns {string} Caddyfile snippet
|
||||||
*/
|
*/
|
||||||
function generateSiteBlock(config) {
|
function generateSiteBlock(config) {
|
||||||
@@ -38,12 +166,15 @@ function generateSiteBlock(config) {
|
|||||||
const lines = [];
|
const lines = [];
|
||||||
lines.push(`${domain} {`);
|
lines.push(`${domain} {`);
|
||||||
|
|
||||||
// TLS
|
// TLS — only emit a tls directive when explicitly 'internal' or a CA
|
||||||
|
// name; 'auto' means Caddy's default behaviour (no directive needed).
|
||||||
if (tls === 'internal') {
|
if (tls === 'internal') {
|
||||||
lines.push(` tls internal`);
|
lines.push(` tls internal`);
|
||||||
} else if (tls === 'auto') {
|
} else if (tls === 'auto') {
|
||||||
// Default — Caddy auto-provisions Let's Encrypt
|
// Default — Caddy auto-provisions Let's Encrypt
|
||||||
} else if (typeof tls === 'string') {
|
} else {
|
||||||
|
// CA name validated by validateGenerationConfig against
|
||||||
|
// /^[a-z0-9._-]+$/i — safe to interpolate verbatim.
|
||||||
lines.push(` tls ${tls}`);
|
lines.push(` tls ${tls}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +183,8 @@ function generateSiteBlock(config) {
|
|||||||
lines.push(` # Redirect HTTP to HTTPS is automatic in Caddy 2`);
|
lines.push(` # Redirect HTTP to HTTPS is automatic in Caddy 2`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth gate (DashCaddy forward_auth)
|
// Auth gate (DashCaddy forward_auth) — authService validated by
|
||||||
|
// validateGenerationConfig against REGEX.SUBDOMAIN — safe to interpolate.
|
||||||
if (auth && authService) {
|
if (auth && authService) {
|
||||||
lines.push(` import dashcaddy_auth ${authService}`);
|
lines.push(` import dashcaddy_auth ${authService}`);
|
||||||
}
|
}
|
||||||
@@ -66,16 +198,17 @@ function generateSiteBlock(config) {
|
|||||||
lines.push(` }`);
|
lines.push(` }`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom headers
|
// Custom headers — keys validated against /^[A-Za-z0-9-]+$/, values
|
||||||
if (Object.keys(headers).length > 0) {
|
// escaped via escapeCaddyQuotedString before being placed inside "..."
|
||||||
|
if (headers && typeof headers === 'object' && Object.keys(headers).length > 0) {
|
||||||
lines.push(` header {`);
|
lines.push(` header {`);
|
||||||
for (const [key, value] of Object.entries(headers)) {
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
lines.push(` ${key} "${value}"`);
|
lines.push(` ${key} "${escapeCaddyQuotedString(value)}"`);
|
||||||
}
|
}
|
||||||
lines.push(` }`);
|
lines.push(` }`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip prefix
|
// Strip prefix — validated to /^\/[A-Za-z0-9._\-/]*$/ — safe.
|
||||||
if (stripPrefix) {
|
if (stripPrefix) {
|
||||||
lines.push(` uri strip_prefix ${stripPrefix}`);
|
lines.push(` uri strip_prefix ${stripPrefix}`);
|
||||||
}
|
}
|
||||||
@@ -118,6 +251,19 @@ module.exports = function({ asyncHandler }) {
|
|||||||
return errorResponse(res, 400, 'upstream is required (e.g. localhost:8080)');
|
return errorResponse(res, 400, 'upstream is required (e.g. localhost:8080)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DC-070: structural validation BEFORE interpolation. Every field that
|
||||||
|
// flows into the Caddyfile text must satisfy a known-safe charset rule,
|
||||||
|
// and CRLF is rejected outright. Run this BEFORE generateSiteBlock so
|
||||||
|
// the bad input is rejected with a clean 400 + enumerable error list,
|
||||||
|
// not a generated-Caddyfile + 500.
|
||||||
|
const validation = validateGenerationConfig(config);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return errorResponse(res, 400, 'Invalid configuration', {
|
||||||
|
code: 'DC-CCD-700',
|
||||||
|
errors: validation.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const caddyfile = generateSiteBlock(config);
|
const caddyfile = generateSiteBlock(config);
|
||||||
ok(res, { caddyfile, config });
|
ok(res, { caddyfile, config });
|
||||||
@@ -225,3 +371,11 @@ module.exports = function({ asyncHandler }) {
|
|||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// DC-070: export helpers for unit-testing the sanitization surface
|
||||||
|
// independently of the route handler.
|
||||||
|
module.exports.__test = {
|
||||||
|
validateGenerationConfig,
|
||||||
|
escapeCaddyQuotedString,
|
||||||
|
generateSiteBlock,
|
||||||
|
};
|
||||||
|
|||||||
@@ -37,6 +37,81 @@ const BACKUP_FILES = [
|
|||||||
|
|
||||||
const ASSET_FILES = ['custom-logo.png', 'custom-favicon.png', 'custom-logo.svg'];
|
const ASSET_FILES = ['custom-logo.png', 'custom-favicon.png', 'custom-logo.svg'];
|
||||||
|
|
||||||
|
// DC-079: Restrict restored assets to the hardcoded ASSET_FILES allowlist.
|
||||||
|
// The asset KEYS in the snapshot are user-controlled JSON, so iterating
|
||||||
|
// `Object.entries(snapshot.assets)` and writing each name verbatim into
|
||||||
|
// `path.join(assetsDir, name)` lets an attacker POST `{assets: {"../../etc/caddy/Caddyfile":
|
||||||
|
// "<base64-evil>"}}` and overwrite the live Caddyfile via the bind-mount
|
||||||
|
// (path.join('/app/data/assets', '../../etc/caddy/Caddyfile') resolves
|
||||||
|
// to /etc/caddy/Caddyfile). This bypasses the caddyfile-staging gate
|
||||||
|
// above because the dataDir bind-mount can write to /etc/caddy on the host.
|
||||||
|
const ASSET_KEY_RE = /^[a-zA-Z0-9._-]+$/;
|
||||||
|
const ASSET_PATH_TRAVERSAL_RE = /(^|\/)\.\.($|\/)|^\//;
|
||||||
|
|
||||||
|
// DC-079: Caddyfile content safety limits for disaster-recovery restore.
|
||||||
|
// The live Caddyfile on DNS2 is ~17 KB and grows linearly with vhost count.
|
||||||
|
// Express's default JSON body parser limit (1 MB) is the outer gate; this
|
||||||
|
// in-handler cap is defense-in-depth against either a future body-limit
|
||||||
|
// raise or a custom body parser. Cap well below the body-parser ceiling.
|
||||||
|
const MAX_CADDYFILE_BYTES = 512 * 1024; // 512 KiB — 30x the live file, far below 1 MB body limit
|
||||||
|
|
||||||
|
// DC-079: theme filenames must match this pattern. No slashes (no path
|
||||||
|
// traversal), no `..`, must end in `.json`, and only filename-safe chars.
|
||||||
|
// Themes are written to <dataDir>/themes/<name>; we also defense-in-depth
|
||||||
|
// check the resolved path stays inside that dir.
|
||||||
|
const THEME_NAME_RE = /^[a-zA-Z0-9._-]+\.json$/;
|
||||||
|
|
||||||
|
function assertSafeAssetKey(key) {
|
||||||
|
if (typeof key !== 'string' || key.length === 0 || key.length > 128) {
|
||||||
|
throw new Error(`asset key must be a non-empty string up to 128 chars`);
|
||||||
|
}
|
||||||
|
if (ASSET_PATH_TRAVERSAL_RE.test(key) || !ASSET_KEY_RE.test(key)) {
|
||||||
|
throw new Error(`asset key contains forbidden characters or path segments`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSafeThemeName(name) {
|
||||||
|
if (typeof name !== 'string' || name.length === 0 || name.length > 128) {
|
||||||
|
throw new Error(`theme name must be a non-empty string up to 128 chars`);
|
||||||
|
}
|
||||||
|
if (!THEME_NAME_RE.test(name)) {
|
||||||
|
throw new Error(`theme name must match ${THEME_NAME_RE} (alphanum / dot / dash / underscore, ending in .json)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject Caddyfile content that smuggles in arbitrary `import` directives.
|
||||||
|
// caddy-apply expects the single top-level Caddyfile; any `import` to an
|
||||||
|
// absolute path means "load another file from disk at Caddy reload time" —
|
||||||
|
// that's a classic injection vector (an attacker can craft a snapshot whose
|
||||||
|
// `import /etc/caddy/external.caddy` reads any file Caddy can read).
|
||||||
|
// We allow the relative-style `import <snippet>` form ONLY if the snippet
|
||||||
|
// name matches a small allowlist of well-known Caddy snippet names (none
|
||||||
|
// today; add explicit names if a future snippet module is needed).
|
||||||
|
const FORBIDDEN_IMPORT_RE = /^\s*import\s+(["']|\/|\.\.|~\/|%[A-F0-9]{2})/im;
|
||||||
|
|
||||||
|
function validateCaddyfileContent(content) {
|
||||||
|
if (typeof content !== 'string') {
|
||||||
|
return { ok: false, error: 'Caddyfile content must be a string' };
|
||||||
|
}
|
||||||
|
if (content.length === 0) {
|
||||||
|
return { ok: false, error: 'Caddyfile content is empty' };
|
||||||
|
}
|
||||||
|
if (Buffer.byteLength(content, 'utf8') > MAX_CADDYFILE_BYTES) {
|
||||||
|
return { ok: false, error: `Caddyfile content exceeds ${MAX_CADDYFILE_BYTES} bytes` };
|
||||||
|
}
|
||||||
|
if (FORBIDDEN_IMPORT_RE.test(content)) {
|
||||||
|
// Allow the canonical single-quoted snippet import form ONLY if the
|
||||||
|
// snippet name is on the explicit allowlist (currently empty). This
|
||||||
|
// catches absolute paths, ../, ~/, and URL-encoded payloads while
|
||||||
|
// leaving room for future snippet additions without touching this gate.
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'Caddyfile contains forbidden `import` directive (absolute path, encoded, or non-allowlisted snippet)'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = function({ servicesStateManager, platformPaths, log, asyncHandler }) {
|
module.exports = function({ servicesStateManager, platformPaths, log, asyncHandler }) {
|
||||||
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
|
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -44,6 +119,15 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
|
|||||||
let lastBackupStatus = { timestamp: null, status: null, size: null };
|
let lastBackupStatus = { timestamp: null, status: null, size: null };
|
||||||
let lastRestoreStatus = { timestamp: null, status: null };
|
let lastRestoreStatus = { timestamp: null, status: null };
|
||||||
|
|
||||||
|
// DC-079: Staging dir for the candidate Caddyfile. The disaster-recovery
|
||||||
|
// restore endpoint stages here instead of writing directly to the live
|
||||||
|
// Caddyfile path. The operator must run `caddy-apply` (or its equivalent)
|
||||||
|
// to validate + reload + git-commit the staged file. This keeps the live
|
||||||
|
// Caddyfile under the same atomic-commit guard as every other edit.
|
||||||
|
function getStagedCaddyfileDir(dataDir) {
|
||||||
|
return path.join(dataDir, 'disaster-staged');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/v1/disaster/backup
|
* POST /api/v1/disaster/backup
|
||||||
* Creates a complete system snapshot as a downloadable JSON file.
|
* Creates a complete system snapshot as a downloadable JSON file.
|
||||||
@@ -175,13 +259,64 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore Caddyfile
|
// DC-079: Stage the Caddyfile to a staging path inside dataDir
|
||||||
if (snapshot.caddyfile) {
|
// instead of writing directly to caddyfilePath (which is the LIVE
|
||||||
|
// /etc/caddy/Caddyfile bind-mounted into the container as /caddyfile).
|
||||||
|
//
|
||||||
|
// Threat model (defense-in-depth, mirrors DC-070 / DC-074 / DC-076):
|
||||||
|
// the endpoint is TOTP-gated, but a compromised operator / phished
|
||||||
|
// session / pivot path could POST a snapshot with `caddyfile: <evil>`
|
||||||
|
// and the pre-fix code would call `fsp.writeFile(caddyfilePath, ...)`
|
||||||
|
// which writes the attacker-controlled string straight to the live
|
||||||
|
// Caddyfile. Caddy then reads that file on the next reload (which can
|
||||||
|
// be triggered by ACME renewals, health probes, or any admin API
|
||||||
|
// touch), executing whatever directives the attacker embedded:
|
||||||
|
// - `admin off` + arbitrary config write
|
||||||
|
// - `import /etc/caddy/<anything-caddy-can-read>` for content theft
|
||||||
|
// - `reverse_proxy` to attacker-controlled upstreams
|
||||||
|
// - `acme_ca` override to attacker CA
|
||||||
|
// - `log` directives to attacker-writable paths
|
||||||
|
//
|
||||||
|
// The Caddyfile is managed by the `caddy-apply` wrapper (validates +
|
||||||
|
// reloads + git-commits atomically — see CLAUDE.md hard rule). This
|
||||||
|
// endpoint previously bypassed that wrapper. The fix stages the
|
||||||
|
// candidate file under dataDir/disaster-staged/Caddyfile.candidate and
|
||||||
|
// returns the path so the operator can apply it via the normal flow.
|
||||||
|
const caddyfileStaged = [];
|
||||||
|
// DC-079: handle three cases for the caddyfile field:
|
||||||
|
// - absent/null/undefined: back-compat — no Caddyfile in snapshot
|
||||||
|
// - empty string "": explicit empty payload is suspicious — reject
|
||||||
|
// - non-string (object/array/number): type confusion attempt — reject
|
||||||
|
// - valid string: stage to dataDir/disaster-staged/Caddyfile.candidate
|
||||||
|
if (snapshot.caddyfile !== undefined && snapshot.caddyfile !== null) {
|
||||||
|
const validation = validateCaddyfileContent(snapshot.caddyfile);
|
||||||
|
if (!validation.ok) {
|
||||||
|
return errorResponse(res, 400, `Invalid Caddyfile in snapshot: ${validation.error}`, {
|
||||||
|
code: ErrorCodes.BACKUP.INVALID_CONFIG,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stagedDir = getStagedCaddyfileDir(dataDir);
|
||||||
try {
|
try {
|
||||||
await fsp.writeFile(caddyfilePath, snapshot.caddyfile);
|
await fsp.mkdir(stagedDir, { recursive: true });
|
||||||
restored.push('Caddyfile');
|
const stagedPath = path.join(stagedDir, 'Caddyfile.candidate');
|
||||||
|
// Atomic write: write to .candidate.tmp then rename. The live
|
||||||
|
// Caddyfile is NEVER touched from this endpoint.
|
||||||
|
const tmpPath = stagedPath + '.tmp';
|
||||||
|
await fsp.writeFile(tmpPath, snapshot.caddyfile, { mode: 0o644 });
|
||||||
|
await fsp.rename(tmpPath, stagedPath);
|
||||||
|
caddyfileStaged.push({
|
||||||
|
file: 'Caddyfile',
|
||||||
|
stagedPath,
|
||||||
|
action: 'awaiting caddy-apply',
|
||||||
|
livePath: caddyfilePath,
|
||||||
|
});
|
||||||
|
if (log) log.info('disaster-recovery', 'Caddyfile staged (not applied)', {
|
||||||
|
stagedPath,
|
||||||
|
size: Buffer.byteLength(snapshot.caddyfile, 'utf8'),
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push({ file: 'Caddyfile', error: err.message });
|
errors.push({ file: 'Caddyfile (staging)', error: err.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,8 +324,20 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
|
|||||||
const assetsDir = platformPaths?.resolveAssetsPath?.() || path.join(dataDir, 'assets');
|
const assetsDir = platformPaths?.resolveAssetsPath?.() || path.join(dataDir, 'assets');
|
||||||
for (const [name, base64] of Object.entries(snapshot.assets || {})) {
|
for (const [name, base64] of Object.entries(snapshot.assets || {})) {
|
||||||
try {
|
try {
|
||||||
|
// DC-079: assets directory is the first attack surface that
|
||||||
|
// bypasses the Caddyfile-staging gate. `name` is a user-supplied
|
||||||
|
// JSON key; without validation, `path.join(assetsDir, name)` lets
|
||||||
|
// an attacker escape to /etc/caddy via path traversal.
|
||||||
|
assertSafeAssetKey(name);
|
||||||
|
const resolved = path.resolve(assetsDir, name);
|
||||||
|
// Defense-in-depth: even after charset checks, the resolved path
|
||||||
|
// MUST stay inside assetsDir. If it doesn't, refuse the write.
|
||||||
|
if (!resolved.startsWith(path.resolve(assetsDir) + path.sep) &&
|
||||||
|
resolved !== path.resolve(assetsDir)) {
|
||||||
|
throw new Error(`asset path resolves outside assets directory`);
|
||||||
|
}
|
||||||
await fsp.mkdir(assetsDir, { recursive: true });
|
await fsp.mkdir(assetsDir, { recursive: true });
|
||||||
await fsp.writeFile(path.join(assetsDir, name), Buffer.from(base64, 'base64'));
|
await fsp.writeFile(resolved, Buffer.from(base64, 'base64'));
|
||||||
restored.push(`assets/${name}`);
|
restored.push(`assets/${name}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push({ file: `assets/${name}`, error: err.message });
|
errors.push({ file: `assets/${name}`, error: err.message });
|
||||||
@@ -203,8 +350,21 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
|
|||||||
try {
|
try {
|
||||||
await fsp.mkdir(themesDir, { recursive: true });
|
await fsp.mkdir(themesDir, { recursive: true });
|
||||||
for (const [name, content] of Object.entries(snapshot.themes)) {
|
for (const [name, content] of Object.entries(snapshot.themes)) {
|
||||||
await fsp.writeFile(path.join(themesDir, name), JSON.stringify(content, null, 2));
|
// DC-079: same path-traversal vector as assets — keys are
|
||||||
restored.push(`themes/${name}`);
|
// user-controlled JSON. Validate the name AND confirm the
|
||||||
|
// resolved path stays inside themesDir.
|
||||||
|
try {
|
||||||
|
assertSafeThemeName(name);
|
||||||
|
const resolved = path.resolve(themesDir, name);
|
||||||
|
if (!resolved.startsWith(path.resolve(themesDir) + path.sep) &&
|
||||||
|
resolved !== path.resolve(themesDir)) {
|
||||||
|
throw new Error(`theme path resolves outside themes directory`);
|
||||||
|
}
|
||||||
|
await fsp.writeFile(resolved, JSON.stringify(content, null, 2));
|
||||||
|
restored.push(`themes/${name}`);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push({ file: `themes/${name}`, error: err.message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push({ file: 'themes', error: err.message });
|
errors.push({ file: 'themes', error: err.message });
|
||||||
@@ -215,19 +375,33 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
status: errors.length === 0 ? 'success' : 'partial',
|
status: errors.length === 0 ? 'success' : 'partial',
|
||||||
restored: restored.length,
|
restored: restored.length,
|
||||||
|
staged: caddyfileStaged.length,
|
||||||
errors: errors.length,
|
errors: errors.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (log) log.info('disaster-recovery', 'Restore completed', lastRestoreStatus);
|
if (log) log.info('disaster-recovery', 'Restore completed', lastRestoreStatus);
|
||||||
|
|
||||||
ok(res, {
|
// DC-079: Surface the staged-Caddyfile warning in the response body so
|
||||||
|
// the UI / operator can see that the Caddyfile is NOT yet live. The
|
||||||
|
// restore endpoint stages under dataDir/disaster-staged/Caddyfile.candidate
|
||||||
|
// and the operator must run `caddy-apply` (or its equivalent) to
|
||||||
|
// validate + reload + git-commit the staged file. The live Caddyfile
|
||||||
|
// is owned by the caddy-apply wrapper per CLAUDE.md hard rule.
|
||||||
|
const responseBody = {
|
||||||
status: errors.length === 0 ? 'success' : 'partial',
|
status: errors.length === 0 ? 'success' : 'partial',
|
||||||
restored,
|
restored,
|
||||||
errors,
|
errors,
|
||||||
message: errors.length === 0
|
message: errors.length === 0
|
||||||
? `Successfully restored ${restored.length} files. Restart DashCaddy to apply.`
|
? `Successfully restored ${restored.length} files${caddyfileStaged.length > 0 ? ` (Caddyfile staged — ${caddyfileStaged[0].stagedPath}; run caddy-apply to apply)` : ''}. Restart DashCaddy to apply.`
|
||||||
: `Restored ${restored.length} files with ${errors.length} errors. Check error details.`,
|
: `Restored ${restored.length} files with ${errors.length} errors. Check error details.`,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (caddyfileStaged.length > 0) {
|
||||||
|
responseBody.caddyfileStaged = caddyfileStaged;
|
||||||
|
responseBody.warning = '[DC-079] Caddyfile is STAGED, not applied. Live /etc/caddy/Caddyfile was NOT modified by this restore. Run `caddy-apply <reason>` (or equivalent) to validate + reload + git-commit the staged candidate.';
|
||||||
|
}
|
||||||
|
|
||||||
|
ok(res, responseBody);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,50 @@ const url = require('url');
|
|||||||
|
|
||||||
const docker = new Docker();
|
const docker = new Docker();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DC-072: WebSocket scope authorization — admin-only by default.
|
||||||
|
*
|
||||||
|
* Container exec is full root-equivalent access inside the target
|
||||||
|
* container. Granting it to a key whose scope is `['read']` violates
|
||||||
|
* least privilege. The validScopes list (`['read','write','admin']`)
|
||||||
|
* is defined in routes/auth/keys.js; exec requires `admin`.
|
||||||
|
*
|
||||||
|
* Defensive: the scope field is coerced via `Array.isArray(...) ? ... : []`
|
||||||
|
* so a malformed payload (string, object, null, undefined) cannot reach
|
||||||
|
* `.includes('admin')` and accidentally grant access. Every malformed
|
||||||
|
* shape falls into the rejection branch with the same 403 envelope.
|
||||||
|
*
|
||||||
|
* Tests should call `__test.assertExecScope(auth)` directly rather
|
||||||
|
* than spinning up a WebSocket server.
|
||||||
|
*/
|
||||||
|
function assertExecScope(auth) {
|
||||||
|
const scope = Array.isArray(auth && auth.scope) ? auth.scope : [];
|
||||||
|
if (!scope.includes('admin')) {
|
||||||
|
const err = new Error('Container exec requires admin scope');
|
||||||
|
err.code = 'DC-072_INSUFFICIENT_SCOPE';
|
||||||
|
err.statusCode = 403;
|
||||||
|
err.requiredScope = 'admin';
|
||||||
|
err.actualScope = scope;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DC-072: Tighten containerId validation.
|
||||||
|
*
|
||||||
|
* Docker container IDs are exactly 64 lowercase hex chars (or 12-char
|
||||||
|
* short form). The pre-fix regex accepted `_`, `-`, `.`, mixed case,
|
||||||
|
* and up to 128 chars — Docker would then 404 the inspect call and
|
||||||
|
* the rejection would surface as a generic 500 in the WS error
|
||||||
|
* envelope. Pre-validate at the upgrade layer so the rejection is
|
||||||
|
* fast and the log line discriminates "malformed" from "unknown".
|
||||||
|
*/
|
||||||
|
function isValidContainerId(id) {
|
||||||
|
if (typeof id !== 'string') return false;
|
||||||
|
// Full 64-char hex, or 12-char short hex
|
||||||
|
return /^[0-9a-f]{64}$/.test(id) || /^[0-9a-f]{12}$/.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attach WebSocket server for container exec/shell
|
* Attach WebSocket server for container exec/shell
|
||||||
* Route: ws://host/ws/exec/:containerId
|
* Route: ws://host/ws/exec/:containerId
|
||||||
@@ -21,8 +65,8 @@ module.exports = function attachExecWS(server, log, authManager) {
|
|||||||
|
|
||||||
const containerId = decodeURIComponent(match[1]);
|
const containerId = decodeURIComponent(match[1]);
|
||||||
|
|
||||||
// Validate container ID format to prevent injection
|
// DC-072: Tighten containerId charset (64-char / 12-char lowercase hex)
|
||||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(containerId)) {
|
if (!isValidContainerId(containerId)) {
|
||||||
log.warn('exec', 'Invalid container ID in WebSocket path', { containerId });
|
log.warn('exec', 'Invalid container ID in WebSocket path', { containerId });
|
||||||
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
@@ -55,6 +99,35 @@ module.exports = function attachExecWS(server, log, authManager) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DC-072: Container exec is root-equivalent — require admin scope.
|
||||||
|
// Pre-fix, a key issued with scope `['read']` (e.g., for monitoring)
|
||||||
|
// would get a full PTY shell inside any running container. The
|
||||||
|
// `auth.scope` was captured at lines 39/46 but never checked.
|
||||||
|
try {
|
||||||
|
assertExecScope(auth);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('exec', 'Insufficient scope for exec attempt', {
|
||||||
|
containerId,
|
||||||
|
authType: auth.type,
|
||||||
|
authId: auth.type === 'jwt' ? auth.userId : auth.keyId,
|
||||||
|
actualScope: err.actualScope,
|
||||||
|
requiredScope: err.requiredScope,
|
||||||
|
ip: req.socket.remoteAddress,
|
||||||
|
});
|
||||||
|
// 403 with a JSON error envelope over the upgrade socket so the
|
||||||
|
// dashboard can display "admin required" instead of guessing.
|
||||||
|
socket.write('HTTP/1.1 403 Forbidden\r\n');
|
||||||
|
socket.write('Content-Type: application/json\r\n');
|
||||||
|
socket.write('\r\n');
|
||||||
|
socket.end(JSON.stringify({
|
||||||
|
error: err.message,
|
||||||
|
code: err.code,
|
||||||
|
requiredScope: err.requiredScope,
|
||||||
|
actualScope: err.actualScope,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Auth passed — proceed with WebSocket upgrade
|
// Auth passed — proceed with WebSocket upgrade
|
||||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
handleExec(ws, containerId, log, auth);
|
handleExec(ws, containerId, log, auth);
|
||||||
@@ -67,6 +140,7 @@ module.exports = function attachExecWS(server, log, authManager) {
|
|||||||
async function handleExec(ws, containerId, log, auth) {
|
async function handleExec(ws, containerId, log, auth) {
|
||||||
let execStream = null;
|
let execStream = null;
|
||||||
let execInstance = null;
|
let execInstance = null;
|
||||||
|
const sessionStart = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const container = docker.getContainer(containerId);
|
const container = docker.getContainer(containerId);
|
||||||
@@ -78,10 +152,13 @@ async function handleExec(ws, containerId, log, auth) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DC-072: Audit-log the exec session start. Pairs with the end-log
|
||||||
|
// below so the operator can correlate who opened which shell.
|
||||||
log.info('exec', 'Authenticated exec session started', {
|
log.info('exec', 'Authenticated exec session started', {
|
||||||
containerId,
|
containerId,
|
||||||
authType: auth.type,
|
authType: auth.type,
|
||||||
authId: auth.type === 'jwt' ? auth.userId : auth.keyId
|
authId: auth.type === 'jwt' ? auth.userId : auth.keyId,
|
||||||
|
containerName: info.Name,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detect available shell
|
// Detect available shell
|
||||||
@@ -120,7 +197,28 @@ async function handleExec(ws, containerId, log, auth) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// DC-072: Track whether the end-log has fired so we don't double-log
|
||||||
|
// when both execStream 'end' and ws 'close' fire (Docker stream end
|
||||||
|
// closes the WS, which then fires 'close' too — without the flag
|
||||||
|
// we'd emit the same audit line twice with the same durationMs).
|
||||||
|
let ended = false;
|
||||||
|
const logSessionEnd = (reason) => {
|
||||||
|
if (ended) return;
|
||||||
|
ended = true;
|
||||||
|
log.info('exec', 'Exec session ended', {
|
||||||
|
containerId,
|
||||||
|
authType: auth.type,
|
||||||
|
authId: auth.type === 'jwt' ? auth.userId : auth.keyId,
|
||||||
|
durationMs: Date.now() - sessionStart,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
execStream.on('end', () => {
|
execStream.on('end', () => {
|
||||||
|
// DC-072: Audit-log the session end (duration + container) so a
|
||||||
|
// long-running session is observable in the error log. Normal
|
||||||
|
// shutdown path: Docker exec stream closes → log + tell client.
|
||||||
|
logSessionEnd('exec-stream-end');
|
||||||
if (ws.readyState === ws.OPEN) {
|
if (ws.readyState === ws.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: 'exit' }));
|
ws.send(JSON.stringify({ type: 'exit' }));
|
||||||
ws.close();
|
ws.close();
|
||||||
@@ -148,6 +246,11 @@ async function handleExec(ws, containerId, log, auth) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
|
// DC-072: Fallback audit-log for abnormal close (browser tab
|
||||||
|
// closed, network drop, container killed mid-session) where the
|
||||||
|
// execStream 'end' event never fires. The ended-flag guard makes
|
||||||
|
// this idempotent with the normal path above.
|
||||||
|
logSessionEnd('ws-close');
|
||||||
if (execStream) {
|
if (execStream) {
|
||||||
try { execStream.destroy(); } catch (_) {
|
try { execStream.destroy(); } catch (_) {
|
||||||
// Ignore stream teardown errors on socket close
|
// Ignore stream teardown errors on socket close
|
||||||
@@ -172,3 +275,11 @@ async function handleExec(ws, containerId, log, auth) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Internal-only export for unit tests. Stripped from the public
|
||||||
|
// surface; tests import this via the destructure form
|
||||||
|
// `const { __test } = require('./routes/exec')`.
|
||||||
|
module.exports.__test = {
|
||||||
|
assertExecScope,
|
||||||
|
isValidContainerId,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,8 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* DC-081: Plain-English log insights + dispose endpoint
|
||||||
|
*
|
||||||
|
* GET /api/v1/log-insights — Plain English summary of who's doing what
|
||||||
|
* POST /api/v1/log-insights/dispose — Preview then confirm cleanup
|
||||||
|
*
|
||||||
|
* DC-081 hardening (paired with the deploy path fix):
|
||||||
|
* - AUDIT_LOG_FILE / SECURITY_EVENT_LOG_FILE were HARDCODED to
|
||||||
|
* `/opt/dashcaddy/dashcaddy-api/data/...` which DOES NOT EXIST in the
|
||||||
|
* production container — files live at `/app/data/...`. The dispose
|
||||||
|
* endpoint silently no-op'd (read empty arrays, wrote empty arrays
|
||||||
|
* back) and the GET endpoint dropped the storage-size block. Both
|
||||||
|
* paths now use the same canonical resolution as the audit-logger
|
||||||
|
* itself: `process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json')`.
|
||||||
|
* - keepDays was unbounded — `parseInt(req.body.keepDays) || 30` accepted
|
||||||
|
* negative numbers (e.g. -1000 → cutoff = +3 years in the future,
|
||||||
|
* deleting 100% of forensic context) and non-integers (Infinity,
|
||||||
|
* floats). Now validated to an integer in [1, 3650] (1 day .. 10 years)
|
||||||
|
* before any file read.
|
||||||
|
* - confirm gate added: must send { confirm: true, keepDays: N } — the
|
||||||
|
* preview pass is read-only, the confirm pass writes. Matches the
|
||||||
|
* audit-logs/DELETE confirm=CLEAR pattern.
|
||||||
|
* - The dispose handler now uses a single shared `_resolvePaths()` helper
|
||||||
|
* to keep GET and POST in lockstep (and so a future path-config change
|
||||||
|
* touches one site, not four).
|
||||||
|
*
|
||||||
|
* Pre-DC-081 verification: from inside the running container, both
|
||||||
|
* `/app/data/audit-log.json` (318 KB) and `/app/data/security-events.jsonl`
|
||||||
|
* (15 MB) exist, but the old hardcoded `/opt/dashcaddy/dashcaddy-api/data/...`
|
||||||
|
* paths resolve to ENOENT. The dispose endpoint therefore did nothing;
|
||||||
|
* this fix wires it back to the actual files.
|
||||||
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
|
const platformPaths = require('../platform-paths');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the canonical paths for the audit log + security event log.
|
||||||
|
*
|
||||||
|
* Both store the file path in their own module-level constants, so any
|
||||||
|
* environment override (e.g. AUDIT_LOG_FILE=...) is honoured here too —
|
||||||
|
* exactly the same behaviour as src/security/audit-logger.js and
|
||||||
|
* src/security/event-store.js. Without this, a container with
|
||||||
|
* AUDIT_LOG_FILE set would see the dispose handler read from one file
|
||||||
|
* and the audit-logger write to a different one.
|
||||||
|
*
|
||||||
|
* @returns {{auditPath: string, secPath: string, auditPathFrom: string, secPathFrom: string}}
|
||||||
|
* paths + the source ("env" or "default") so tests can verify.
|
||||||
|
*/
|
||||||
|
function _resolvePaths() {
|
||||||
|
const auditPath = process.env.AUDIT_LOG_FILE
|
||||||
|
|| path.join(platformPaths.dataDir, 'audit-log.json');
|
||||||
|
const secPath = process.env.SECURITY_EVENT_LOG_FILE
|
||||||
|
|| path.join(platformPaths.dataDir, 'security-events.jsonl');
|
||||||
|
return {
|
||||||
|
auditPath,
|
||||||
|
secPath,
|
||||||
|
auditPathFrom: process.env.AUDIT_LOG_FILE ? 'env' : 'default',
|
||||||
|
secPathFrom: process.env.SECURITY_EVENT_LOG_FILE ? 'env' : 'default',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the keepDays input. Coerces + bounds-checks BEFORE any file
|
||||||
|
* read so a malicious or mistyped client can't:
|
||||||
|
* - pass a negative number (cutoff = far future → wipe 100%)
|
||||||
|
* - pass Infinity (parseInt(Infinity, 10) === NaN, currently falls
|
||||||
|
* through `|| 30` — fixed to fail-fast instead)
|
||||||
|
* - pass a non-integer (e.g. 1.5 → cutoff mid-day, off-by-half-day)
|
||||||
|
* - pass 0 (no-op-but-lies) or 10000 (way past retention policy)
|
||||||
|
*
|
||||||
|
* @param {unknown} raw - value from req.body.keepDays
|
||||||
|
* @returns {number} validated integer in [1, 3650]
|
||||||
|
* @throws {Error} when out of range / wrong type
|
||||||
|
*/
|
||||||
|
function _validateKeepDays(raw) {
|
||||||
|
if (raw === undefined || raw === null) {
|
||||||
|
throw new Error('keepDays is required (integer in [1, 3650])');
|
||||||
|
}
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
throw new Error(`keepDays must be a finite number (received ${JSON.stringify(raw)})`);
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(n)) {
|
||||||
|
throw new Error(`keepDays must be an integer (received ${raw})`);
|
||||||
|
}
|
||||||
|
if (n < 1 || n > 3650) {
|
||||||
|
throw new Error(`keepDays must be between 1 and 3650 (received ${n})`);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore }) {
|
module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
// Resolve once at module init so GET + POST both use the same files.
|
||||||
|
// If the env vars change at runtime (rare — start.sh wires them at
|
||||||
|
// container start), operators re-deploy rather than mutate env mid-flight.
|
||||||
|
const { auditPath, secPath } = _resolvePaths();
|
||||||
|
|
||||||
// GET /api/v1/log-insights — Plain English summary of who's doing what
|
// GET /api/v1/log-insights — Plain English summary of who's doing what
|
||||||
router.get('/log-insights', asyncHandler(async (req, res) => {
|
router.get('/log-insights', asyncHandler(async (req, res) => {
|
||||||
@@ -74,16 +169,18 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Storage info ---
|
// --- Storage info ---
|
||||||
const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json';
|
// DC-081: read from the canonical resolved paths (NOT the hardcoded
|
||||||
const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl';
|
// /opt/... paths that don't exist in the container). Empty-object
|
||||||
|
// fallback on ENOENT — the file may legitimately be absent on a
|
||||||
|
// fresh install where the audit-logger hasn't written yet.
|
||||||
let storage = {};
|
let storage = {};
|
||||||
try {
|
try {
|
||||||
const a = await fs.stat(auditPath);
|
const a = await fs.stat(auditPath);
|
||||||
storage.auditLog = { sizeMB: +(a.size / 1048576).toFixed(2), entries: auditEntries.length };
|
storage.auditLog = { sizeMB: +(a.size / 1048576).toFixed(2), entries: auditEntries.length, path: auditPath };
|
||||||
} catch {}
|
} catch {}
|
||||||
try {
|
try {
|
||||||
const s = await fs.stat(secPath);
|
const s = await fs.stat(secPath);
|
||||||
storage.securityEvents = { sizeMB: +(s.size / 1048576).toFixed(2), entries: securityEvents.length };
|
storage.securityEvents = { sizeMB: +(s.size / 1048576).toFixed(2), entries: securityEvents.length, path: secPath };
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
ok(res, {
|
ok(res, {
|
||||||
@@ -108,16 +205,44 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// POST /api/v1/log-insights/dispose — Preview then confirm cleanup
|
// POST /api/v1/log-insights/dispose — Preview then confirm cleanup
|
||||||
|
//
|
||||||
|
// Two-call pattern:
|
||||||
|
// 1. { keepDays: 30 } → preview, no writes
|
||||||
|
// 2. { keepDays: 30, confirm: true } → actually delete
|
||||||
|
//
|
||||||
|
// DC-081 hardening:
|
||||||
|
// - keepDays is validated to integer [1, 3650] BEFORE any file read.
|
||||||
|
// A negative keepDays (e.g. -1000) would previously compute a
|
||||||
|
// cutoff +3 years in the future, then delete every entry older
|
||||||
|
// than that — i.e. 100% of the audit log. Now rejected at the gate.
|
||||||
|
// - auditPath / secPath come from the canonical _resolvePaths() helper
|
||||||
|
// so the container's actual /app/data files are read (the pre-fix
|
||||||
|
// hardcoded /opt/dashcaddy/dashcaddy-api/data/... paths resolved to
|
||||||
|
// ENOENT inside the container, so the endpoint silently did nothing).
|
||||||
router.post('/log-insights/dispose', asyncHandler(async (req, res) => {
|
router.post('/log-insights/dispose', asyncHandler(async (req, res) => {
|
||||||
const keepDays = parseInt(req.body.keepDays) || 30;
|
// Validate keepDays first — fail-fast before any file IO so a bad
|
||||||
|
// client never touches disk.
|
||||||
|
let keepDays;
|
||||||
|
try {
|
||||||
|
keepDays = _validateKeepDays(req.body?.keepDays);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(400).json({ success: false, error: e.message, code: 'DC-081_INVALID_KEEP_DAYS' });
|
||||||
|
}
|
||||||
const confirm = req.body.confirm === true;
|
const confirm = req.body.confirm === true;
|
||||||
const cutoff = new Date(Date.now() - keepDays * 86400000).toISOString();
|
const cutoff = new Date(Date.now() - keepDays * 86400000).toISOString();
|
||||||
|
|
||||||
const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json';
|
// Read both files via the canonical resolved paths (NOT the hardcoded
|
||||||
const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl';
|
// /opt/... paths from before — those don't exist in the container).
|
||||||
|
|
||||||
const auditRaw = await fs.readFile(auditPath, 'utf8').catch(function () { return '[]'; });
|
const auditRaw = await fs.readFile(auditPath, 'utf8').catch(function () { return '[]'; });
|
||||||
const auditData = JSON.parse(auditRaw);
|
let auditData;
|
||||||
|
try {
|
||||||
|
auditData = JSON.parse(auditRaw);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ success: false, error: `audit-log file is corrupt (${auditPath}): ${e.message}`, code: 'DC-081_AUDIT_PARSE_FAILED' });
|
||||||
|
}
|
||||||
|
if (!Array.isArray(auditData)) {
|
||||||
|
return res.status(500).json({ success: false, error: `audit-log file is not an array (${auditPath})`, code: 'DC-081_AUDIT_SHAPE_INVALID' });
|
||||||
|
}
|
||||||
const oldAudit = auditData.filter(function (e) { return e.timestamp < cutoff; });
|
const oldAudit = auditData.filter(function (e) { return e.timestamp < cutoff; });
|
||||||
|
|
||||||
const secRaw = await fs.readFile(secPath, 'utf8').catch(function () { return ''; });
|
const secRaw = await fs.readFile(secPath, 'utf8').catch(function () { return ''; });
|
||||||
@@ -127,16 +252,40 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
|
|||||||
if (!confirm) {
|
if (!confirm) {
|
||||||
ok(res, {
|
ok(res, {
|
||||||
preview: true,
|
preview: true,
|
||||||
message: 'This will delete ' + oldAudit.length + ' audit entries and ' + oldSec.length + ' security events older than ' + keepDays + ' days. Send {confirm: true} to proceed.',
|
message: 'This will delete ' + oldAudit.length + ' audit entries and ' + oldSec.length + ' security events older than ' + keepDays + ' days. Send {confirm: true, keepDays: ' + keepDays + '} to proceed.',
|
||||||
wouldDelete: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
wouldDelete: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
||||||
cutoffDate: cutoff
|
cutoffDate: cutoff,
|
||||||
|
paths: { auditPath, secPath },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute cleanup
|
// Execute cleanup. Audit the wipe FIRST via the audit-logger so the
|
||||||
|
// fact that a delete happened is itself preserved (matches the
|
||||||
|
// audit-logs/DELETE + error-logs/DELETE pattern).
|
||||||
|
try {
|
||||||
|
if (auditLogger && typeof auditLogger.log === 'function') {
|
||||||
|
await auditLogger.log({
|
||||||
|
action: 'log-insights.dispose',
|
||||||
|
resource: 'audit-log,security-events',
|
||||||
|
outcome: 'success',
|
||||||
|
details: {
|
||||||
|
keepDays,
|
||||||
|
cutoff,
|
||||||
|
deleted: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch { /* don't fail the dispose on audit-side errors */ }
|
||||||
|
|
||||||
|
// Rewrite audit-log.json atomically — write to tmp + rename so a
|
||||||
|
// crash mid-write can't leave the file half-empty (the file is read
|
||||||
|
// by state-manager on every container start; a corrupt file would
|
||||||
|
// block the whole API).
|
||||||
const keptAudit = auditData.filter(function (e) { return e.timestamp >= cutoff; });
|
const keptAudit = auditData.filter(function (e) { return e.timestamp >= cutoff; });
|
||||||
await fs.writeFile(auditPath, JSON.stringify(keptAudit, null, 2));
|
const tmpAudit = auditPath + '.tmp';
|
||||||
|
await fs.writeFile(tmpAudit, JSON.stringify(keptAudit, null, 2));
|
||||||
|
await fs.rename(tmpAudit, auditPath);
|
||||||
|
|
||||||
const keptSec = secLines.filter(function (l) { try { return JSON.parse(l).timestamp >= cutoff; } catch (e) { return false; } });
|
const keptSec = secLines.filter(function (l) { try { return JSON.parse(l).timestamp >= cutoff; } catch (e) { return false; } });
|
||||||
await fs.writeFile(secPath, keptSec.join('\n') + '\n');
|
await fs.writeFile(secPath, keptSec.join('\n') + '\n');
|
||||||
@@ -145,9 +294,16 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
|
|||||||
disposed: true,
|
disposed: true,
|
||||||
deleted: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
deleted: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
||||||
remaining: { auditEntries: keptAudit.length, securityEvents: keptSec.length },
|
remaining: { auditEntries: keptAudit.length, securityEvents: keptSec.length },
|
||||||
cutoffDate: cutoff
|
cutoffDate: cutoff,
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// DC-081: export helpers for direct unit testing (the route handlers are
|
||||||
|
// otherwise unreachable from outside the factory closure).
|
||||||
|
module.exports.__test = {
|
||||||
|
_resolvePaths,
|
||||||
|
_validateKeepDays,
|
||||||
|
};
|
||||||
@@ -263,9 +263,10 @@ module.exports = function({
|
|||||||
const arrKey = await credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
|
const arrKey = await credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
|
||||||
const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
|
const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
|
||||||
const username = await credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
const username = await credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
||||||
|
const password = await credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
||||||
success(res, {
|
success(res, {
|
||||||
hasApiKey: !!(arrKey || svcKey),
|
hasApiKey: !!(arrKey || svcKey),
|
||||||
hasBasicAuth: !!username,
|
hasBasicAuth: !!username && !!password,
|
||||||
username: username || null
|
username: username || null
|
||||||
});
|
});
|
||||||
}, 'service-creds'));
|
}, 'service-creds'));
|
||||||
|
|||||||
@@ -37,6 +37,10 @@
|
|||||||
const { ValidationError, NotFoundError } = require('../src/utilities/errors');
|
const { ValidationError, NotFoundError } = require('../src/utilities/errors');
|
||||||
const { PaymentRequiredError } = require('../src/utilities/errors');
|
const { PaymentRequiredError } = require('../src/utilities/errors');
|
||||||
const { ok, created, badRequest, notFound } = require('../src/utils/responses');
|
const { ok, created, badRequest, notFound } = require('../src/utils/responses');
|
||||||
|
// DC-083: route-layer validators for the public CSRF-exempt endpoints. These
|
||||||
|
// are imported from share-store so the route and store stay in lockstep
|
||||||
|
// (drift risk if one set is updated and the other is forgotten).
|
||||||
|
const { validatePublicEmail, validatePublicDeviceId } = require('../src/security/share-store');
|
||||||
|
|
||||||
const PUBLIC_TTL_OPTIONS = new Set([
|
const PUBLIC_TTL_OPTIONS = new Set([
|
||||||
60 * 60 * 1000,
|
60 * 60 * 1000,
|
||||||
@@ -293,7 +297,35 @@ module.exports = function shareRoutesFactory({
|
|||||||
|
|
||||||
// ─── Public endpoints (no auth, no Pro gate) ──────────────────────────────
|
// ─── Public endpoints (no auth, no Pro gate) ──────────────────────────────
|
||||||
|
|
||||||
router.get('/share/:token/preview', asyncHandler(async (req, res) => {
|
// DC-083: rate-limit the two CSRF-exempt public endpoints. The general
|
||||||
|
// limiter (1000/15min) is mounted globally in app.js and is too generous
|
||||||
|
// for unauthenticated state-mutating endpoints. 30/15min per IP is
|
||||||
|
// enough for a legitimate user clicking "subscribe" once or twice; anything
|
||||||
|
// beyond is abuse. Skipped in test envs via the standard isTest guard.
|
||||||
|
// Lazy-loaded so test environments without the dep installed don't blow up;
|
||||||
|
// a missing-dep in production logs a warning and falls back to no-op (still
|
||||||
|
// safe — the route+store validators are the primary defense).
|
||||||
|
const { RATE_LIMITS } = require('../src/utilities/constants');
|
||||||
|
const isTest = process.env.NODE_ENV === 'test';
|
||||||
|
let _sharePublicLimiter = (req, _res, next) => next(); // no-op default
|
||||||
|
try {
|
||||||
|
const rateLimit = require('express-rate-limit'); // eslint-disable-line global-require
|
||||||
|
_sharePublicLimiter = rateLimit({
|
||||||
|
...RATE_LIMITS.SHARE_PUBLIC,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skip: () => isTest,
|
||||||
|
message: { success: false, error: 'Too many share requests, please try again later' },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Don't crash on missing dep in a bare-bones env — but log so it's not
|
||||||
|
// invisible if production misconfigured.
|
||||||
|
if (log && typeof log.warn === 'function') {
|
||||||
|
log.warn({ ctx: 'share-routes', err: e.message }, 'express-rate-limit unavailable; share public endpoints have NO rate limit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/share/:token/preview', _sharePublicLimiter, asyncHandler(async (req, res) => {
|
||||||
const meta = await shareStore.peek(req.params.token);
|
const meta = await shareStore.peek(req.params.token);
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
return res.status(404).json({ success: false, error: '[DC-553] share not found or expired' });
|
return res.status(404).json({ success: false, error: '[DC-553] share not found or expired' });
|
||||||
@@ -310,12 +342,21 @@ module.exports = function shareRoutesFactory({
|
|||||||
});
|
});
|
||||||
}, 'share-preview'));
|
}, 'share-preview'));
|
||||||
|
|
||||||
router.post('/share/:token/subscribe', asyncHandler(async (req, res) => {
|
router.post('/share/:token/subscribe', _sharePublicLimiter, asyncHandler(async (req, res) => {
|
||||||
|
// DC-083: replace the primitive `email.includes('@')` check with a
|
||||||
|
// charset/length/control-char-bounded validator. The pre-fix code
|
||||||
|
// accepted `@`, `a@`, `<script>@x.c`, and 10MB strings as "valid email".
|
||||||
|
// The subscribe body's `email` is now also captured to the share record
|
||||||
|
// (capped to last 8 entries, see share-store recordPublicSubscribe) so
|
||||||
|
// the operator can see who subscribed.
|
||||||
const { email } = req.body || {};
|
const { email } = req.body || {};
|
||||||
if (!email || typeof email !== 'string' || !email.includes('@')) {
|
let normalizedEmail = null;
|
||||||
throw new ValidationError('valid email required', 'email');
|
if (email !== undefined && email !== null) {
|
||||||
|
const v = validatePublicEmail(email);
|
||||||
|
if (!v.ok) throw new ValidationError(v.reason, 'email');
|
||||||
|
normalizedEmail = v.email;
|
||||||
}
|
}
|
||||||
const result = await shareStore.recordPublicSubscribe(req.params.token);
|
const result = await shareStore.recordPublicSubscribe(req.params.token, { email: normalizedEmail });
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
if (result.reason === 'not_found') throw new NotFoundError('share not found');
|
if (result.reason === 'not_found') throw new NotFoundError('share not found');
|
||||||
throw new ValidationError(result.reason, 'share');
|
throw new ValidationError(result.reason, 'share');
|
||||||
@@ -323,12 +364,23 @@ module.exports = function shareRoutesFactory({
|
|||||||
res.json({ success: true, data: { count: result.count, cap: result.cap } });
|
res.json({ success: true, data: { count: result.count, cap: result.cap } });
|
||||||
}, 'share-subscribe'));
|
}, 'share-subscribe'));
|
||||||
|
|
||||||
router.post('/share/:token/redeem-tailscale', asyncHandler(async (req, res) => {
|
router.post('/share/:token/redeem-tailscale', _sharePublicLimiter, asyncHandler(async (req, res) => {
|
||||||
|
// DC-083: replace the bare `typeof deviceId === 'string'` check with a
|
||||||
|
// charset/length/control-char-bounded validator. The pre-fix code
|
||||||
|
// accepted arbitrary strings of any length — including CR/LF/NUL,
|
||||||
|
// which flow into the Tailscale auth-key description string in
|
||||||
|
// POST /share/tailscale (routes/share.js:213 in the issue path).
|
||||||
|
// The redeem-tailscale path receives the deviceId from Caddy's
|
||||||
|
// forward_auth (a Tailscale machine ID), which is base64url +
|
||||||
|
// hyphens — well within the validator's charset.
|
||||||
const { deviceId } = req.body || {};
|
const { deviceId } = req.body || {};
|
||||||
if (!deviceId || typeof deviceId !== 'string') {
|
let normalizedDeviceId = null;
|
||||||
throw new ValidationError('deviceId required', 'deviceId');
|
if (deviceId !== undefined && deviceId !== null) {
|
||||||
|
const v = validatePublicDeviceId(deviceId);
|
||||||
|
if (!v.ok) throw new ValidationError(v.reason, 'deviceId');
|
||||||
|
normalizedDeviceId = v.deviceId;
|
||||||
}
|
}
|
||||||
const result = await shareStore.recordTailscaleUse(req.params.token, { deviceId });
|
const result = await shareStore.recordTailscaleUse(req.params.token, { deviceId: normalizedDeviceId });
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
if (result.reason === 'not_found') throw new NotFoundError('share not found');
|
if (result.reason === 'not_found') throw new NotFoundError('share not found');
|
||||||
throw new ValidationError(result.reason, 'share');
|
throw new ValidationError(result.reason, 'share');
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ const { CADDY, REGEX, LIMITS } = require('../src/utilities/constants');
|
|||||||
const { ValidationError, ConflictError, NotFoundError } = require('../src/utilities/errors');
|
const { ValidationError, ConflictError, NotFoundError } = require('../src/utilities/errors');
|
||||||
const { validateURL } = require('../src/security/input-validator');
|
const { validateURL } = require('../src/security/input-validator');
|
||||||
const { ok, successMessage } = require('../src/utils/responses');
|
const { ok, successMessage } = require('../src/utils/responses');
|
||||||
|
// DC-074: SSRF defense — reject upstream hosts that resolve to
|
||||||
|
// private/reserved ranges before they reach the Caddyfile.
|
||||||
|
const { validateUpstream } = require('../src/utilities/fleet-validation');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sites route factory
|
* Sites route factory
|
||||||
@@ -166,8 +169,25 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a
|
|||||||
if (!domain || !upstream) throw new ValidationError('Domain and upstream are required');
|
if (!domain || !upstream) throw new ValidationError('Domain and upstream are required');
|
||||||
if (!REGEX.DOMAIN.test(domain)) throw new ValidationError('[DC-301] Invalid domain format');
|
if (!REGEX.DOMAIN.test(domain)) throw new ValidationError('[DC-301] Invalid domain format');
|
||||||
|
|
||||||
const upstreamRegex = /^[a-z0-9.-]+:\d{1,5}$/i;
|
// DC-074: SSRF defense — reject upstreams that resolve to private/
|
||||||
if (!upstreamRegex.test(upstream)) throw new ValidationError('Invalid upstream format. Use host:port');
|
// reserved ranges BEFORE we write them into the Caddyfile. Without
|
||||||
|
// this, an authenticated dashboard operator can call POST /api/v1/site
|
||||||
|
// with `upstream: '10.0.0.1:80'` and end up with a Caddy site block
|
||||||
|
// that proxies public traffic to an internal host. Caddy runs on
|
||||||
|
// DNS2 (same network as the targets), so the SSRF lands.
|
||||||
|
//
|
||||||
|
// The existing upstreamRegex /^[a-z0-9.-]+:\d{1,5}$/i only checks
|
||||||
|
// charset — it happily accepts 192.168.1.1:80 and 169.254.169.254:80
|
||||||
|
// (the AWS metadata IP). validateUpstream() also does a DNS lookup
|
||||||
|
// for hostnames so a malicious operator can't sneak a public-looking
|
||||||
|
// domain past the gate and have it resolve to a private IP later.
|
||||||
|
const upstreamCheck = await validateUpstream(upstream);
|
||||||
|
if (!upstreamCheck.ok) {
|
||||||
|
// Don't echo attacker-supplied hostnames in the audit log; keep the
|
||||||
|
// canonical code + message but never write the raw value.
|
||||||
|
log?.warn?.('site', 'POST /site rejected by SSRF gate', { code: upstreamCheck.code });
|
||||||
|
throw new ValidationError(`[DC-074] ${upstreamCheck.message} (set SITES_ALLOW_PRIVATE_UPSTREAMS=true to opt in)`);
|
||||||
|
}
|
||||||
|
|
||||||
const content = await caddy.read();
|
const content = await caddy.read();
|
||||||
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
@@ -199,12 +219,40 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a
|
|||||||
throw new ValidationError('[DC-301] Invalid subdomain format');
|
throw new ValidationError('[DC-301] Invalid subdomain format');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DC-074: SSRF defense — validate the URL syntax via validateURL() (catches
|
||||||
|
// non-http(s) schemes, malformed URLs) AND validateUpstream() (catches
|
||||||
|
// every private/reserved range including CGNAT, multicast, TEST-NET
|
||||||
|
// ranges that validateURL's isPrivateIP() regex misses).
|
||||||
|
//
|
||||||
|
// We intentionally do NOT pass `blockPrivate: true` to validateURL()
|
||||||
|
// here — that's handled by validateUpstream() below, which honors the
|
||||||
|
// SITES_ALLOW_PRIVATE_UPSTREAMS opt-in. validateURL's blockPrivate path
|
||||||
|
// is a hard reject with no escape hatch, which would force operators
|
||||||
|
// who intentionally proxy to a private target to remove validation
|
||||||
|
// entirely.
|
||||||
try {
|
try {
|
||||||
validateURL(externalUrl);
|
validateURL(externalUrl);
|
||||||
} catch (validationErr) {
|
} catch (validationErr) {
|
||||||
throw new ValidationError(validationErr.message);
|
throw new ValidationError(validationErr.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DC-074: validateUpstream() does the same rigorous private-IP check
|
||||||
|
// fleet-validation shipped for DC-068, with full CGNAT / multicast /
|
||||||
|
// broadcast / 0.0.0.0 / TEST-NET / benchmark range coverage and a DNS
|
||||||
|
// resolution step for hostnames (rebinding defense).
|
||||||
|
let parsedExternalUrl;
|
||||||
|
try {
|
||||||
|
parsedExternalUrl = new URL(externalUrl);
|
||||||
|
} catch (_) {
|
||||||
|
// validateURL() above already gates URL syntax — unreachable.
|
||||||
|
throw new ValidationError('Invalid external URL');
|
||||||
|
}
|
||||||
|
const externalCheck = await validateUpstream(`${parsedExternalUrl.hostname}:${parsedExternalUrl.port || (parsedExternalUrl.protocol === 'https:' ? '443' : '80')}`);
|
||||||
|
if (!externalCheck.ok) {
|
||||||
|
log?.warn?.('site', 'POST /site/external rejected by SSRF gate', { code: externalCheck.code });
|
||||||
|
throw new ValidationError(`[DC-074] ${externalCheck.message} (set SITES_ALLOW_PRIVATE_UPSTREAMS=true to opt in)`);
|
||||||
|
}
|
||||||
|
|
||||||
const domain = buildDomain(subdomain);
|
const domain = buildDomain(subdomain);
|
||||||
let dnsWarning = null;
|
let dnsWarning = null;
|
||||||
|
|
||||||
|
|||||||
@@ -41,12 +41,124 @@
|
|||||||
*
|
*
|
||||||
* DELETE /api/v1/tailscale/admin/devices/:id
|
* DELETE /api/v1/tailscale/admin/devices/:id
|
||||||
* Revokes a device from the tailnet.
|
* Revokes a device from the tailnet.
|
||||||
|
*
|
||||||
|
* # DC-080 input validation
|
||||||
|
*
|
||||||
|
* Three coupled gaps in the route layer pre-fix:
|
||||||
|
*
|
||||||
|
* (a) PUT /settings validated `apiToken.startsWith('tskey-api-')` but had
|
||||||
|
* no length cap — body-parser limit was the only ceiling. A 1 MB
|
||||||
|
* string starting with `tskey-api-` would be `.trim()`-ed, sent to
|
||||||
|
* Tailscale's /devices endpoint, and waste server-side CPU on a
|
||||||
|
* request that will always 401.
|
||||||
|
* (b) POST /settings/test accepted `apiToken` from the body with NO
|
||||||
|
* validation at all. The PUT route's prefix check is bypassed on
|
||||||
|
* the test path — an operator could submit any string and have the
|
||||||
|
* container ping Tailscale's API with it (low impact, but inconsistent
|
||||||
|
* with PUT and surfaces fingerprinting via the 401 timing).
|
||||||
|
* (c) POST /admin/keys validated `tags` as Array but NOT per-element
|
||||||
|
* type — `tags: ['tag:guest', null, 123, {injection: true}]` would be
|
||||||
|
* forwarded to Tailscale verbatim. Tailscale's API is JSON-strict
|
||||||
|
* and would 400 the request, but the bad shape reached the wire.
|
||||||
|
* Similarly `description` had no length cap (Tailscale caps at 120
|
||||||
|
* chars per their docs).
|
||||||
|
*
|
||||||
|
* All three are gated by TOTP — this is a logged-in-operator / phished-
|
||||||
|
* session threat surface, not anonymous-unauth. The fix is defense-in-
|
||||||
|
* depth: a bug in the auth path (TOTP bypass, session theft, future
|
||||||
|
* route handler trust-boundary drift) should not turn these endpoints
|
||||||
|
* into a "submit anything and forward to Tailscale" relay.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { ok, errorResponse } = require('../src/utils/responses');
|
const { ok, errorResponse } = require('../src/utils/responses');
|
||||||
const { TailscaleCoordError } = require('../src/managers/tailscale-coord');
|
const { TailscaleCoordError } = require('../src/managers/tailscale-coord');
|
||||||
|
|
||||||
|
// DC-080: shared validation helpers for the Tailscale admin surface.
|
||||||
|
// Tailscale API tokens follow the form `tskey-<kind>-<opaque>` where
|
||||||
|
// `<kind>` is one of a small set of values (`api`, `auth`, `partner`,
|
||||||
|
// `cli`). Real tokens observed in the wild are 40..80 chars; we cap at
|
||||||
|
// 256 to leave headroom for future Tailscale key formats without giving
|
||||||
|
// an unbounded buffer to validate+forward.
|
||||||
|
const TAILSCALE_TOKEN_PREFIX = 'tskey-api-';
|
||||||
|
const TAILSCALE_TOKEN_MAX_LEN = 256;
|
||||||
|
const TAG_KEY_MAX_LEN = 64;
|
||||||
|
const TAGS_MAX_LEN = 32;
|
||||||
|
const DESCRIPTION_MAX_LEN = 120;
|
||||||
|
|
||||||
|
// Tailscale tags are lowercased identifiers with optional colons
|
||||||
|
// (e.g. `tag:server`, `tag:guest-plex`). Reject whitespace, CR/LF,
|
||||||
|
// control chars, JSON metacharacters, and any character that could
|
||||||
|
// enable header-injection through the Tailscale coord client.
|
||||||
|
//
|
||||||
|
// DC-080 round-2 polish: Tailscale's tag spec requires `tag:` followed by
|
||||||
|
// ≥1 identifier char — bare `tag:` (empty name) is rejected by their API.
|
||||||
|
// We split the pattern in two so the error message names which form failed
|
||||||
|
// instead of dumping a generic regex.
|
||||||
|
const TAG_KEY_RE = /^tag:[a-z0-9][a-z0-9_-]{0,62}$/;
|
||||||
|
|
||||||
|
function _validateApiToken(token, fieldName = 'apiToken') {
|
||||||
|
if (typeof token !== 'string' || !token) {
|
||||||
|
return `${fieldName} is required and must be a string`;
|
||||||
|
}
|
||||||
|
if (!token.startsWith(TAILSCALE_TOKEN_PREFIX)) {
|
||||||
|
return `${fieldName} must start with ${TAILSCALE_TOKEN_PREFIX}`;
|
||||||
|
}
|
||||||
|
if (token.length > TAILSCALE_TOKEN_MAX_LEN) {
|
||||||
|
return `${fieldName} exceeds maximum length of ${TAILSCALE_TOKEN_MAX_LEN} characters`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _validateTags(tags) {
|
||||||
|
if (tags === undefined || tags === null) return null;
|
||||||
|
if (!Array.isArray(tags)) {
|
||||||
|
return 'tags must be an array of strings';
|
||||||
|
}
|
||||||
|
if (tags.length > TAGS_MAX_LEN) {
|
||||||
|
return `tags exceeds maximum length of ${TAGS_MAX_LEN} entries`;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < tags.length; i += 1) {
|
||||||
|
const t = tags[i];
|
||||||
|
if (typeof t !== 'string' || !t) {
|
||||||
|
return `tags[${i}] must be a non-empty string`;
|
||||||
|
}
|
||||||
|
if (t.length > TAG_KEY_MAX_LEN) {
|
||||||
|
return `tags[${i}] exceeds maximum length of ${TAG_KEY_MAX_LEN} characters`;
|
||||||
|
}
|
||||||
|
if (!TAG_KEY_RE.test(t)) {
|
||||||
|
return `tags[${i}] must match ${TAG_KEY_RE} (lowercase alnum + :_-)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _validateDescription(description) {
|
||||||
|
if (description === undefined || description === null) return null;
|
||||||
|
if (typeof description !== 'string') {
|
||||||
|
return 'description must be a string';
|
||||||
|
}
|
||||||
|
if (description.length > DESCRIPTION_MAX_LEN) {
|
||||||
|
return `description exceeds maximum length of ${DESCRIPTION_MAX_LEN} characters`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exported for direct unit testing in __tests__/routes/tailscale-admin.test.js
|
||||||
|
// (the validator functions are otherwise unreachable from outside the factory
|
||||||
|
// closure; direct tests assert edge cases without supertest overhead).
|
||||||
|
const _validators = {
|
||||||
|
validateApiToken: _validateApiToken,
|
||||||
|
validateTags: _validateTags,
|
||||||
|
validateDescription: _validateDescription,
|
||||||
|
TAILSCALE_TOKEN_PREFIX,
|
||||||
|
TAILSCALE_TOKEN_MAX_LEN,
|
||||||
|
TAG_KEY_MAX_LEN,
|
||||||
|
TAGS_MAX_LEN,
|
||||||
|
DESCRIPTION_MAX_LEN,
|
||||||
|
TAG_KEY_RE,
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = function({
|
module.exports = function({
|
||||||
tailscaleCoord,
|
tailscaleCoord,
|
||||||
asyncHandler,
|
asyncHandler,
|
||||||
@@ -75,9 +187,12 @@ module.exports = function({
|
|||||||
|
|
||||||
router.put('/settings', asyncHandler(async (req, res) => {
|
router.put('/settings', asyncHandler(async (req, res) => {
|
||||||
const token = req.body && req.body.apiToken;
|
const token = req.body && req.body.apiToken;
|
||||||
if (!token || typeof token !== 'string' || !token.startsWith('tskey-api-')) {
|
// DC-080: validate prefix + length cap. The pre-fix code only checked
|
||||||
return errorResponse(res, 400, 'Invalid API token (must start with tskey-api-)');
|
// the prefix — a 1 MB string starting with `tskey-api-` would have been
|
||||||
}
|
// sent to Tailscale's /devices endpoint and wasted server-side CPU
|
||||||
|
// before the inevitable 401.
|
||||||
|
const tokenErr = _validateApiToken(token);
|
||||||
|
if (tokenErr) return errorResponse(res, 400, tokenErr);
|
||||||
|
|
||||||
// Validate before storing
|
// Validate before storing
|
||||||
const client = new (require('../src/managers/tailscale-coord').TailscaleCoordClient)({ apiToken: token });
|
const client = new (require('../src/managers/tailscale-coord').TailscaleCoordClient)({ apiToken: token });
|
||||||
@@ -130,6 +245,17 @@ module.exports = function({
|
|||||||
|
|
||||||
router.post('/settings/test', asyncHandler(async (req, res) => {
|
router.post('/settings/test', asyncHandler(async (req, res) => {
|
||||||
const token = (req.body && req.body.apiToken) || null;
|
const token = (req.body && req.body.apiToken) || null;
|
||||||
|
// DC-080: validate any caller-provided token before it reaches the
|
||||||
|
// Tailscale API. Pre-fix the test endpoint accepted any string — the
|
||||||
|
// PUT route's prefix check did NOT extend to this path. An operator
|
||||||
|
// could submit arbitrary junk and the container would still call
|
||||||
|
// /devices on the Tailscale API with it (DoS-reflection + fingerprint
|
||||||
|
// timing for a future attacker probing whether this API token format
|
||||||
|
// is accepted at all).
|
||||||
|
if (token !== null && token !== undefined) {
|
||||||
|
const tokenErr = _validateApiToken(token);
|
||||||
|
if (tokenErr) return errorResponse(res, 400, tokenErr);
|
||||||
|
}
|
||||||
const client = await tailscaleCoord.getClient();
|
const client = await tailscaleCoord.getClient();
|
||||||
if (token) {
|
if (token) {
|
||||||
// Caller provided a fresh token to test — don't save it
|
// Caller provided a fresh token to test — don't save it
|
||||||
@@ -214,10 +340,16 @@ module.exports = function({
|
|||||||
return errorResponse(res, 503, 'Tailscale API token not configured');
|
return errorResponse(res, 503, 'Tailscale API token not configured');
|
||||||
}
|
}
|
||||||
const opts = req.body || {};
|
const opts = req.body || {};
|
||||||
// Reject obviously-bad input early
|
// Reject obviously-bad input early.
|
||||||
if (opts.tags && !Array.isArray(opts.tags)) {
|
// DC-080: pre-fix the route only checked `Array.isArray(opts.tags)`.
|
||||||
return errorResponse(res, 400, 'tags must be an array of strings');
|
// A `tags: ['tag:guest', null, 123, {injection: true}]` payload would
|
||||||
}
|
// be forwarded to Tailscale verbatim — Tailscale's API is JSON-strict
|
||||||
|
// and would 400 the request, but the bad shape reached the wire and
|
||||||
|
// would silently pass through the dashboard's JSON.stringify() flow.
|
||||||
|
const tagsErr = _validateTags(opts.tags);
|
||||||
|
if (tagsErr) return errorResponse(res, 400, tagsErr);
|
||||||
|
const descErr = _validateDescription(opts.description);
|
||||||
|
if (descErr) return errorResponse(res, 400, descErr);
|
||||||
if (opts.expirySeconds !== undefined && (!Number.isInteger(opts.expirySeconds) || opts.expirySeconds <= 0)) {
|
if (opts.expirySeconds !== undefined && (!Number.isInteger(opts.expirySeconds) || opts.expirySeconds <= 0)) {
|
||||||
return errorResponse(res, 400, 'expirySeconds must be a positive integer');
|
return errorResponse(res, 400, 'expirySeconds must be a positive integer');
|
||||||
}
|
}
|
||||||
@@ -254,4 +386,10 @@ module.exports = function({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// DC-080: validators exported for direct unit testing in
|
||||||
|
// __tests__/routes/tailscale-admin.test.js — the route factory closes
|
||||||
|
// over the same functions, so the validators are exercised end-to-end via
|
||||||
|
// supertest AND in isolation here.
|
||||||
|
module.exports._validators = _validators;
|
||||||
@@ -31,6 +31,17 @@ module.exports = {
|
|||||||
CADDY_ADMIN_URL,
|
CADDY_ADMIN_URL,
|
||||||
SERVICES_FILE,
|
SERVICES_FILE,
|
||||||
SERVICES_DIR,
|
SERVICES_DIR,
|
||||||
|
// Re-export the resolved data directory so other modules (notably
|
||||||
|
// src/utilities/nesting-guard.js) can locate `/app/data` without having to
|
||||||
|
// also require('../../platform-paths') — keeps a single source of truth for
|
||||||
|
// the data dir on the src/config/paths surface. Without this, `dataDir`
|
||||||
|
// resolves to `undefined`, and `path.join(undefined, 'data')` throws
|
||||||
|
// `TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type
|
||||||
|
// string. Received undefined` at startup (DC-077 fingerprint). Fall back to
|
||||||
|
// platformPaths.dataDir if SERVICES_DIR is somehow not a string (defensive —
|
||||||
|
// SERVICES_DIR is computed from a path.dirname() of a string so it always
|
||||||
|
// is, but the cost of guarding is one branch).
|
||||||
|
dataDir: typeof SERVICES_DIR === 'string' && SERVICES_DIR ? SERVICES_DIR : platformPaths.dataDir,
|
||||||
CONFIG_FILE,
|
CONFIG_FILE,
|
||||||
DNS_CREDENTIALS_FILE,
|
DNS_CREDENTIALS_FILE,
|
||||||
TAILSCALE_CONFIG_FILE,
|
TAILSCALE_CONFIG_FILE,
|
||||||
|
|||||||
@@ -18,6 +18,30 @@ const UPDATE_CONFIG_FILE = process.env.UPDATE_CONFIG_FILE || path.join(platformP
|
|||||||
const UPDATE_HISTORY_FILE = process.env.UPDATE_HISTORY_FILE || path.join(platformPaths.dataDir, 'update-history.json');
|
const UPDATE_HISTORY_FILE = process.env.UPDATE_HISTORY_FILE || path.join(platformPaths.dataDir, 'update-history.json');
|
||||||
const CHECK_INTERVAL = parseInt(process.env.UPDATE_CHECK_INTERVAL || '3600000', 10); // 1 hour
|
const CHECK_INTERVAL = parseInt(process.env.UPDATE_CHECK_INTERVAL || '3600000', 10); // 1 hour
|
||||||
|
|
||||||
|
// DC-078: registry probe reliability knobs. The container's /etc/resolv.conf points
|
||||||
|
// at Technitium (100.121.150.22) which sometimes returns a mix of A and AAAA
|
||||||
|
// records even when the host's IPv6 path to public registries (Docker Hub,
|
||||||
|
// ghcr.io) is broken or slow. Without `family: 4` Node defaults to dual-stack,
|
||||||
|
// every `https.request` to a registry races dual-stack DNS and stalls 30+ seconds
|
||||||
|
// per ENETUNREACH on the unreachable family. Without an explicit request timeout
|
||||||
|
// the entire `checkForUpdates()` loop (5+ containers) blocks for minutes per
|
||||||
|
// tick — visible in error.log as AggregateError [ETIMEDOUT] with a stack like
|
||||||
|
// `at internalConnectMultiple (node:net:1114:18)`.
|
||||||
|
//
|
||||||
|
// TUNABLES — keep conservative; the digest check is a background poll, not
|
||||||
|
// user-facing. Worst-case latency per query:
|
||||||
|
// 1st attempt: REGISTRY_REQUEST_TIMEOUT_MS (10s)
|
||||||
|
// 1st retry : REGISTRY_RETRY_BACKOFF_MS + REGISTRY_REQUEST_TIMEOUT_MS (10.5s)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// per-container ceiling: 20.5s (REGISTRY_MAX_RETRIES=1)
|
||||||
|
const REGISTRY_REQUEST_TIMEOUT_MS = 10000; // hard per-request socket timeout
|
||||||
|
const REGISTRY_MAX_RETRIES = 1; // extra attempts after first failure
|
||||||
|
const REGISTRY_RETRY_BACKOFF_MS = 500; // delay before retry (transient blips)
|
||||||
|
const REGISTRY_TRANSIENT_ERROR_CODES = new Set([
|
||||||
|
'ETIMEDOUT', 'ENOTFOUND', 'ENETUNREACH', 'ECONNRESET', 'EAI_AGAIN',
|
||||||
|
'EPIPE', 'ECONNREFUSED', 'EHOSTUNREACH',
|
||||||
|
]);
|
||||||
|
|
||||||
class UpdateManager extends EventEmitter {
|
class UpdateManager extends EventEmitter {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -144,12 +168,39 @@ class UpdateManager extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get latest image digest from registry
|
* Get latest image digest from registry
|
||||||
|
*
|
||||||
|
* DC-082: when the image name is a docker-compose prefixed name like
|
||||||
|
* `dashcaddy-dashcaddy-api:latest` (single hyphen-separated, no slash),
|
||||||
|
* the existing code normalized it to `library/dashcaddy-dashcaddy-api`
|
||||||
|
* before probing Docker Hub. The actual upstream namespace for a
|
||||||
|
* compose-prefixed image is `<project>/<service>` (with slash) — Docker
|
||||||
|
* Compose hyphenates the project name and service name when tagging
|
||||||
|
* locally. The pre-fix code probed the wrong repo, Docker Hub returned
|
||||||
|
* HTTP 401 (the repo doesn't exist), and the error log showed
|
||||||
|
* `Docker Hub registry returned HTTP 401 after auth` on every restart
|
||||||
|
* for the local dashcaddy-api image. The fix: split on the FIRST hyphen
|
||||||
|
* for compose-prefixed names so the lookup targets the correct
|
||||||
|
* namespace.
|
||||||
|
*
|
||||||
|
* Compose-prefixed shape: `^[a-z0-9]+-[a-z0-9][a-z0-9_-]*$` (no slash,
|
||||||
|
* lowercase, both halves non-empty). Examples:
|
||||||
|
* dashcaddy-dashcaddy-api -> dashcaddy/dashcaddy-api
|
||||||
|
* myproject-myservice -> myproject/myservice
|
||||||
|
* nginx -> library/nginx (official, unchanged)
|
||||||
|
* library/nginx -> library/nginx (official, unchanged)
|
||||||
|
* dashcaddy/some-image -> dashcaddy/some-image (already has slash)
|
||||||
|
* ghcr.io/x/y -> ghcr.io/x/y (handled below)
|
||||||
*/
|
*/
|
||||||
async getLatestImageDigest(imageName) {
|
async getLatestImageDigest(imageName) {
|
||||||
|
// DC-082: declare `remainder` at the function scope so the catch block
|
||||||
|
// can classify the error against the image-name shape (compose-prefixed
|
||||||
|
// local images produce a steady-state 401 that should log as info, not
|
||||||
|
// error).
|
||||||
|
let remainder = imageName;
|
||||||
try {
|
try {
|
||||||
// Parse image name — strip any leading registry host first
|
// Parse image name — strip any leading registry host first
|
||||||
let imageTag = 'latest';
|
let imageTag = 'latest';
|
||||||
let remainder = imageName;
|
remainder = imageName;
|
||||||
const lastColon = imageName.lastIndexOf(':');
|
const lastColon = imageName.lastIndexOf(':');
|
||||||
// Only treat as tag if the colon is AFTER the last slash (avoids `ghcr.io:443/...`)
|
// Only treat as tag if the colon is AFTER the last slash (avoids `ghcr.io:443/...`)
|
||||||
const lastSlash = imageName.lastIndexOf('/');
|
const lastSlash = imageName.lastIndexOf('/');
|
||||||
@@ -163,8 +214,19 @@ class UpdateManager extends EventEmitter {
|
|||||||
return await this.getGhcrDigest(remainder, imageTag);
|
return await this.getGhcrDigest(remainder, imageTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Docker Hub images (library/nginx OR org/image with single slash)
|
// Docker Hub images (library/nginx OR org/image with single slash).
|
||||||
if (!remainder.includes('/') || remainder.split('/').length === 2) {
|
// Special-case docker-compose prefixed names (single hyphen, no slash,
|
||||||
|
// lowercase) — split on the FIRST hyphen to recover the original
|
||||||
|
// `<project>/<service>` namespace. See DC-082.
|
||||||
|
if (!remainder.includes('/')) {
|
||||||
|
const composeRepo = this._composeProjectToRepo(remainder);
|
||||||
|
if (composeRepo) {
|
||||||
|
return await this.getDockerHubDigest(composeRepo, imageTag);
|
||||||
|
}
|
||||||
|
// Not a compose-prefixed name — fall through to the library/ default
|
||||||
|
return await this.getDockerHubDigest(remainder, imageTag);
|
||||||
|
}
|
||||||
|
if (remainder.split('/').length === 2) {
|
||||||
return await this.getDockerHubDigest(remainder, imageTag);
|
return await this.getDockerHubDigest(remainder, imageTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,96 +234,278 @@ class UpdateManager extends EventEmitter {
|
|||||||
log.warn('update', 'Custom registry not yet supported', { remainder });
|
log.warn('update', 'Custom registry not yet supported', { remainder });
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// DC-082: a "registry returned HTTP 401 after auth" against a
|
||||||
|
// compose-prefixed local image is the steady-state when the image
|
||||||
|
// is built locally and the upstream namespace on Docker Hub
|
||||||
|
// doesn't exist (or is private). The token endpoint returns 200
|
||||||
|
// with an empty-access JWT, and the authed manifest GET 401s.
|
||||||
|
// Log these as a clean info not-found line instead of an error
|
||||||
|
// so dashboards and PagerDuty don't fire on every restart.
|
||||||
|
if (this._isNotPublishedError(error, remainder)) {
|
||||||
|
log.info('update', 'No upstream registry image — skipping update check', { imageName, remainder });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
log.error('update', error, null, { imageName });
|
log.error('update', error, null, { imageName });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DC-082: split a docker-compose prefixed image name on the FIRST hyphen
|
||||||
|
* to recover the original `<project>/<service>` namespace. Returns null
|
||||||
|
* for names that don't match the compose-prefixed shape — callers fall
|
||||||
|
* through to the standard library/-prefixed official-image path.
|
||||||
|
*
|
||||||
|
* Compose-prefixed shape:
|
||||||
|
* - Contains exactly one or more hyphens
|
||||||
|
* - No slash
|
||||||
|
* - Lowercase letters / digits / hyphens / underscores only
|
||||||
|
* - Both halves (before first hyphen, after first hyphen) are non-empty
|
||||||
|
* - First char is a letter or digit (not a hyphen)
|
||||||
|
*/
|
||||||
|
_composeProjectToRepo(remainder) {
|
||||||
|
if (typeof remainder !== 'string' || remainder.length === 0) return null;
|
||||||
|
if (remainder.includes('/')) return null; // already namespaced
|
||||||
|
if (!/^[a-z0-9][a-z0-9_-]*-[a-z0-9][a-z0-9_-]*$/i.test(remainder)) {
|
||||||
|
// Not a compose-prefixed name — let the library/ path handle it
|
||||||
|
// (this is the official-image path: e.g. `nginx`, `alpine`).
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const firstHyphen = remainder.indexOf('-');
|
||||||
|
// Defensive: indexOf must find a hyphen (regex requires it), but guard
|
||||||
|
// against any future regex drift.
|
||||||
|
if (firstHyphen <= 0 || firstHyphen === remainder.length - 1) return null;
|
||||||
|
const project = remainder.substring(0, firstHyphen);
|
||||||
|
const service = remainder.substring(firstHyphen + 1);
|
||||||
|
if (!project || !service) return null;
|
||||||
|
return `${project}/${service}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DC-082: detect the "registry returned 401 after auth" pattern that
|
||||||
|
* signals "this image has no public upstream on Docker Hub" (as opposed
|
||||||
|
* to a genuine auth failure or transient network error). Steady-state
|
||||||
|
* for compose-prefixed local images that aren't published.
|
||||||
|
*/
|
||||||
|
_isNotPublishedError(error, remainder) {
|
||||||
|
if (!error || typeof error.message !== 'string') return false;
|
||||||
|
if (!error.message.includes('HTTP 401')) return false;
|
||||||
|
// Constrain to the compose-prefixed path — a real auth failure on a
|
||||||
|
// legitimate `library/foo` or `namespace/foo` probe should still log
|
||||||
|
// as an error (it never auto-heals).
|
||||||
|
if (typeof remainder !== 'string' || remainder.includes('/')) return false;
|
||||||
|
if (!/^[a-z0-9][a-z0-9_-]*-[a-z0-9][a-z0-9_-]*$/i.test(remainder)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get image digest from GitHub Container Registry (ghcr.io)
|
* Get image digest from GitHub Container Registry (ghcr.io)
|
||||||
* Public images are tokenless via the registry-1.docker.io-style bearer flow,
|
* Public images are tokenless via the registry-1.docker.io-style bearer flow,
|
||||||
* but using ghcr.io's own auth endpoint.
|
* but using ghcr.io's own auth endpoint.
|
||||||
|
*
|
||||||
|
* DC-078: hardened — `family: 4` to avoid the dual-stack DNS race when the
|
||||||
|
* host's IPv6 path is unreachable (was producing AggregateError [ETIMEDOUT] in
|
||||||
|
* error.log every check cycle). Hard request timeout caps each attempt.
|
||||||
*/
|
*/
|
||||||
async getGhcrDigest(repository, tag) {
|
async getGhcrDigest(repository, tag) {
|
||||||
// ghcr.io uses the same OCI distribution spec as Docker Hub
|
// ghcr.io uses the same OCI distribution spec as Docker Hub
|
||||||
const imageRepo = repository.replace(/^ghcr\.io\//, '');
|
const imageRepo = repository.replace(/^ghcr\.io\//, '');
|
||||||
|
const res = await this.fetchWithReliability({
|
||||||
|
hostname: 'ghcr.io',
|
||||||
|
path: `/v2/${imageRepo}/manifests/${tag}`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/vnd.docker.distribution.manifest.v2+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.headers['docker-content-digest'] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get image digest from Docker Hub
|
||||||
|
*
|
||||||
|
* DC-078: hardened — see getGhcrDigest comment. Resolves a 401 → token via
|
||||||
|
* `fetchAuthToken`, which itself is wrapped in the same retry + IPv4-only +
|
||||||
|
* timeout policy via `fetchWithReliability`.
|
||||||
|
*/
|
||||||
|
async getDockerHubDigest(repository, tag) {
|
||||||
|
// Normalize repository name
|
||||||
|
const repo = repository.includes('/') ? repository : `library/${repository}`;
|
||||||
|
const firstAttempt = await this.fetchWithReliability({
|
||||||
|
hostname: 'registry-1.docker.io',
|
||||||
|
path: `/v2/${repo}/manifests/${tag}`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (firstAttempt.statusCode !== 401) {
|
||||||
|
if (firstAttempt.statusCode < 200 || firstAttempt.statusCode >= 300) {
|
||||||
|
throw new Error(`Docker Hub registry returned HTTP ${firstAttempt.statusCode}`);
|
||||||
|
}
|
||||||
|
return firstAttempt.headers['docker-content-digest'] || null;
|
||||||
|
}
|
||||||
|
// 401 → acquire a Bearer token via the WWW-Authenticate realm, then retry once.
|
||||||
|
const authHeader = firstAttempt.headers['www-authenticate'];
|
||||||
|
const authUrl = this.parseAuthHeader(authHeader);
|
||||||
|
if (!authUrl) {
|
||||||
|
throw new Error('Authentication required but no auth URL found');
|
||||||
|
}
|
||||||
|
const token = await this.fetchAuthToken(authUrl);
|
||||||
|
const authed = await this.fetchWithReliability({
|
||||||
|
hostname: 'registry-1.docker.io',
|
||||||
|
path: `/v2/${repo}/manifests/${tag}`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/vnd.docker.distribution.manifest.v2+json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (authed.statusCode < 200 || authed.statusCode >= 300) {
|
||||||
|
throw new Error(`Docker Hub registry returned HTTP ${authed.statusCode} after auth`);
|
||||||
|
}
|
||||||
|
return authed.headers['docker-content-digest'] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single hardened HTTPS probe — DC-078.
|
||||||
|
*
|
||||||
|
* Reliability properties:
|
||||||
|
* 1. `family: 4` — IPv4-only DNS lookup. Avoids dual-stack races where a
|
||||||
|
* single unreachable IPv6 destination consumes the default 30-second
|
||||||
|
* connect timeout before the IPv4 fallback succeeds (manifested in
|
||||||
|
* error.log as AggregateError [ETIMEDOUT] with `at internalConnectMultiple`).
|
||||||
|
* 2. Hard per-request timeout (REGISTRY_REQUEST_TIMEOUT_MS) — caps total
|
||||||
|
* latency for any single probe attempt.
|
||||||
|
* 3. Retry on transient network errors (REGISTRY_TRANSIENT_ERROR_CODES)
|
||||||
|
* with REGISTRY_RETRY_BACKOFF_MS delay between attempts. Does NOT
|
||||||
|
* retry on HTTP 4xx/5xx — those are real responses we should surface.
|
||||||
|
*
|
||||||
|
* Returns {statusCode, headers, body} so callers can read whichever response
|
||||||
|
* header or body bytes they need. For digest probes the body is drained and
|
||||||
|
* discarded; for auth-token fetches the JSON body is parsed.
|
||||||
|
*
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.hostname
|
||||||
|
* @param {string} opts.path
|
||||||
|
* @param {object} [opts.headers]
|
||||||
|
* @param {number} [opts.maxBodyBytes=65536] — protect against runaway bodies
|
||||||
|
*/
|
||||||
|
async fetchWithReliability(opts) {
|
||||||
|
const maxBodyBytes = opts.maxBodyBytes || 65536;
|
||||||
|
let attempt = 0;
|
||||||
|
while (attempt <= REGISTRY_MAX_RETRIES) {
|
||||||
|
try {
|
||||||
|
const result = await this._httpsRequestOnce({
|
||||||
|
hostname: opts.hostname,
|
||||||
|
path: opts.path,
|
||||||
|
headers: opts.headers || {},
|
||||||
|
maxBodyBytes,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
// Drain retryable transient errors; non-transient (HTTP status) errors
|
||||||
|
// and code-less errors are surfaced directly to the caller.
|
||||||
|
if (!REGISTRY_TRANSIENT_ERROR_CODES.has(error && error.code)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (attempt >= REGISTRY_MAX_RETRIES) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
attempt += 1;
|
||||||
|
// Brief backoff before retry to let transient blips settle.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, REGISTRY_RETRY_BACKOFF_MS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Defensive — should not reach here because the loop either throws or returns.
|
||||||
|
throw new Error('fetchWithReliability exhausted retries');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot HTTPS request helper for fetchWithReliability — DC-078.
|
||||||
|
* Returns {statusCode, headers, body} on 2xx and most non-2xx responses
|
||||||
|
* (the caller decides what to do with non-2xx). Throws on transient
|
||||||
|
* network errors so the retry policy catches them.
|
||||||
|
*/
|
||||||
|
_httpsRequestOnce({ hostname, path: urlPath, headers, maxBodyBytes }) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const options = {
|
const options = {
|
||||||
hostname: 'ghcr.io',
|
hostname,
|
||||||
path: `/v2/${imageRepo}/manifests/${tag}`,
|
path: urlPath,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
family: 4, // DC-078: IPv4-only — see top-of-file comment
|
||||||
'Accept': 'application/vnd.docker.distribution.manifest.v2+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json'
|
headers,
|
||||||
}
|
timeout: REGISTRY_REQUEST_TIMEOUT_MS, // DC-078: hard per-request cap
|
||||||
};
|
};
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
const req = https.request(options, (res) => {
|
||||||
if (res.statusCode === 401) {
|
let body = '';
|
||||||
const authHeader = res.headers['www-authenticate'];
|
let size = 0;
|
||||||
const authUrl = this.parseAuthHeader(authHeader);
|
let aborted = false;
|
||||||
if (authUrl) {
|
res.on('data', (chunk) => {
|
||||||
// ghcr.io auth endpoint accepts scope=repository:owner/name:pull
|
if (aborted) return;
|
||||||
this.authenticateAndGetDigest(authUrl, options).then(resolve).catch(reject);
|
size += chunk.length;
|
||||||
} else {
|
if (size > maxBodyBytes) {
|
||||||
reject(new Error('Authentication required but no auth URL found'));
|
aborted = true;
|
||||||
|
res.destroy();
|
||||||
|
const err = new Error(`response from ${hostname}${urlPath} exceeded ${maxBodyBytes} bytes`);
|
||||||
|
err.code = 'ERR_RESPONSE_TOO_LARGE';
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return;
|
body += chunk;
|
||||||
}
|
});
|
||||||
|
res.on('end', () => {
|
||||||
if (res.statusCode !== 200) {
|
if (aborted) return;
|
||||||
// Drain body to avoid socket leak
|
resolve({
|
||||||
res.resume();
|
statusCode: res.statusCode,
|
||||||
reject(new Error(`ghcr.io returned HTTP ${res.statusCode}`));
|
headers: res.headers,
|
||||||
return;
|
body,
|
||||||
}
|
});
|
||||||
|
});
|
||||||
const digest = res.headers['docker-content-digest'];
|
});
|
||||||
resolve(digest || null);
|
// Node 22 emits 'timeout' on the request, not the socket, when socket.setTimeout
|
||||||
|
// is hit — make it an explicit error so fetchWithReliability's retry policy catches it.
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy(new Error('request timeout'));
|
||||||
|
const err = new Error(`registry request to ${hostname}${urlPath} timed out after ${REGISTRY_REQUEST_TIMEOUT_MS}ms`);
|
||||||
|
err.code = 'ETIMEDOUT';
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
req.on('error', (err) => {
|
||||||
|
// Tag errors missing .code so the retry policy recognizes transient ones.
|
||||||
|
if (!err.code && /timeout/i.test(err.message)) err.code = 'ETIMEDOUT';
|
||||||
|
reject(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
req.on('error', reject);
|
|
||||||
req.end();
|
req.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get image digest from Docker Hub
|
* Fetch an auth token from a registry's WWW-Authenticate realm URL — DC-078.
|
||||||
|
* Uses fetchWithReliability for IPv4-only + timeout + retry. Parses the
|
||||||
|
* JSON body and returns the `token` or `access_token` field.
|
||||||
*/
|
*/
|
||||||
async getDockerHubDigest(repository, tag) {
|
async fetchAuthToken(authUrl) {
|
||||||
return new Promise((resolve, reject) => {
|
const url = new URL(authUrl);
|
||||||
// Normalize repository name
|
const result = await this.fetchWithReliability({
|
||||||
const repo = repository.includes('/') ? repository : `library/${repository}`;
|
hostname: url.hostname,
|
||||||
|
path: url.pathname + url.search,
|
||||||
const options = {
|
maxBodyBytes: 16384, // auth tokens are <2 KB; cap to a small bound
|
||||||
hostname: 'registry-1.docker.io',
|
|
||||||
path: `/v2/${repo}/manifests/${tag}`,
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
|
||||||
if (res.statusCode === 401) {
|
|
||||||
// Need to authenticate
|
|
||||||
const authHeader = res.headers['www-authenticate'];
|
|
||||||
const authUrl = this.parseAuthHeader(authHeader);
|
|
||||||
|
|
||||||
if (authUrl) {
|
|
||||||
this.authenticateAndGetDigest(authUrl, options).then(resolve).catch(reject);
|
|
||||||
} else {
|
|
||||||
reject(new Error('Authentication required but no auth URL found'));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const digest = res.headers['docker-content-digest'];
|
|
||||||
resolve(digest || null);
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', reject);
|
|
||||||
req.end();
|
|
||||||
});
|
});
|
||||||
|
if (result.statusCode !== 200) {
|
||||||
|
throw new Error(`auth token endpoint ${authUrl} returned HTTP ${result.statusCode}`);
|
||||||
|
}
|
||||||
|
let auth;
|
||||||
|
try {
|
||||||
|
auth = JSON.parse(result.body);
|
||||||
|
} catch (parseErr) {
|
||||||
|
// Surface a clean error — otherwise a malformed token response throws
|
||||||
|
// SyntaxError with the raw body snippet, which is hard to diagnose
|
||||||
|
// against the offending realm URL in a log line.
|
||||||
|
throw new Error(`auth token response from ${authUrl} was not valid JSON: ${parseErr.message}`);
|
||||||
|
}
|
||||||
|
const token = auth.token || auth.access_token;
|
||||||
|
if (!token) throw new Error(`No token in auth response from ${authUrl}`);
|
||||||
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -283,48 +527,6 @@ class UpdateManager extends EventEmitter {
|
|||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticate and get digest
|
|
||||||
*/
|
|
||||||
async authenticateAndGetDigest(authUrl, originalOptions) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
https.get(authUrl, (res) => {
|
|
||||||
let data = '';
|
|
||||||
res.on('data', chunk => data += chunk);
|
|
||||||
res.on('end', () => {
|
|
||||||
try {
|
|
||||||
const auth = JSON.parse(data);
|
|
||||||
const token = auth.token || auth.access_token;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
reject(new Error('No token in auth response'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry original request with token
|
|
||||||
const options = {
|
|
||||||
...originalOptions,
|
|
||||||
headers: {
|
|
||||||
...originalOptions.headers,
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
|
||||||
const digest = res.headers['docker-content-digest'];
|
|
||||||
resolve(digest || null);
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', reject);
|
|
||||||
req.end();
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract tag from image name
|
* Extract tag from image name
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -32,6 +32,30 @@ const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000', 10
|
|||||||
const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff
|
const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff
|
||||||
const MAX_ENTRIES_PER_SERVICE = parseInt(process.env.HEALTH_MAX_ENTRIES || '500', 10); // Cap to prevent disk explosion
|
const MAX_ENTRIES_PER_SERVICE = parseInt(process.env.HEALTH_MAX_ENTRIES || '500', 10); // Cap to prevent disk explosion
|
||||||
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
|
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
|
||||||
|
// DC-088: how long a removal tombstone outlives the removal itself. Only needs
|
||||||
|
// to cover the max in-flight probe lifetime (timeout + scheduling headroom);
|
||||||
|
// swept by cleanupHistory so removed services cannot accumulate map entries.
|
||||||
|
const REMOVED_GENERATION_TTL_MS = parseInt(process.env.HEALTH_REMOVED_GEN_TTL || '600000', 10);
|
||||||
|
|
||||||
|
// DC-086: hysteresis thresholds for badge display.
|
||||||
|
// The raw probe result can flap on a single transient blip (Caddy reload,
|
||||||
|
// container CPU steal, network hiccup, mid-flight TLS handshake). Showing
|
||||||
|
// every probe result as-is to the dashboard creates the "perpetual flicker"
|
||||||
|
// UX. Asymmetric thresholds: going red is slow (don't false-alarm), going
|
||||||
|
// green is fast (don't keep showing red after recovery).
|
||||||
|
// - DOWN_THRESHOLD = N consecutive "down" probes before the badge flips to red
|
||||||
|
// - UP_THRESHOLD = N consecutive "up" probes before the badge flips back to green
|
||||||
|
// Single probe flips to green on purpose — false-positive-green is much less
|
||||||
|
// painful than perpetual-red (operators notice red, ignore green).
|
||||||
|
function readPositiveIntEnv(name, fallback) {
|
||||||
|
const raw = process.env[name];
|
||||||
|
if (raw === undefined || raw === '') return fallback;
|
||||||
|
const value = Number(raw);
|
||||||
|
return Number.isSafeInteger(value) && value >= 1 ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOWN_THRESHOLD = readPositiveIntEnv('HEALTH_DOWN_THRESHOLD', 2);
|
||||||
|
const UP_THRESHOLD = readPositiveIntEnv('HEALTH_UP_THRESHOLD', 1);
|
||||||
|
|
||||||
class HealthChecker extends EventEmitter {
|
class HealthChecker extends EventEmitter {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -39,11 +63,27 @@ class HealthChecker extends EventEmitter {
|
|||||||
this.config = this.loadConfig();
|
this.config = this.loadConfig();
|
||||||
this.history = this.loadHistory();
|
this.history = this.loadHistory();
|
||||||
this.currentStatus = new Map();
|
this.currentStatus = new Map();
|
||||||
|
// DC-086: the status the dashboard SHOULD display (post-hysteresis).
|
||||||
|
// Distinct from currentStatus, which is the latest raw probe result.
|
||||||
|
this.displayedStatus = new Map();
|
||||||
|
// DC-086: counter of consecutive healthy/unhealthy probes since the
|
||||||
|
// last displayed-status change. Reset to 0 whenever displayed status flips.
|
||||||
|
this.consecutiveSinceChange = new Map();
|
||||||
this.incidents = [];
|
this.incidents = [];
|
||||||
this.checking = false;
|
this.checking = false;
|
||||||
this.checkInterval = null;
|
this.checkInterval = null;
|
||||||
this.consecutiveFailures = new Map(); // serviceId -> failure count
|
this.consecutiveFailures = new Map(); // serviceId -> failure count
|
||||||
this.serviceTimers = new Map(); // serviceId -> timer for per-service backoff
|
this.serviceTimers = new Map(); // serviceId -> timer for per-service backoff
|
||||||
|
// Invalidate probe completions that race with removal/reconfiguration.
|
||||||
|
this.serviceGenerations = new Map(); // serviceId -> configuration generation
|
||||||
|
// DC-088: monotonically increasing sequence so generation numbers can never
|
||||||
|
// repeat across remove -> re-add cycles (prevents ABA on the stale check).
|
||||||
|
this.generationSeq = 0;
|
||||||
|
// DC-088: serviceId -> { generation, removedAt } tombstones. A live entry in
|
||||||
|
// serviceGenerations means the service is (re)configured; a tombstone with a
|
||||||
|
// HIGHER generation than the captured one marks the capture as stale. Entry
|
||||||
|
// is deleted when the service is removed, so the live map cannot leak.
|
||||||
|
this.removedGenerations = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,11 +151,27 @@ class HealthChecker extends EventEmitter {
|
|||||||
this.cleanupHistory();
|
this.cleanupHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DC-088: true when a probe's captured generation no longer matches the
|
||||||
|
* service's current configuration state. A live serviceGenerations entry
|
||||||
|
* must match exactly. With no live entry the service was never configured
|
||||||
|
* in this process (disk-loaded / direct callers) — stale only if a removal
|
||||||
|
* tombstone with a HIGHER generation exists.
|
||||||
|
*/
|
||||||
|
_isStaleCapture(serviceId, generation) {
|
||||||
|
if (this.serviceGenerations.has(serviceId)) {
|
||||||
|
return this.serviceGenerations.get(serviceId) !== generation;
|
||||||
|
}
|
||||||
|
const tomb = this.removedGenerations.get(serviceId);
|
||||||
|
return Boolean(tomb && tomb.generation > generation);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check a single service
|
* Check a single service
|
||||||
*/
|
*/
|
||||||
async checkService(serviceId, config) {
|
async checkService(serviceId, config) {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
const generation = this.serviceGenerations.get(serviceId) || 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.performHealthCheck(config);
|
const result = await this.performHealthCheck(config);
|
||||||
@@ -131,6 +187,10 @@ class HealthChecker extends EventEmitter {
|
|||||||
details: result.details
|
details: result.details
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (this._isStaleCapture(serviceId, generation)) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
// Track consecutive failures for exponential backoff
|
// Track consecutive failures for exponential backoff
|
||||||
if (result.healthy) {
|
if (result.healthy) {
|
||||||
this.consecutiveFailures.delete(serviceId);
|
this.consecutiveFailures.delete(serviceId);
|
||||||
@@ -138,16 +198,15 @@ class HealthChecker extends EventEmitter {
|
|||||||
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
|
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previousStatus = this.currentStatus.get(serviceId);
|
||||||
|
const previousDisplayed = this.displayedStatus.get(serviceId);
|
||||||
this.recordStatus(serviceId, status);
|
this.recordStatus(serviceId, status);
|
||||||
this.checkForIncidents(serviceId, status, config);
|
this.checkForIncidents(serviceId, status, config, previousStatus, previousDisplayed);
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const responseTime = Date.now() - startTime;
|
const responseTime = Date.now() - startTime;
|
||||||
|
|
||||||
// Increment failure count for backoff
|
|
||||||
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
|
|
||||||
|
|
||||||
const status = {
|
const status = {
|
||||||
serviceId,
|
serviceId,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -156,8 +215,18 @@ class HealthChecker extends EventEmitter {
|
|||||||
error: error.message
|
error: error.message
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (this._isStaleCapture(serviceId, generation)) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment failure count for backoff — only after the result is known
|
||||||
|
// to be non-stale, so a removed service cannot re-create map entries.
|
||||||
|
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
|
||||||
|
|
||||||
|
const previousStatus = this.currentStatus.get(serviceId);
|
||||||
|
const previousDisplayed = this.displayedStatus.get(serviceId);
|
||||||
this.recordStatus(serviceId, status);
|
this.recordStatus(serviceId, status);
|
||||||
this.checkForIncidents(serviceId, status, config);
|
this.checkForIncidents(serviceId, status, config, previousStatus, previousDisplayed);
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
@@ -273,27 +342,109 @@ class HealthChecker extends EventEmitter {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the displayed status for a service given the latest raw probe
|
||||||
|
* result. Applies asymmetric hysteresis:
|
||||||
|
* - Going DOWN: requires DOWN_THRESHOLD (default 2) consecutive "down"
|
||||||
|
* probes since the last display-state change. A single blip keeps the
|
||||||
|
* badge green.
|
||||||
|
* - Going UP: requires UP_THRESHOLD (default 1) consecutive "up" probes.
|
||||||
|
* Any single "up" after a down streak flips back to green so the badge
|
||||||
|
* doesn't linger red after the service has recovered.
|
||||||
|
*
|
||||||
|
* Returns the displayed status object (same shape as the raw status) so
|
||||||
|
* recordStatus can use it both for the displayed map and as the broadcast
|
||||||
|
* payload when the displayed status actually changes.
|
||||||
|
*/
|
||||||
|
_computeDisplayedStatus(serviceId, rawStatus) {
|
||||||
|
const currentDisplayed = this.displayedStatus.get(serviceId);
|
||||||
|
const previousStatus = currentDisplayed ? currentDisplayed.status : null;
|
||||||
|
|
||||||
|
// If no prior state, accept the raw probe as-is (first-check bootstrap).
|
||||||
|
if (!previousStatus) {
|
||||||
|
return rawStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe agrees with current displayed → no change, reset the counter so
|
||||||
|
// a brief blip doesn't accumulate against the displayed state.
|
||||||
|
if (rawStatus.status === previousStatus) {
|
||||||
|
this.consecutiveSinceChange.set(serviceId, 0);
|
||||||
|
return rawStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe disagrees with displayed. Bump the streak counter — this counts
|
||||||
|
// CONSECUTIVE probes that disagree with what's shown, regardless of
|
||||||
|
// whether the raw value itself changed between probes. That's what
|
||||||
|
// makes "down, down" flip after threshold but "down, up, down" not flip.
|
||||||
|
const prev = this.consecutiveSinceChange.get(serviceId) || 0;
|
||||||
|
const next = prev + 1;
|
||||||
|
|
||||||
|
if (rawStatus.status === 'down') {
|
||||||
|
// Going DOWN: need DOWN_THRESHOLD consecutive probes that disagree
|
||||||
|
// with the displayed "up" state.
|
||||||
|
if (previousStatus === 'up' && next < DOWN_THRESHOLD) {
|
||||||
|
this.consecutiveSinceChange.set(serviceId, next);
|
||||||
|
// Keep the last internally-consistent displayed snapshot. Mixing the
|
||||||
|
// raw failure metadata with status="up" would expose contradictory
|
||||||
|
// API data (for example statusCode=500 on an "up" service).
|
||||||
|
return currentDisplayed;
|
||||||
|
}
|
||||||
|
// Threshold met (or already down) — flip to red.
|
||||||
|
this.consecutiveSinceChange.set(serviceId, 0);
|
||||||
|
return rawStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawStatus.status === 'up' (must be — the equal-to-displayed case above
|
||||||
|
// already returned). Going UP after a down streak: need UP_THRESHOLD.
|
||||||
|
if (previousStatus === 'down' && next < UP_THRESHOLD) {
|
||||||
|
this.consecutiveSinceChange.set(serviceId, next);
|
||||||
|
return currentDisplayed;
|
||||||
|
}
|
||||||
|
this.consecutiveSinceChange.set(serviceId, 0);
|
||||||
|
return rawStatus;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record service status
|
* Record service status
|
||||||
|
*
|
||||||
|
* DC-086: history + consecutiveFailures are updated for EVERY probe
|
||||||
|
* (operators want full probe history for postmortems). The dashboard's
|
||||||
|
* `status-check` event is only emitted when the DISPLAYED status changes,
|
||||||
|
* so the badge stops re-rendering on every probe.
|
||||||
*/
|
*/
|
||||||
recordStatus(serviceId, status) {
|
recordStatus(serviceId, status) {
|
||||||
// Update current status
|
// Update current (raw) status — used by checkForIncidents and history.
|
||||||
this.currentStatus.set(serviceId, status);
|
this.currentStatus.set(serviceId, status);
|
||||||
|
|
||||||
// Add to history
|
// Add raw probe to history (full fidelity — operators rely on this).
|
||||||
if (!this.history[serviceId]) {
|
if (!this.history[serviceId]) {
|
||||||
this.history[serviceId] = [];
|
this.history[serviceId] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
this.history[serviceId].push(status);
|
this.history[serviceId].push(status);
|
||||||
|
|
||||||
// Cap entries to prevent unbounded growth (disk explosion fix)
|
// Cap entries to prevent unbounded growth (disk explosion fix)
|
||||||
if (this.history[serviceId].length > MAX_ENTRIES_PER_SERVICE) {
|
if (this.history[serviceId].length > MAX_ENTRIES_PER_SERVICE) {
|
||||||
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
|
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit status event
|
// Compute the post-hysteresis displayed status; only emit when it changes.
|
||||||
this.emit('status-check', status);
|
// _computeDisplayedStatus compares the raw probe against the DISPLAYED
|
||||||
|
// status (not the previous raw status), so the "consecutive since
|
||||||
|
// change" counter doesn't depend on the order of writes here.
|
||||||
|
const displayed = this._computeDisplayedStatus(serviceId, status);
|
||||||
|
const previousDisplayed = this.displayedStatus.get(serviceId);
|
||||||
|
const displayChanged =
|
||||||
|
!previousDisplayed || previousDisplayed.status !== displayed.status;
|
||||||
|
|
||||||
|
this.displayedStatus.set(serviceId, displayed);
|
||||||
|
|
||||||
|
if (displayChanged) {
|
||||||
|
// Emit with the displayed status so the dashboard renders the same
|
||||||
|
// state the hysteresis just decided. The raw probe result is still
|
||||||
|
// in `history` and `currentStatus` for anyone who wants it.
|
||||||
|
this.emit('status-check', displayed);
|
||||||
|
}
|
||||||
|
|
||||||
// Save history periodically
|
// Save history periodically
|
||||||
if (Math.random() < 0.05) { // 5% chance (every ~20 checks)
|
if (Math.random() < 0.05) { // 5% chance (every ~20 checks)
|
||||||
@@ -304,11 +455,27 @@ class HealthChecker extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Check for incidents (downtime, slow response, etc.)
|
* Check for incidents (downtime, slow response, etc.)
|
||||||
*/
|
*/
|
||||||
checkForIncidents(serviceId, status, config) {
|
checkForIncidents(serviceId, status, config, previous = this.currentStatus.get(serviceId), previousDisplayed = null) {
|
||||||
const previous = this.currentStatus.get(serviceId);
|
|
||||||
|
|
||||||
// Check for status change (up -> down or down -> up)
|
// DC-090: outage incidents follow the DISPLAYED (post-hysteresis) status —
|
||||||
if (previous && previous.status !== status.status) {
|
// the same signal that flips the dashboard badge. A single raw "down"
|
||||||
|
// blip that hysteresis suppresses must not open a critical outage
|
||||||
|
// incident (and a suppressed blip must not resolve a real one). When the
|
||||||
|
// caller supplies the pre-probe displayed state (checkService always
|
||||||
|
// does), transitions are evaluated displayed-vs-displayed using the
|
||||||
|
// post-recordStatus state in this.displayedStatus. Direct callers with
|
||||||
|
// no hysteresis state (previousDisplayed === null) keep the legacy
|
||||||
|
// raw-probe transition semantics.
|
||||||
|
if (previousDisplayed) {
|
||||||
|
const displayed = this.displayedStatus.get(serviceId);
|
||||||
|
if (displayed && displayed.status !== previousDisplayed.status) {
|
||||||
|
if (displayed.status === 'down') {
|
||||||
|
this.createIncident(serviceId, 'outage', 'Service is down', displayed);
|
||||||
|
} else if (displayed.status === 'up') {
|
||||||
|
this.resolveIncident(serviceId, 'outage', displayed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (previous && previous.status !== status.status) {
|
||||||
if (status.status === 'down') {
|
if (status.status === 'down') {
|
||||||
this.createIncident(serviceId, 'outage', 'Service is down', status);
|
this.createIncident(serviceId, 'outage', 'Service is down', status);
|
||||||
} else if (status.status === 'up') {
|
} else if (status.status === 'up') {
|
||||||
@@ -445,19 +612,29 @@ class HealthChecker extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current status for all services
|
* Get current status for all services.
|
||||||
|
*
|
||||||
|
* DC-086: returns the DISPLAYED status (post-hysteresis), not the latest
|
||||||
|
* raw probe. A page reload should show the same badge state the live
|
||||||
|
* SSE stream is currently showing — otherwise an operator who reloads
|
||||||
|
* the page after a single blip sees red even though the hysteresis kept
|
||||||
|
* the badge green for them.
|
||||||
*/
|
*/
|
||||||
getCurrentStatus() {
|
getCurrentStatus() {
|
||||||
const result = {};
|
const result = {};
|
||||||
|
|
||||||
for (const [serviceId, status] of this.currentStatus.entries()) {
|
for (const [serviceId, rawStatus] of this.currentStatus.entries()) {
|
||||||
const config = this.config.services[serviceId];
|
const config = this.config.services[serviceId];
|
||||||
const uptime24h = this.calculateUptime(serviceId, 24);
|
const uptime24h = this.calculateUptime(serviceId, 24);
|
||||||
const uptime7d = this.calculateUptime(serviceId, 168);
|
const uptime7d = this.calculateUptime(serviceId, 168);
|
||||||
const avgResponseTime = this.calculateAverageResponseTime(serviceId, 24);
|
const avgResponseTime = this.calculateAverageResponseTime(serviceId, 24);
|
||||||
|
|
||||||
|
// Prefer the displayed status if we've already computed one; fall back
|
||||||
|
// to the raw probe on the very first call (before recordStatus has run).
|
||||||
|
const displayed = this.displayedStatus.get(serviceId) || rawStatus;
|
||||||
|
|
||||||
result[serviceId] = {
|
result[serviceId] = {
|
||||||
...status,
|
...displayed,
|
||||||
name: config?.name || serviceId,
|
name: config?.name || serviceId,
|
||||||
uptime: {
|
uptime: {
|
||||||
'24h': uptime24h,
|
'24h': uptime24h,
|
||||||
@@ -467,7 +644,7 @@ class HealthChecker extends EventEmitter {
|
|||||||
sla: config?.sla
|
sla: config?.sla
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,6 +707,13 @@ class HealthChecker extends EventEmitter {
|
|||||||
this.config.services = {};
|
this.config.services = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DC-088: monotonic instance-wide sequence — a re-added service can never
|
||||||
|
// recycle a previous generation number, and any older in-flight capture is
|
||||||
|
// invalidated by definition.
|
||||||
|
this.generationSeq += 1;
|
||||||
|
this.serviceGenerations.set(serviceId, this.generationSeq);
|
||||||
|
// Re-configuration supersedes any prior removal tombstone.
|
||||||
|
this.removedGenerations.delete(serviceId);
|
||||||
this.config.services[serviceId] = {
|
this.config.services[serviceId] = {
|
||||||
enabled: config.enabled !== false,
|
enabled: config.enabled !== false,
|
||||||
name: config.name || serviceId,
|
name: config.name || serviceId,
|
||||||
@@ -552,12 +736,42 @@ class HealthChecker extends EventEmitter {
|
|||||||
* Remove service configuration
|
* Remove service configuration
|
||||||
*/
|
*/
|
||||||
removeService(serviceId) {
|
removeService(serviceId) {
|
||||||
|
// DC-088: tombstone the captured generation instead of leaking an entry.
|
||||||
|
// The live map entry is deleted; an in-flight probe captured BEFORE this
|
||||||
|
// point sees no live entry but a higher tombstone generation, so it is
|
||||||
|
// discarded. configureService clears the tombstone on re-add.
|
||||||
|
this.generationSeq += 1;
|
||||||
|
this.serviceGenerations.delete(serviceId);
|
||||||
|
this.removedGenerations.set(serviceId, {
|
||||||
|
generation: this.generationSeq,
|
||||||
|
removedAt: Date.now()
|
||||||
|
});
|
||||||
if (this.config.services) {
|
if (this.config.services) {
|
||||||
delete this.config.services[serviceId];
|
delete this.config.services[serviceId];
|
||||||
this.saveConfig();
|
this.saveConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DC-088: open incidents for a removed service must not linger forever.
|
||||||
|
// Close them through the same resolve path a recovery would, annotated so
|
||||||
|
// history shows why (dashboard renders resolved incidents green + duration).
|
||||||
|
for (const incident of this.incidents) {
|
||||||
|
if (incident.serviceId === serviceId && incident.status === 'open') {
|
||||||
|
incident.status = 'resolved';
|
||||||
|
incident.resolvedAt = new Date().toISOString();
|
||||||
|
incident.duration = new Date(incident.resolvedAt) - new Date(incident.createdAt);
|
||||||
|
incident.resolvedBy = 'service-removed';
|
||||||
|
this.emit('incident-resolved', incident);
|
||||||
|
this.emit('log', 'info', `Incident closed by service removal: ${incident.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.currentStatus.delete(serviceId);
|
this.currentStatus.delete(serviceId);
|
||||||
|
this.displayedStatus.delete(serviceId);
|
||||||
|
this.consecutiveSinceChange.delete(serviceId);
|
||||||
|
this.consecutiveFailures.delete(serviceId);
|
||||||
|
const timer = this.serviceTimers.get(serviceId);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
this.serviceTimers.delete(serviceId);
|
||||||
delete this.history[serviceId];
|
delete this.history[serviceId];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,6 +790,18 @@ class HealthChecker extends EventEmitter {
|
|||||||
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
|
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DC-088: sweep expired removal tombstones. After the TTL no probe that
|
||||||
|
// captured a pre-removal generation can still be in flight (timeout is
|
||||||
|
// bounded by performHealthCheck), so the tombstone has done its job.
|
||||||
|
if (this.removedGenerations.size > 0) {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [serviceId, tomb] of this.removedGenerations) {
|
||||||
|
if (now - tomb.removedAt > REMOVED_GENERATION_TTL_MS) {
|
||||||
|
this.removedGenerations.delete(serviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -47,6 +47,53 @@ const ALLOWED_PUBLIC_TTLS = new Set([60 * 60 * 1000, 24 * 60 * 60 * 1000, 7 * 24
|
|||||||
const TAILSCALE_MAX_USES = 1;
|
const TAILSCALE_MAX_USES = 1;
|
||||||
const PUBLIC_DEFAULT_SUBSCRIBE_CAP = 1000; // bound on subscribe events per link
|
const PUBLIC_DEFAULT_SUBSCRIBE_CAP = 1000; // bound on subscribe events per link
|
||||||
|
|
||||||
|
// DC-083: Public share endpoint input bounds. The two CSRF-exempt public
|
||||||
|
// endpoints accept untrusted body fields — bound shape, length, charset so
|
||||||
|
// an attacker can't bloat data/shares.json, inject CRLF into fields that
|
||||||
|
// flow into Tailscale auth-key descriptions, or smuggle control chars into
|
||||||
|
// the on-disk store. See routes/share.js for the route-layer validation;
|
||||||
|
// these helpers are the defense-in-depth belt under the route's suspenders.
|
||||||
|
const PUBLIC_EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||||
|
const PUBLIC_EMAIL_MAX_LENGTH = 254; // RFC 5321 §4.5.3.1.3
|
||||||
|
const PUBLIC_DEVICE_ID_REGEX = /^[a-zA-Z0-9._:-]+$/;
|
||||||
|
const PUBLIC_DEVICE_ID_MIN_LENGTH = 1;
|
||||||
|
const PUBLIC_DEVICE_ID_MAX_LENGTH = 128;
|
||||||
|
|
||||||
|
function validatePublicEmail(raw) {
|
||||||
|
if (typeof raw !== 'string') return { ok: false, reason: 'invalid_email' };
|
||||||
|
// Reject control chars / NUL / CR / LF before they can corrupt the on-disk
|
||||||
|
// JSON or be embedded in subsequent log lines. RFC 5321 forbids these in
|
||||||
|
// SMTP addresses; we mirror that at the API layer.
|
||||||
|
if (raw.length === 0 || raw.length > PUBLIC_EMAIL_MAX_LENGTH) {
|
||||||
|
return { ok: false, reason: 'invalid_email' };
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
if (/[\x00-\x1f\x7f]/.test(raw)) return { ok: false, reason: 'invalid_email' };
|
||||||
|
// The local-part can technically contain `+`, `.`, `_`, `%`, `-`; the
|
||||||
|
// domain part must have at least one dot and a 2+ letter TLD. Reject
|
||||||
|
// quote-bracket forms (RFC 5321 obs-quote-text) — we don't accept them.
|
||||||
|
if (!PUBLIC_EMAIL_REGEX.test(raw)) return { ok: false, reason: 'invalid_email' };
|
||||||
|
// Block obvious shell-attachment characters that the regex doesn't catch.
|
||||||
|
if (/[<>{}|\\^`\s]/.test(raw)) return { ok: false, reason: 'invalid_email' };
|
||||||
|
return { ok: true, email: raw.toLowerCase() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePublicDeviceId(raw) {
|
||||||
|
if (typeof raw !== 'string') return { ok: false, reason: 'invalid_device_id' };
|
||||||
|
if (raw.length < PUBLIC_DEVICE_ID_MIN_LENGTH || raw.length > PUBLIC_DEVICE_ID_MAX_LENGTH) {
|
||||||
|
return { ok: false, reason: 'invalid_device_id' };
|
||||||
|
}
|
||||||
|
// Tailscale machine IDs are base64url-with-hyphens; we accept a slightly
|
||||||
|
// broader charset (`._:-`) to also accommodate hostname-style IDs and
|
||||||
|
// Caddy's `forward_auth` device headers. Reject CR/LF/NUL/TAB explicitly
|
||||||
|
// so a smuggled control char can't break out of the Tailscale auth-key
|
||||||
|
// description string in routes/share.js:213.
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
if (/[\x00-\x1f\x7f]/.test(raw)) return { ok: false, reason: 'invalid_device_id' };
|
||||||
|
if (!PUBLIC_DEVICE_ID_REGEX.test(raw)) return { ok: false, reason: 'invalid_device_id' };
|
||||||
|
return { ok: true, deviceId: raw };
|
||||||
|
}
|
||||||
|
|
||||||
function _nowMs() { return Date.now(); }
|
function _nowMs() { return Date.now(); }
|
||||||
function _nowIso() { return new Date().toISOString(); }
|
function _nowIso() { return new Date().toISOString(); }
|
||||||
|
|
||||||
@@ -327,8 +374,18 @@ function createShareStore(opts = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordPublicSubscribe(token) {
|
function recordPublicSubscribe(token, { email } = {}) {
|
||||||
return _enqueue(() => {
|
return _enqueue(() => {
|
||||||
|
// DC-083: validate the optional subscriber email at the store layer too.
|
||||||
|
// The route layer validates first; this is the defense-in-depth catch
|
||||||
|
// for direct callers (cron sweepers, internal jobs, future endpoints).
|
||||||
|
// `email` is OPT-IN — callers omitting it get the original behavior.
|
||||||
|
let normalizedEmail = null;
|
||||||
|
if (email !== undefined && email !== null) {
|
||||||
|
const v = validatePublicEmail(email);
|
||||||
|
if (!v.ok) return { ok: false, reason: v.reason };
|
||||||
|
normalizedEmail = v.email;
|
||||||
|
}
|
||||||
const data = _load();
|
const data = _load();
|
||||||
const hash = _sha256(token);
|
const hash = _sha256(token);
|
||||||
const s = _findByHash(data, hash);
|
const s = _findByHash(data, hash);
|
||||||
@@ -341,6 +398,16 @@ function createShareStore(opts = {}) {
|
|||||||
const cap = s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP;
|
const cap = s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP;
|
||||||
if (s.subscribeCount >= cap) return { ok: false, reason: 'cap_reached' };
|
if (s.subscribeCount >= cap) return { ok: false, reason: 'cap_reached' };
|
||||||
s.subscribeCount += 1;
|
s.subscribeCount += 1;
|
||||||
|
// DC-083: record the last submitting email (capped to 8 entries to
|
||||||
|
// bound the on-disk size). PII minimization — we keep only the hash
|
||||||
|
// + last 8 emails; full email log would grow unbounded.
|
||||||
|
if (normalizedEmail) {
|
||||||
|
if (!Array.isArray(s.subscriberEmails)) s.subscriberEmails = [];
|
||||||
|
s.subscriberEmails.push(normalizedEmail);
|
||||||
|
if (s.subscriberEmails.length > 8) {
|
||||||
|
s.subscriberEmails.splice(0, s.subscriberEmails.length - 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
_save(data);
|
_save(data);
|
||||||
return { ok: true, count: s.subscribeCount, cap };
|
return { ok: true, count: s.subscribeCount, cap };
|
||||||
});
|
});
|
||||||
@@ -348,6 +415,18 @@ function createShareStore(opts = {}) {
|
|||||||
|
|
||||||
function recordTailscaleUse(token, { deviceId } = {}) {
|
function recordTailscaleUse(token, { deviceId } = {}) {
|
||||||
return _enqueue(() => {
|
return _enqueue(() => {
|
||||||
|
// DC-083: validate deviceId at the store layer. The pre-fix code
|
||||||
|
// accepted ANY string of any length, including control chars and
|
||||||
|
// CR/LF — which would flow into the Tailscale auth-key description
|
||||||
|
// (routes/share.js:213) and into the on-disk shares.json. Reject
|
||||||
|
// early so an attacker can't bloat the store or smuggle characters
|
||||||
|
// out of the Tailscale description field.
|
||||||
|
let normalizedDeviceId = 'unknown';
|
||||||
|
if (deviceId !== undefined && deviceId !== null) {
|
||||||
|
const v = validatePublicDeviceId(deviceId);
|
||||||
|
if (!v.ok) return { ok: false, reason: v.reason };
|
||||||
|
normalizedDeviceId = v.deviceId;
|
||||||
|
}
|
||||||
const data = _load();
|
const data = _load();
|
||||||
const hash = _sha256(token);
|
const hash = _sha256(token);
|
||||||
const s = _findByHash(data, hash);
|
const s = _findByHash(data, hash);
|
||||||
@@ -359,7 +438,7 @@ function createShareStore(opts = {}) {
|
|||||||
return { ok: false, reason: 'expired' };
|
return { ok: false, reason: 'expired' };
|
||||||
}
|
}
|
||||||
s.usedAt = _nowIso();
|
s.usedAt = _nowIso();
|
||||||
s.usedBy = typeof deviceId === 'string' ? deviceId : 'unknown';
|
s.usedBy = normalizedDeviceId;
|
||||||
_save(data);
|
_save(data);
|
||||||
return { ok: true, share: _publicView(s) };
|
return { ok: true, share: _publicView(s) };
|
||||||
});
|
});
|
||||||
@@ -411,4 +490,4 @@ function createShareStore(opts = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createShareStore };
|
module.exports = { createShareStore, validatePublicEmail, validatePublicDeviceId };
|
||||||
@@ -79,6 +79,17 @@ const RATE_LIMITS = {
|
|||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
max: 10,
|
max: 10,
|
||||||
},
|
},
|
||||||
|
// DC-083: Public share endpoint limiter. The two CSRF-exempt public
|
||||||
|
// endpoints (POST /share/:token/subscribe + POST /share/:token/redeem-tailscale)
|
||||||
|
// mutate on-disk state (data/shares.json). Bound them tighter than the
|
||||||
|
// general limiter (1000/15min) so a single attacker can't bloat the
|
||||||
|
// store or saturate the tmp+rename writer. 30/15min is enough for a
|
||||||
|
// legitimate user clicking "subscribe" once or twice — anything beyond
|
||||||
|
// is abuse.
|
||||||
|
SHARE_PUBLIC: {
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 30,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Caddy ─────────────────────────────────────────────────────
|
// ── Caddy ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -415,10 +415,91 @@ function validateFleetHost(input) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a `host:port` upstream string for use in Caddy's `reverse_proxy`.
|
||||||
|
*
|
||||||
|
* DC-074 SSRF hardening: an authenticated dashboard operator can call
|
||||||
|
* POST /api/v1/site with `upstream: '10.0.0.1:80'` and end up with a
|
||||||
|
* Caddyfile entry that proxies public traffic (https://attacker.example.com)
|
||||||
|
* to an INTERNAL host (10.0.0.1:80). Caddy runs on DNS2 — same network
|
||||||
|
* as the targets — so the proxy lands the request on the private host.
|
||||||
|
* The operator doesn't even need DNS-rebinding tricks: a literal IPv4
|
||||||
|
* like 192.168.1.1 is accepted by the existing `[a-z0-9.-]+:\d{1,5}`
|
||||||
|
* upstream regex.
|
||||||
|
*
|
||||||
|
* Reuses `resolveAndCheckAddress()` to:
|
||||||
|
* - reject literal private IPv4 / IPv6
|
||||||
|
* - resolve DNS names and reject any private-IP answer
|
||||||
|
* (rebinding defense — the actual address Caddy connects to is
|
||||||
|
* the resolved IP at registration time; Caddy itself resolves
|
||||||
|
* the name per-request, so a malicious operator could flip the
|
||||||
|
* A record between registration and connection. Acceptable
|
||||||
|
* residual risk — the registration check is the main gate.)
|
||||||
|
* - cap port to 1..65535 (defense vs. `host:99999999` integer
|
||||||
|
* overflow / Caddy parser-bomb)
|
||||||
|
*
|
||||||
|
* Opt-in via SITES_ALLOW_PRIVATE_UPSTREAMS=true for operators who
|
||||||
|
* intentionally proxy to private targets (faster than a public DNS
|
||||||
|
* round-trip + central control plane).
|
||||||
|
*
|
||||||
|
* @param {string} upstream - "host:port" string (e.g. "10.0.0.1:80")
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {boolean} [opts.allowPrivate] - override the env-var default
|
||||||
|
* @returns {Promise<{ok: true, host: string, port: number, resolvedIp?: string, family?: number} | {ok: false, code: string, message: string}>}
|
||||||
|
*/
|
||||||
|
async function validateUpstream(upstream, opts = {}) {
|
||||||
|
if (typeof upstream !== 'string' || upstream.length === 0) {
|
||||||
|
return { ok: false, code: 'INVALID_UPSTREAM', message: 'upstream is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split on the LAST colon so IPv6 literals like `[::1]:80` parse
|
||||||
|
// correctly (and a malformed `[::1]` without port is rejected with
|
||||||
|
// a clean code, not a confusing TypeError from Number()).
|
||||||
|
const lastColon = upstream.lastIndexOf(':');
|
||||||
|
if (lastColon < 0) {
|
||||||
|
return { ok: false, code: 'INVALID_UPSTREAM', message: 'upstream must be host:port' };
|
||||||
|
}
|
||||||
|
const host = upstream.slice(0, lastColon);
|
||||||
|
const portStr = upstream.slice(lastColon + 1);
|
||||||
|
|
||||||
|
const portNum = Number(portStr);
|
||||||
|
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
|
||||||
|
return { ok: false, code: 'INVALID_PORT', message: 'upstream port must be an integer 1..65535' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow-list the host charset BEFORE the DNS lookup so attacker
|
||||||
|
// payloads can't make the resolver do work. Matches the fleet
|
||||||
|
// isValidHostnameSyntax check; sites.js's own `[a-z0-9.-]+` regex
|
||||||
|
// is more restrictive (only letters/digits/dots/hyphens) so
|
||||||
|
// we widen here to also accept bracketed IPv6. Anything else gets
|
||||||
|
// rejected pre-DNS.
|
||||||
|
const isBracketedIPv6 = host.startsWith('[') && host.endsWith(']');
|
||||||
|
const hostToCheck = isBracketedIPv6 ? host.slice(1, -1) : host;
|
||||||
|
if (!isValidHostnameSyntax(hostToCheck) && require('net').isIP(hostToCheck) === 0) {
|
||||||
|
return { ok: false, code: 'INVALID_HOST', message: `upstream host "${host}" is not a valid DNS name or IP address` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowPrivate = typeof opts.allowPrivate === 'boolean'
|
||||||
|
? opts.allowPrivate
|
||||||
|
: process.env.SITES_ALLOW_PRIVATE_UPSTREAMS === 'true';
|
||||||
|
|
||||||
|
const r = await resolveAndCheckAddress(hostToCheck, { allowPrivate });
|
||||||
|
if (!r.ok) return r; // bubbles up PRIVATE_IPV4 / PRIVATE_IPV6 / INVALID_HOSTNAME / DNS_*
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
host,
|
||||||
|
port: portNum,
|
||||||
|
resolvedIp: r.ip,
|
||||||
|
family: r.family,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
validateFleetHost,
|
validateFleetHost,
|
||||||
resolveAndCheckAddress,
|
resolveAndCheckAddress,
|
||||||
isPrivateOrReservedIPv4,
|
isPrivateOrReservedIPv4,
|
||||||
isPrivateOrReservedIPv6,
|
isPrivateOrReservedIPv6,
|
||||||
isValidHostnameSyntax,
|
isValidHostnameSyntax,
|
||||||
|
validateUpstream,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -330,17 +330,22 @@ module.exports = function configureMiddleware(app, {
|
|||||||
const ssoHandoffTokens = new Map();
|
const ssoHandoffTokens = new Map();
|
||||||
const SSO_HANDOFF_TTL_MS = 60 * 1000;
|
const SSO_HANDOFF_TTL_MS = 60 * 1000;
|
||||||
|
|
||||||
function createHandoffToken() {
|
function createHandoffToken(expectedHost = null) {
|
||||||
const token = crypto.randomBytes(24).toString('base64url');
|
const token = crypto.randomBytes(24).toString('base64url');
|
||||||
ssoHandoffTokens.set(token, { exp: Date.now() + SSO_HANDOFF_TTL_MS });
|
ssoHandoffTokens.set(token, {
|
||||||
|
exp: Date.now() + SSO_HANDOFF_TTL_MS,
|
||||||
|
expectedHost: expectedHost ? String(expectedHost).toLowerCase() : null,
|
||||||
|
});
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
function redeemHandoffToken(token) {
|
function redeemHandoffToken(token, actualHost = null) {
|
||||||
if (!token) return false;
|
if (!token) return false;
|
||||||
const entry = ssoHandoffTokens.get(token);
|
const entry = ssoHandoffTokens.get(token);
|
||||||
ssoHandoffTokens.delete(token); // one-time use regardless of outcome
|
ssoHandoffTokens.delete(token); // one-time use regardless of outcome
|
||||||
return !!entry && entry.exp > Date.now();
|
if (!entry || entry.exp <= Date.now()) return false;
|
||||||
|
if (!entry.expectedHost) return true;
|
||||||
|
return !!actualHost && entry.expectedHost === String(actualHost).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setHostOnlySessionCookie(res, durationKey) {
|
function setHostOnlySessionCookie(res, durationKey) {
|
||||||
@@ -426,8 +431,21 @@ module.exports = function configureMiddleware(app, {
|
|||||||
{ path: '/api/v1/ca/root.crt', exact: true, method: 'GET' },
|
{ path: '/api/v1/ca/root.crt', exact: true, method: 'GET' },
|
||||||
{ path: '/api/v1/ca/install-script', exact: true, method: 'GET' },
|
{ path: '/api/v1/ca/install-script', exact: true, method: 'GET' },
|
||||||
{ path: '/api/v1/health/ca', exact: true, method: 'GET' },
|
{ path: '/api/v1/health/ca', exact: true, method: 'GET' },
|
||||||
{ path: '/api/v1/ca/cert/', prefix: true, method: 'GET' },
|
// DC-076: /api/v1/ca/cert/<domain> and /api/v1/ca/certs MUST stay gated
|
||||||
{ path: '/api/v1/ca/certs', exact: true, method: 'GET' },
|
// by TOTP/session. The /cert/<domain> endpoint returns the private key
|
||||||
|
// (format=key and format=pem both embed `server.key`; format=pfx wraps
|
||||||
|
// the same key in a PKCS#12 envelope). If an operator disables TOTP at
|
||||||
|
// any point in the future (ops command, fresh install with TOTP off
|
||||||
|
// during setup, .disabled-* rename of totp-config.json), an unauthenticated
|
||||||
|
// attacker reaching `https://ca.sami/api/ca/cert/<any-domain>?format=key`
|
||||||
|
// would receive the per-service RSA private key for every service whose
|
||||||
|
// cert Caddy has ever signed — that's a per-service key disclosure, not
|
||||||
|
// just a CA fingerprint leak. The `/api/v1/ca/info`, `/root.crt`, and
|
||||||
|
// `/install-script` paths above stay public (the root CA cert is public
|
||||||
|
// by design — devices need it to trust *.sami TLS); only the per-service
|
||||||
|
// private key and per-service cert list go behind auth. See DC-076 for
|
||||||
|
// the corresponding rate-limit + admin-scope + password-required
|
||||||
|
// hardening in routes/ca.js.
|
||||||
{ path: '/api/v1/csrf-token', exact: true, method: 'GET' },
|
{ path: '/api/v1/csrf-token', exact: true, method: 'GET' },
|
||||||
{ path: '/api/v1/logo', exact: true, method: 'GET' },
|
{ path: '/api/v1/logo', exact: true, method: 'GET' },
|
||||||
{ path: '/api/v1/favicon', exact: true, method: 'GET' },
|
{ path: '/api/v1/favicon', exact: true, method: 'GET' },
|
||||||
|
|||||||
@@ -14,8 +14,19 @@ const path = require('path');
|
|||||||
module.exports = function nestingGuard() {
|
module.exports = function nestingGuard() {
|
||||||
try {
|
try {
|
||||||
const paths = require('../config/paths');
|
const paths = require('../config/paths');
|
||||||
const dataDir = paths.dataDir;
|
const dataDir = paths && paths.dataDir;
|
||||||
const dataDataPath = path.join(dataDir, 'data');
|
// Defensive: if paths.dataDir is undefined (older callers or a future
|
||||||
|
// export-shape drift), fall back to platformPaths.dataDir directly so the
|
||||||
|
// guard can still execute. Pre-fix this branch was swallowed silently by
|
||||||
|
// the outer try/catch, leaving the entire nesting-guard a no-op (DC-077).
|
||||||
|
const effectiveDataDir = typeof dataDir === 'string' && dataDir
|
||||||
|
? dataDir
|
||||||
|
: require('../../platform-paths').dataDir;
|
||||||
|
if (typeof effectiveDataDir !== 'string' || !effectiveDataDir) {
|
||||||
|
console.warn('[nesting-guard] Skipped: dataDir unavailable from src/config/paths and platform-paths');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dataDataPath = path.join(effectiveDataDir, 'data');
|
||||||
|
|
||||||
// If data/data exists, it's a recursive duplicate — remove it
|
// If data/data exists, it's a recursive duplicate — remove it
|
||||||
if (fs.existsSync(dataDataPath)) {
|
if (fs.existsSync(dataDataPath)) {
|
||||||
|
|||||||
@@ -630,10 +630,15 @@ generate_caddyfile() {
|
|||||||
SNIP
|
SNIP
|
||||||
|
|
||||||
local auth_snippet="(dashcaddy_auth) {
|
local auth_snippet="(dashcaddy_auth) {
|
||||||
forward_auth localhost:${API_PORT} {
|
@needsAuth not path /dashcaddy-sso
|
||||||
|
forward_auth @needsAuth localhost:${API_PORT} {
|
||||||
uri /api/v1/auth/gate/{args[0]}
|
uri /api/v1/auth/gate/{args[0]}
|
||||||
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
|
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
|
||||||
}
|
}
|
||||||
|
handle /dashcaddy-sso {
|
||||||
|
rewrite * /api/v1/auth/sso-exchange
|
||||||
|
reverse_proxy localhost:${API_PORT}
|
||||||
|
}
|
||||||
}"
|
}"
|
||||||
|
|
||||||
local site_body=" root * ${DASHBOARD_DIR}
|
local site_body=" root * ${DASHBOARD_DIR}
|
||||||
|
|||||||
@@ -51,10 +51,15 @@ class CaddyfileGenerator {
|
|||||||
_authSnippet(apiPort) {
|
_authSnippet(apiPort) {
|
||||||
return `# DashCaddy SSO auth snippet
|
return `# DashCaddy SSO auth snippet
|
||||||
(dashcaddy_auth) {
|
(dashcaddy_auth) {
|
||||||
forward_auth localhost:${apiPort} {
|
@needsAuth not path /dashcaddy-sso
|
||||||
|
forward_auth @needsAuth localhost:${apiPort} {
|
||||||
uri /api/v1/auth/gate/{args[0]}
|
uri /api/v1/auth/gate/{args[0]}
|
||||||
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
|
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
|
||||||
}
|
}
|
||||||
|
handle /dashcaddy-sso {
|
||||||
|
rewrite * /api/v1/auth/sso-exchange
|
||||||
|
reverse_proxy localhost:${apiPort}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
const CaddyfileGenerator = require('./caddyfile-generator');
|
||||||
|
|
||||||
|
describe('cross-host SSO installer contract', () => {
|
||||||
|
test('generated auth snippet exposes the public one-time exchange landing route', () => {
|
||||||
|
const snippet = new CaddyfileGenerator()._authSnippet(3001);
|
||||||
|
expect(snippet).toContain('@needsAuth not path /dashcaddy-sso');
|
||||||
|
expect(snippet).toContain('handle /dashcaddy-sso');
|
||||||
|
expect(snippet).toContain('rewrite * /api/v1/auth/sso-exchange');
|
||||||
|
expect(snippet).toContain('reverse_proxy localhost:3001');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shell installer emits the same exchange landing contract', () => {
|
||||||
|
const installer = fs.readFileSync(path.join(__dirname, '..', '..', 'install.sh'), 'utf8');
|
||||||
|
expect(installer).toContain('@needsAuth not path /dashcaddy-sso');
|
||||||
|
expect(installer).toContain('handle /dashcaddy-sso');
|
||||||
|
expect(installer).toContain('rewrite * /api/v1/auth/sso-exchange');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Caddy parser accepts a complete service config using the generated snippet', () => {
|
||||||
|
const available = spawnSync('caddy', ['version'], { encoding: 'utf8' });
|
||||||
|
if (available.status !== 0) return;
|
||||||
|
|
||||||
|
const generator = new CaddyfileGenerator();
|
||||||
|
const config = `${generator._authSnippet(3001)}\nexample.test {\n import dashcaddy_auth plex\n respond "ok" 200\n}\n`;
|
||||||
|
const result = spawnSync('caddy', ['validate', '--config', '-', '--adapter', 'caddyfile'], {
|
||||||
|
input: config,
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(`${result.stdout}\n${result.stderr}`).toContain('Valid configuration');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,7 @@ const bundles = {
|
|||||||
// totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js
|
// totp-recovery.js registers window._refreshRecoveryLink which totp-auth.js
|
||||||
// calls from showTotpOverlay(). Must come after totp-auth.js.
|
// calls from showTotpOverlay(). Must come after totp-auth.js.
|
||||||
JS('totp-recovery.js'),
|
JS('totp-recovery.js'),
|
||||||
|
JS('credential-vault-handoff.js'),
|
||||||
JS('service-credentials.js'),
|
JS('service-credentials.js'),
|
||||||
JS('totp-settings.js'),
|
JS('totp-settings.js'),
|
||||||
// DC-048 admin panel — modal-overlay UI for user/invite management.
|
// DC-048 admin panel — modal-overlay UI for user/invite management.
|
||||||
|
|||||||
Vendored
+108
-108
File diff suppressed because one or more lines are too long
Vendored
+7
-7
File diff suppressed because one or more lines are too long
+65
-4
@@ -216,8 +216,8 @@
|
|||||||
_el('input', { name: 'ttlHours', type: 'number', min: '1', max: '168', value: '24', style: 'padding:6px;width:80px' }),
|
_el('input', { name: 'ttlHours', type: 'number', min: '1', max: '168', value: '24', style: 'padding:6px;width:80px' }),
|
||||||
));
|
));
|
||||||
form.appendChild(_el('label', { style: 'display:flex;gap:4px;align-items:center;font-size:0.85rem' },
|
form.appendChild(_el('label', { style: 'display:flex;gap:4px;align-items:center;font-size:0.85rem' },
|
||||||
_el('input', { name: 'sendEmail', type: 'checkbox', checked: true }),
|
_el('input', { name: 'sendEmail', type: 'checkbox', checked: false }),
|
||||||
_el('span', { text: 'Send email' }),
|
_el('span', { text: 'Also send via email (optional)' }),
|
||||||
));
|
));
|
||||||
form.appendChild(_el('button', { type: 'submit', class: 'btn-sm', style: 'padding:6px 12px', text: 'Issue invite' }));
|
form.appendChild(_el('button', { type: 'submit', class: 'btn-sm', style: 'padding:6px 12px', text: 'Issue invite' }));
|
||||||
container.appendChild(form);
|
container.appendChild(form);
|
||||||
@@ -297,16 +297,77 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
banner.appendChild(copyBtn);
|
banner.appendChild(copyBtn);
|
||||||
if (invite.deliveredVia === 'dev-console') {
|
|
||||||
|
// DC-085: pre-formatted message for one-tap paste into iMessage / WhatsApp /
|
||||||
|
// Telegram / SMS / Signal / Discord / paste-into-email. The operator can
|
||||||
|
// copy this as a sentence instead of dealing with the raw URL.
|
||||||
|
if (invite.shareText) {
|
||||||
|
const shareBlock = _el('div', { style: 'margin-top:12px' });
|
||||||
|
shareBlock.appendChild(_el('div', {
|
||||||
|
style: 'font-size:0.8rem;color:#86efac;margin-bottom:4px',
|
||||||
|
text: 'Share this message:',
|
||||||
|
}));
|
||||||
|
shareBlock.appendChild(_el('div', {
|
||||||
|
style: 'padding:8px;background:#000;border-radius:4px;color:#d1fae5;white-space:pre-wrap',
|
||||||
|
text: invite.shareText,
|
||||||
|
}));
|
||||||
|
const shareActions = _el('div', { style: 'margin-top:6px;display:flex;gap:6px;flex-wrap:wrap' });
|
||||||
|
const copyTextBtn = _el('button', {
|
||||||
|
class: 'btn-sm', style: 'padding:4px 10px',
|
||||||
|
text: 'Copy message',
|
||||||
|
onclick: async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(invite.shareText);
|
||||||
|
copyTextBtn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => { copyTextBtn.textContent = 'Copy message'; }, 2000);
|
||||||
|
} catch (e) {
|
||||||
|
window.errorHandler && window.errorHandler.show('Clipboard blocked: select the text manually.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
shareActions.appendChild(copyTextBtn);
|
||||||
|
// Native share sheet on mobile / supported browsers. Falls back silently
|
||||||
|
// (the copy buttons cover the same intent).
|
||||||
|
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
|
||||||
|
const nativeShareBtn = _el('button', {
|
||||||
|
class: 'btn-sm', style: 'padding:4px 10px',
|
||||||
|
text: 'Share via…',
|
||||||
|
onclick: async () => {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: 'DashCaddy invite',
|
||||||
|
text: invite.shareText,
|
||||||
|
url: invite.acceptUrl,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// User-cancelled throws AbortError — that's fine, just stay quiet.
|
||||||
|
if (e && e.name && e.name !== 'AbortError') {
|
||||||
|
window.errorHandler && window.errorHandler.show('Share failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
shareActions.appendChild(nativeShareBtn);
|
||||||
|
}
|
||||||
|
shareBlock.appendChild(shareActions);
|
||||||
|
banner.appendChild(shareBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invite.deliveredVia === 'failed') {
|
||||||
banner.appendChild(_el('p', {
|
banner.appendChild(_el('p', {
|
||||||
style: 'margin-top:8px;color:#fbbf24;font-size:0.8rem',
|
style: 'margin-top:8px;color:#fbbf24;font-size:0.8rem',
|
||||||
text: 'SMTP not configured — the invite was logged to the server console (search for [DC-048-DEV-INVITE-LINK]).',
|
text: 'Email could not be sent (SMTP not configured). Share the link above instead — it works the same way.',
|
||||||
}));
|
}));
|
||||||
} else if (invite.deliveredVia === 'email') {
|
} else if (invite.deliveredVia === 'email') {
|
||||||
banner.appendChild(_el('p', {
|
banner.appendChild(_el('p', {
|
||||||
style: 'margin-top:8px;color:#86efac;font-size:0.8rem',
|
style: 'margin-top:8px;color:#86efac;font-size:0.8rem',
|
||||||
text: 'Email sent to ' + invite.email + '.',
|
text: 'Email sent to ' + invite.email + '.',
|
||||||
}));
|
}));
|
||||||
|
} else if (invite.deliveredVia === 'manual') {
|
||||||
|
banner.appendChild(_el('p', {
|
||||||
|
style: 'margin-top:8px;color:#86efac;font-size:0.8rem',
|
||||||
|
text: 'Share the link above via text, chat, or any messenger.',
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
parent.appendChild(banner);
|
parent.appendChild(banner);
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-2
@@ -267,6 +267,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSsoHandoffTarget(returnUrl, token) {
|
||||||
|
const parsed = new URL(returnUrl, window.location.origin);
|
||||||
|
if (parsed.origin === window.location.origin) return parsed.toString();
|
||||||
|
|
||||||
|
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||||
|
const isPrivateHost = parsed.hostname === suffix.slice(1) || parsed.hostname.endsWith(suffix);
|
||||||
|
if (parsed.protocol !== 'https:' || !isPrivateHost || !token) return null;
|
||||||
|
|
||||||
|
const returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||||
|
parsed.pathname = '/dashcaddy-sso';
|
||||||
|
parsed.search = '';
|
||||||
|
parsed.hash = '';
|
||||||
|
parsed.searchParams.set('token', token);
|
||||||
|
parsed.searchParams.set('return', returnPath);
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumeExistingSession(returnUrl) {
|
||||||
|
if (!returnUrl || !isAllowedReturnUrl(returnUrl)) return false;
|
||||||
|
try {
|
||||||
|
const parsedReturn = new URL(returnUrl, window.location.origin);
|
||||||
|
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||||
|
const serviceId = parsedReturn.hostname.slice(0, -suffix.length);
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(serviceId)) return false;
|
||||||
|
const res = await fetch(`/api/v1/auth/sso-handoff?serviceId=${encodeURIComponent(serviceId)}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const data = await res.json();
|
||||||
|
const target = data.success && buildSsoHandoffTarget(returnUrl, data.ssoToken);
|
||||||
|
if (!target) return false;
|
||||||
|
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
|
||||||
|
window.location.replace(target);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
if (urlParams.get('auth') === 'required') {
|
if (urlParams.get('auth') === 'required') {
|
||||||
// Preserve the gated service destination so submitTotpCode() can append
|
// Preserve the gated service destination so submitTotpCode() can append
|
||||||
@@ -277,8 +317,12 @@
|
|||||||
}
|
}
|
||||||
// Clean URL — happens after we've captured the redirect
|
// Clean URL — happens after we've captured the redirect
|
||||||
window.history.replaceState({}, '', window.location.pathname);
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
// Show on next tick so the DOM (the .totp-card) is ready
|
// Reuse the valid status.sami session first. Only show the TOTP/provider
|
||||||
setTimeout(show, 0);
|
// challenge when that session is genuinely absent or expired.
|
||||||
|
setTimeout(async () => {
|
||||||
|
if (await resumeExistingSession(returnUrl)) return;
|
||||||
|
await show();
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose for hot-trigger from other modules (e.g. logout)
|
// Expose for hot-trigger from other modules (e.g. logout)
|
||||||
|
|||||||
@@ -33,6 +33,20 @@
|
|||||||
return server?.name || dnsId.toUpperCase();
|
return server?.name || dnsId.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requireSuccessfulDnsMutation(response, label) {
|
||||||
|
if (!response) throw new Error(`${label} failed: no response`);
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch (_) {
|
||||||
|
throw new Error(`${label} failed: invalid server response`);
|
||||||
|
}
|
||||||
|
if (!response.ok || data?.success !== true) {
|
||||||
|
throw new Error(data?.error || `${label} failed (${response.status || 'unknown status'})`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
/** Build per-server credential form sections from SITE.dnsServers */
|
/** Build per-server credential form sections from SITE.dnsServers */
|
||||||
function buildCredentialSections() {
|
function buildCredentialSections() {
|
||||||
const container = document.getElementById('dns-cred-sections');
|
const container = document.getElementById('dns-cred-sections');
|
||||||
@@ -258,14 +272,6 @@
|
|||||||
document.getElementById('token-save')?.addEventListener('click', async () => {
|
document.getElementById('token-save')?.addEventListener('click', async () => {
|
||||||
const dnsIds = getDnsIds();
|
const dnsIds = getDnsIds();
|
||||||
|
|
||||||
// Save all to localStorage
|
|
||||||
dnsIds.forEach(dnsId => {
|
|
||||||
setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim());
|
|
||||||
setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim());
|
|
||||||
setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim());
|
|
||||||
setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build per-server credentials payload for backend sync
|
// Build per-server credentials payload for backend sync
|
||||||
const servers = {};
|
const servers = {};
|
||||||
let hasAnyCreds = false;
|
let hasAnyCreds = false;
|
||||||
@@ -304,45 +310,36 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ servers })
|
body: JSON.stringify({ servers })
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await requireSuccessfulDnsMutation(res, 'DNS credential save');
|
||||||
|
|
||||||
if (data.results) {
|
if (data.results) {
|
||||||
dnsIds.forEach(dnsId => {
|
const failed = Object.keys(servers).filter(dnsId => data.results[dnsId]?.success !== true);
|
||||||
const statusEl = document.getElementById(`${dnsId}-token-status`);
|
if (failed.length) {
|
||||||
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
|
const details = failed.map(dnsId => data.results[dnsId]?.error || `${dnsId} failed`).join('; ');
|
||||||
const result = data.results[dnsId];
|
throw new Error(details);
|
||||||
if (result?.success) {
|
}
|
||||||
statusEl.textContent = '\u2713 Verified & saved';
|
|
||||||
statusEl.className = 'token-status success';
|
|
||||||
} else if (result?.partial) {
|
|
||||||
statusEl.textContent = '\u2713 ' + result.partial;
|
|
||||||
statusEl.className = 'token-status success';
|
|
||||||
} else {
|
|
||||||
statusEl.textContent = '\u2717 ' + (result?.error || 'Login failed');
|
|
||||||
statusEl.className = 'token-status error';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (data.success) {
|
|
||||||
dnsIds.forEach(dnsId => {
|
|
||||||
if (servers[dnsId]) {
|
|
||||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved';
|
|
||||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
dnsIds.forEach(dnsId => {
|
|
||||||
if (servers[dnsId]) {
|
|
||||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (data.error || 'Failed');
|
|
||||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache locally only after the encrypted server vault confirms success.
|
||||||
|
dnsIds.forEach(dnsId => {
|
||||||
|
setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim());
|
||||||
|
setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim());
|
||||||
|
setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim());
|
||||||
|
setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
dnsIds.forEach(dnsId => {
|
||||||
|
const statusEl = document.getElementById(`${dnsId}-token-status`);
|
||||||
|
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
|
||||||
|
const result = data.results?.[dnsId];
|
||||||
|
statusEl.textContent = result?.partial ? '\u2713 ' + result.partial : '\u2713 Verified & saved';
|
||||||
|
statusEl.className = 'token-status success';
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to sync DNS credentials to backend:', e);
|
console.error('Failed to sync DNS credentials to backend:', e);
|
||||||
dnsIds.forEach(dnsId => {
|
dnsIds.forEach(dnsId => {
|
||||||
if (servers[dnsId]) {
|
if (servers[dnsId]) {
|
||||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved locally (sync failed)';
|
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (e.message || 'Save failed');
|
||||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status';
|
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -368,18 +365,24 @@
|
|||||||
|
|
||||||
document.getElementById('token-clear-all')?.addEventListener('click', async () => {
|
document.getElementById('token-clear-all')?.addEventListener('click', async () => {
|
||||||
if (confirm('Clear all stored DNS credentials? This cannot be undone.')) {
|
if (confirm('Clear all stored DNS credentials? This cannot be undone.')) {
|
||||||
clearAllCredentials();
|
|
||||||
getDnsIds().forEach(dnsId => {
|
|
||||||
document.getElementById(`${dnsId}-readonly-username`).value = '';
|
|
||||||
document.getElementById(`${dnsId}-readonly-token`).value = '';
|
|
||||||
document.getElementById(`${dnsId}-admin-username`).value = '';
|
|
||||||
document.getElementById(`${dnsId}-admin-token`).value = '';
|
|
||||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
|
|
||||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
|
const response = await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
|
||||||
} catch (_) {}
|
await requireSuccessfulDnsMutation(response, 'DNS credential removal');
|
||||||
|
clearAllCredentials();
|
||||||
|
getDnsIds().forEach(dnsId => {
|
||||||
|
document.getElementById(`${dnsId}-readonly-username`).value = '';
|
||||||
|
document.getElementById(`${dnsId}-readonly-token`).value = '';
|
||||||
|
document.getElementById(`${dnsId}-admin-username`).value = '';
|
||||||
|
document.getElementById(`${dnsId}-admin-token`).value = '';
|
||||||
|
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
|
||||||
|
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
getDnsIds().forEach(dnsId => {
|
||||||
|
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (e.message || 'Clear failed');
|
||||||
|
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,9 @@
|
|||||||
await window.loadServices();
|
await window.loadServices();
|
||||||
await loadTemplateCategories();
|
await loadTemplateCategories();
|
||||||
window.buildGrid();
|
window.buildGrid();
|
||||||
|
if (typeof window.openRequestedCredentialForm === 'function') {
|
||||||
|
window.openRequestedCredentialForm();
|
||||||
|
}
|
||||||
animateTopCards();
|
animateTopCards();
|
||||||
window.refreshAll();
|
window.refreshAll();
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// ===== ENCRYPTED VAULT -> SERVICE SSO HANDOFF =====
|
||||||
|
(function() {
|
||||||
|
function isAllowedReturnUrl(returnUrl, expectedServiceId) {
|
||||||
|
if (!returnUrl || !expectedServiceId || !/^[a-z0-9][a-z0-9-]*$/.test(expectedServiceId)) return false;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(returnUrl, window.location.origin);
|
||||||
|
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||||
|
const expectedHost = `${expectedServiceId}${suffix}`;
|
||||||
|
return parsed.protocol === 'https:' && parsed.hostname === expectedHost;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHandoffTarget(returnUrl, token, expectedServiceId) {
|
||||||
|
if (!isAllowedReturnUrl(returnUrl, expectedServiceId)) return null;
|
||||||
|
const parsed = new URL(returnUrl, window.location.origin);
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||||
|
// The shared (dashcaddy_auth) Caddy snippet installs this public landing
|
||||||
|
// route on every protected host. It rewrites to /api/v1/auth/sso-exchange.
|
||||||
|
parsed.pathname = '/dashcaddy-sso';
|
||||||
|
parsed.search = '';
|
||||||
|
parsed.hash = '';
|
||||||
|
parsed.searchParams.set('token', token);
|
||||||
|
parsed.searchParams.set('return', returnPath);
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resume(returnUrl, expectedServiceId, runtime = {}) {
|
||||||
|
if (!isAllowedReturnUrl(returnUrl, expectedServiceId)) return false;
|
||||||
|
const fetchFn = runtime.fetch || window.fetch.bind(window);
|
||||||
|
const locationObj = runtime.location || window.location;
|
||||||
|
try {
|
||||||
|
const response = await fetchFn(`/api/v1/auth/sso-handoff?serviceId=${encodeURIComponent(expectedServiceId)}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const data = await response.json();
|
||||||
|
const target = data.success && buildHandoffTarget(returnUrl, data.ssoToken, expectedServiceId);
|
||||||
|
if (!target) return false;
|
||||||
|
locationObj.replace(target);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.DCCredentialVault = { isAllowedReturnUrl, buildHandoffTarget, resume };
|
||||||
|
})();
|
||||||
@@ -32,8 +32,8 @@
|
|||||||
|
|
||||||
injectModal('service-creds-modal', `<div id="service-creds-modal">
|
injectModal('service-creds-modal', `<div id="service-creds-modal">
|
||||||
<div class="service-creds-content">
|
<div class="service-creds-content">
|
||||||
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Service Credentials</h3>
|
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Encrypted Credential Vault</h3>
|
||||||
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Credentials are injected automatically when accessing this service.</p>
|
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Passwords are encrypted at rest and used automatically when you open this service.</p>
|
||||||
|
|
||||||
<!-- Status indicator -->
|
<!-- Status indicator -->
|
||||||
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;">
|
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;">
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
<!-- Buttons -->
|
<!-- Buttons -->
|
||||||
<div style="display: flex; gap: 8px; margin-top: 14px;">
|
<div style="display: flex; gap: 8px; margin-top: 14px;">
|
||||||
<button id="svc-creds-save" class="btn-accent-solid" style="flex: 1; padding: 9px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;">
|
<button id="svc-creds-save" class="btn-accent-solid" style="flex: 1; padding: 9px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;">
|
||||||
Save
|
Save to encrypted vault
|
||||||
</button>
|
</button>
|
||||||
<button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;">
|
<button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;">
|
||||||
Clear
|
Clear
|
||||||
@@ -105,6 +105,8 @@
|
|||||||
|
|
||||||
const modal = document.getElementById('service-creds-modal');
|
const modal = document.getElementById('service-creds-modal');
|
||||||
let currentService = null;
|
let currentService = null;
|
||||||
|
let credentialReturnUrl = null;
|
||||||
|
let currentServiceHadCreds = false;
|
||||||
const arrServices = ['sonarr', 'radarr', 'prowlarr', 'overseerr'];
|
const arrServices = ['sonarr', 'radarr', 'prowlarr', 'overseerr'];
|
||||||
const qualityProfileServices = ['sonarr', 'radarr'];
|
const qualityProfileServices = ['sonarr', 'radarr'];
|
||||||
|
|
||||||
@@ -124,8 +126,28 @@
|
|||||||
el.style.display = 'none';
|
el.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
window.openServiceCredsModal = async function(service) {
|
async function requireSuccessfulWrite(response, label) {
|
||||||
|
if (!response) throw new Error(`${label} failed: no response`);
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch (_) {
|
||||||
|
throw new Error(`${label} failed: invalid server response`);
|
||||||
|
}
|
||||||
|
if (!response.ok || data?.success !== true) {
|
||||||
|
throw new Error(data?.error || `${label} failed (${response.status || 'unknown status'})`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedCredentialReturnUrl(returnUrl, serviceId) {
|
||||||
|
return !!window.DCCredentialVault?.isAllowedReturnUrl(returnUrl, serviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openServiceCredsModal = async function(service, options = {}) {
|
||||||
currentService = service;
|
currentService = service;
|
||||||
|
credentialReturnUrl = isAllowedCredentialReturnUrl(options.returnUrl, service.id) ? options.returnUrl : null;
|
||||||
|
currentServiceHadCreds = false;
|
||||||
hideError();
|
hideError();
|
||||||
const title = document.getElementById('svc-creds-title');
|
const title = document.getElementById('svc-creds-title');
|
||||||
const desc = document.getElementById('svc-creds-desc');
|
const desc = document.getElementById('svc-creds-desc');
|
||||||
@@ -134,7 +156,10 @@
|
|||||||
const basicSection = document.getElementById('svc-creds-basic');
|
const basicSection = document.getElementById('svc-creds-basic');
|
||||||
const qualitySection = document.getElementById('svc-creds-quality');
|
const qualitySection = document.getElementById('svc-creds-quality');
|
||||||
|
|
||||||
title.textContent = service.name + ' Credentials';
|
title.textContent = service.name + ' — Encrypted Vault';
|
||||||
|
document.getElementById('svc-creds-save').textContent = credentialReturnUrl
|
||||||
|
? 'Save to vault & open service'
|
||||||
|
: 'Save to encrypted vault';
|
||||||
// Determine which sections to show
|
// Determine which sections to show
|
||||||
const isExt = !!service.isExternal;
|
const isExt = !!service.isExternal;
|
||||||
const isArr = arrServices.includes(service.id) || arrServices.includes(service.appTemplate);
|
const isArr = arrServices.includes(service.id) || arrServices.includes(service.appTemplate);
|
||||||
@@ -214,6 +239,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasCreds) {
|
if (hasCreds) {
|
||||||
|
currentServiceHadCreds = true;
|
||||||
dot.style.background = 'var(--ok-fg, #74dfc4)';
|
dot.style.background = 'var(--ok-fg, #74dfc4)';
|
||||||
status.style.color = 'var(--ok-fg, #74dfc4)';
|
status.style.color = 'var(--ok-fg, #74dfc4)';
|
||||||
status.textContent = 'Credentials stored';
|
status.textContent = 'Credentials stored';
|
||||||
@@ -352,16 +378,35 @@
|
|||||||
const isArr = arrServices.includes(currentService.id) || arrServices.includes(currentService.appTemplate);
|
const isArr = arrServices.includes(currentService.id) || arrServices.includes(currentService.appTemplate);
|
||||||
const svcId = currentService.id || currentService.appTemplate;
|
const svcId = currentService.id || currentService.appTemplate;
|
||||||
|
|
||||||
|
if (credentialReturnUrl && !currentServiceHadCreds) {
|
||||||
|
const externalUser = document.getElementById('svc-seedhost-user').value.trim();
|
||||||
|
const externalPass = document.getElementById('svc-seedhost-pass').value;
|
||||||
|
const apiKeyInput = document.getElementById('svc-apikey-input');
|
||||||
|
const requestedApiKey = apiKeyInput?.value.trim();
|
||||||
|
const basicUser = document.getElementById('svc-basic-user').value.trim();
|
||||||
|
const basicPass = document.getElementById('svc-basic-pass').value;
|
||||||
|
const hasExternalLogin = currentService.isExternal && externalUser && externalPass;
|
||||||
|
const hasApiKey = isArr && requestedApiKey && requestedApiKey !== '••••••••';
|
||||||
|
const hasBasicLogin = !currentService.isExternal && basicUser && basicPass;
|
||||||
|
if (!hasExternalLogin && !hasApiKey && !hasBasicLogin) {
|
||||||
|
showError('Enter the login or API key DashCaddy should store for this service.');
|
||||||
|
saveBtn.textContent = 'Save to vault & open service';
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save seedhost creds (shared username + per-service password)
|
// Save seedhost creds (shared username + per-service password)
|
||||||
if (currentService.isExternal) {
|
if (currentService.isExternal) {
|
||||||
const user = document.getElementById('svc-seedhost-user').value.trim();
|
const user = document.getElementById('svc-seedhost-user').value.trim();
|
||||||
const pass = document.getElementById('svc-seedhost-pass').value;
|
const pass = document.getElementById('svc-seedhost-pass').value;
|
||||||
if (user) {
|
if (user) {
|
||||||
await secureFetch('/api/v1/seedhost-creds', {
|
const response = await secureFetch('/api/v1/seedhost-creds', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username: user, password: pass || undefined, serviceId: currentService.id })
|
body: JSON.stringify({ username: user, password: pass || undefined, serviceId: currentService.id })
|
||||||
});
|
});
|
||||||
|
await requireSuccessfulWrite(response, 'Seedhost credential save');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,23 +432,18 @@
|
|||||||
qualityProfileName: qualityProfileName || undefined
|
qualityProfileName: qualityProfileName || undefined
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await requireSuccessfulWrite(res, 'ARR credential save');
|
||||||
if (!data.success) {
|
|
||||||
showError(data.error || 'Failed to save API key');
|
|
||||||
saveBtn.textContent = 'Save';
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (data.connectionTest && !data.connectionTest.success) {
|
if (data.connectionTest && !data.connectionTest.success) {
|
||||||
showError(`API key saved but connection test failed: ${data.connectionTest.error}`);
|
showError(`API key saved but connection test failed: ${data.connectionTest.error}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Non-arr services use the generic endpoint
|
// Non-arr services use the generic endpoint
|
||||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ apiKey })
|
body: JSON.stringify({ apiKey })
|
||||||
});
|
});
|
||||||
|
await requireSuccessfulWrite(response, 'API key save');
|
||||||
}
|
}
|
||||||
} else if (isArr && qualityProfileServices.includes(svcId)) {
|
} else if (isArr && qualityProfileServices.includes(svcId)) {
|
||||||
// API key unchanged but user may have changed quality profile — save profile only
|
// API key unchanged but user may have changed quality profile — save profile only
|
||||||
@@ -411,11 +451,12 @@
|
|||||||
const qualityProfileId = qualSelect?.value ? parseInt(qualSelect.value) : undefined;
|
const qualityProfileId = qualSelect?.value ? parseInt(qualSelect.value) : undefined;
|
||||||
const qualityProfileName = qualSelect?.selectedOptions?.[0]?.textContent || undefined;
|
const qualityProfileName = qualSelect?.selectedOptions?.[0]?.textContent || undefined;
|
||||||
if (qualityProfileId) {
|
if (qualityProfileId) {
|
||||||
await secureFetch('/api/v1/arr/quality-profiles', {
|
const response = await secureFetch('/api/v1/arr/quality-profiles', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ service: svcId, qualityProfileId, qualityProfileName })
|
body: JSON.stringify({ service: svcId, qualityProfileId, qualityProfileName })
|
||||||
});
|
});
|
||||||
|
await requireSuccessfulWrite(response, 'Quality profile save');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,20 +465,28 @@
|
|||||||
const user = document.getElementById('svc-basic-user').value.trim();
|
const user = document.getElementById('svc-basic-user').value.trim();
|
||||||
const pass = document.getElementById('svc-basic-pass').value;
|
const pass = document.getElementById('svc-basic-pass').value;
|
||||||
if (user && pass) {
|
if (user && pass) {
|
||||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username: user, password: pass })
|
body: JSON.stringify({ username: user, password: pass })
|
||||||
});
|
});
|
||||||
|
await requireSuccessfulWrite(response, 'Service credential save');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadServiceCreds(currentService);
|
await loadServiceCreds(currentService);
|
||||||
|
if (credentialReturnUrl) {
|
||||||
|
const returnUrl = credentialReturnUrl;
|
||||||
|
const resumed = await window.DCCredentialVault?.resume(returnUrl, currentService.id);
|
||||||
|
if (!resumed) throw new Error('Credential saved, but the secure service handoff failed. Try opening the service again.');
|
||||||
|
credentialReturnUrl = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorHandler.logError('[ServiceCredentials] Save', e, { function: 'saveCredentials' });
|
errorHandler.logError('[ServiceCredentials] Save', e, { function: 'saveCredentials' });
|
||||||
showError('Failed to save: ' + (e.message || 'Unknown error'));
|
showError('Failed to save: ' + (e.message || 'Unknown error'));
|
||||||
}
|
}
|
||||||
saveBtn.textContent = 'Save';
|
saveBtn.textContent = credentialReturnUrl ? 'Save to vault & open service' : 'Save to encrypted vault';
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -450,12 +499,15 @@
|
|||||||
const svcId = currentService.id || currentService.appTemplate;
|
const svcId = currentService.id || currentService.appTemplate;
|
||||||
const isArr = arrServices.includes(svcId);
|
const isArr = arrServices.includes(svcId);
|
||||||
if (currentService.isExternal) {
|
if (currentService.isExternal) {
|
||||||
await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' });
|
const response = await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' });
|
||||||
|
await requireSuccessfulWrite(response, 'Seedhost credential removal');
|
||||||
}
|
}
|
||||||
// Delete from both namespaces
|
// Delete from both namespaces
|
||||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' });
|
const response = await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' });
|
||||||
|
await requireSuccessfulWrite(response, 'Service credential removal');
|
||||||
if (isArr) {
|
if (isArr) {
|
||||||
await secureFetch(`/api/v1/arr/credentials/${svcId}`, { method: 'DELETE' });
|
const arrResponse = await secureFetch(`/api/v1/arr/credentials/${svcId}`, { method: 'DELETE' });
|
||||||
|
await requireSuccessfulWrite(arrResponse, 'ARR credential removal');
|
||||||
}
|
}
|
||||||
const btn = document.getElementById(`creds-btn-${currentService.id}`);
|
const btn = document.getElementById(`creds-btn-${currentService.id}`);
|
||||||
if (btn) btn.classList.remove('has-creds');
|
if (btn) btn.classList.remove('has-creds');
|
||||||
@@ -470,11 +522,13 @@
|
|||||||
document.getElementById('svc-creds-close')?.addEventListener('click', () => {
|
document.getElementById('svc-creds-close')?.addEventListener('click', () => {
|
||||||
modal.classList.remove('show');
|
modal.classList.remove('show');
|
||||||
currentService = null;
|
currentService = null;
|
||||||
|
credentialReturnUrl = null;
|
||||||
});
|
});
|
||||||
modal?.addEventListener('click', (e) => {
|
modal?.addEventListener('click', (e) => {
|
||||||
if (e.target === modal) {
|
if (e.target === modal) {
|
||||||
modal.classList.remove('show');
|
modal.classList.remove('show');
|
||||||
currentService = null;
|
currentService = null;
|
||||||
|
credentialReturnUrl = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -501,4 +555,18 @@
|
|||||||
}
|
}
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Protected service login pages send missing credentials here. Reuse the
|
||||||
|
// normal vault form, then resume through the existing one-time SSO handoff.
|
||||||
|
window.openRequestedCredentialForm = function() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const serviceId = params.get('credentials');
|
||||||
|
if (!serviceId) return false;
|
||||||
|
const service = (window.APPS || []).find(app => app.id === serviceId || app.appTemplate === serviceId);
|
||||||
|
if (!service) return false;
|
||||||
|
const returnUrl = params.get('return');
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
window.openServiceCredsModal(service, { returnUrl });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
+12
-2
@@ -90,11 +90,22 @@
|
|||||||
errorEl.textContent = 'Verifying...';
|
errorEl.textContent = 'Verifying...';
|
||||||
errorEl.className = 'totp-error verifying';
|
errorEl.className = 'totp-error verifying';
|
||||||
|
|
||||||
|
const redirect = safeSessionGet('totp_redirect');
|
||||||
|
let serviceId = null;
|
||||||
|
if (redirect) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(redirect, window.location.origin);
|
||||||
|
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||||
|
const candidate = parsed.hostname.slice(0, -suffix.length);
|
||||||
|
if (parsed.hostname.endsWith(suffix) && /^[a-z0-9][a-z0-9-]*$/.test(candidate)) serviceId = candidate;
|
||||||
|
} catch (_) { /* invalid redirect is handled by the normal auth flow */ }
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await secureFetch('/api/v1/totp/verify', {
|
const res = await secureFetch('/api/v1/totp/verify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ code })
|
body: JSON.stringify({ code, serviceId })
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
@@ -106,7 +117,6 @@
|
|||||||
}
|
}
|
||||||
hideTotpOverlay();
|
hideTotpOverlay();
|
||||||
// Check if redirected here from another service
|
// Check if redirected here from another service
|
||||||
const redirect = safeSessionGet('totp_redirect');
|
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
|
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
|
||||||
// .sami is an unregistered TLD, so browsers silently drop the
|
// .sami is an unregistered TLD, so browsers silently drop the
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'dashcaddy-shell-a24ef15882';
|
const CACHE = 'dashcaddy-shell-497e1f671c';
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|||||||
@@ -93,3 +93,41 @@ test('same-origin and tokenless destinations keep their direct URL', () => {
|
|||||||
assert.equal(buildHandoffTarget('/settings', 'one-time'), 'https://status.sami/settings');
|
assert.equal(buildHandoffTarget('/settings', 'one-time'), 'https://status.sami/settings');
|
||||||
assert.equal(buildHandoffTarget('https://router.sami/config', ''), 'https://router.sami/config');
|
assert.equal(buildHandoffTarget('https://router.sami/config', ''), 'https://router.sami/config');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an existing status.sami session returns to a service without another TOTP prompt', async () => {
|
||||||
|
const query = new URLSearchParams({ auth: 'required', return: 'https://plex.sami/web/' });
|
||||||
|
let scheduled;
|
||||||
|
let redirected;
|
||||||
|
const location = {
|
||||||
|
origin: 'https://status.sami',
|
||||||
|
pathname: '/',
|
||||||
|
search: `?${query.toString()}`,
|
||||||
|
replace(value) { redirected = value; },
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
URL,
|
||||||
|
URLSearchParams,
|
||||||
|
SITE: { tld: '.sami' },
|
||||||
|
sessionStorage: { setItem() {} },
|
||||||
|
document: { getElementById() { return null; } },
|
||||||
|
setTimeout(fn) { scheduled = fn; },
|
||||||
|
console,
|
||||||
|
fetch: async (url) => {
|
||||||
|
assert.equal(url, '/api/v1/auth/sso-handoff?serviceId=plex');
|
||||||
|
return { ok: true, json: async () => ({ success: true, ssoToken: 'existing-session-token' }) };
|
||||||
|
},
|
||||||
|
window: {
|
||||||
|
location,
|
||||||
|
history: { replaceState() {} },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
context.window.window = context.window;
|
||||||
|
vm.runInNewContext(source, context, { filename: 'auth-gate.js' });
|
||||||
|
|
||||||
|
assert.equal(typeof scheduled, 'function');
|
||||||
|
await scheduled();
|
||||||
|
assert.equal(
|
||||||
|
redirected,
|
||||||
|
'https://plex.sami/dashcaddy-sso?token=existing-session-token&return=%2Fweb%2F',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const { JSDOM } = require('jsdom');
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const handoffSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'credential-vault-handoff.js'), 'utf8');
|
||||||
|
const formSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'service-credentials.js'), 'utf8');
|
||||||
|
const initSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'core', 'init.js'), 'utf8');
|
||||||
|
|
||||||
|
function loadVault() {
|
||||||
|
const window = { location: { origin: 'https://status.sami' } };
|
||||||
|
const context = vm.createContext({ window, SITE: { tld: '.sami' }, URL });
|
||||||
|
vm.runInContext(handoffSource, context);
|
||||||
|
return window.DCCredentialVault;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exerciseFailedModalWrite({
|
||||||
|
service,
|
||||||
|
fetchJson,
|
||||||
|
setupInputs,
|
||||||
|
expectedEndpoint,
|
||||||
|
writeResponse,
|
||||||
|
expectedError = /vault write rejected/,
|
||||||
|
}) {
|
||||||
|
const dom = new JSDOM('<!doctype html><body></body>', {
|
||||||
|
url: 'https://status.sami/',
|
||||||
|
runScripts: 'outside-only',
|
||||||
|
});
|
||||||
|
const { window } = dom;
|
||||||
|
const writeUrls = [];
|
||||||
|
let resumeCalls = 0;
|
||||||
|
window.ErrorHandler = class { logError() {} };
|
||||||
|
window.SITE = { tld: '.sami' };
|
||||||
|
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
||||||
|
window.fetch = async (url) => ({ ok: true, json: async () => fetchJson(url) });
|
||||||
|
window.secureFetch = async (url) => {
|
||||||
|
writeUrls.push(url);
|
||||||
|
return writeResponse || {
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({ success: false, error: 'vault write rejected' }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
window.DCCredentialVault = {
|
||||||
|
isAllowedReturnUrl: () => true,
|
||||||
|
resume: async () => { resumeCalls++; return true; },
|
||||||
|
};
|
||||||
|
window.confirm = () => true;
|
||||||
|
window.eval(formSource);
|
||||||
|
|
||||||
|
await window.openServiceCredsModal(service, { returnUrl: `https://${service.id}.sami/` });
|
||||||
|
setupInputs(window.document);
|
||||||
|
window.document.getElementById('svc-creds-save').click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
assert.equal(writeUrls[0], expectedEndpoint);
|
||||||
|
assert.equal(resumeCalls, 0);
|
||||||
|
assert.match(window.document.getElementById('svc-creds-error').textContent, expectedError);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('existing dashboard session mints a one-time token and resumes on the target host', async () => {
|
||||||
|
const vault = loadVault();
|
||||||
|
const calls = [];
|
||||||
|
const replacements = [];
|
||||||
|
const resumed = await vault.resume('https://plex.sami/web/?direct=1#home', 'plex', {
|
||||||
|
fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, ssoToken: 'one-time-token' }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
location: { replace: (target) => replacements.push(target) },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resumed, true);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].url, '/api/v1/auth/sso-handoff?serviceId=plex');
|
||||||
|
assert.equal(calls[0].options.credentials, 'include');
|
||||||
|
assert.equal(calls[0].options.cache, 'no-store');
|
||||||
|
assert.equal(
|
||||||
|
replacements[0],
|
||||||
|
'https://plex.sami/dashcaddy-sso?token=one-time-token&return=%2Fweb%2F%3Fdirect%3D1%23home',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vault handoff rejects an external return URL before minting a token', async () => {
|
||||||
|
const vault = loadVault();
|
||||||
|
let fetchCalled = false;
|
||||||
|
const resumed = await vault.resume('https://plex.sami.evil.example/phish', 'plex', {
|
||||||
|
fetch: async () => { fetchCalled = true; },
|
||||||
|
location: { replace: () => assert.fail('must not navigate') },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resumed, false);
|
||||||
|
assert.equal(fetchCalled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('credential request opens the form and save path calls the tested handoff helper', () => {
|
||||||
|
assert.match(formSource, /params\.get\('credentials'\)/);
|
||||||
|
assert.match(formSource, /openServiceCredsModal\(service, \{ returnUrl \}\)/);
|
||||||
|
assert.match(formSource, /DCCredentialVault\?\.resume\(returnUrl, currentService\.id\)/);
|
||||||
|
assert.match(initSource, /openRequestedCredentialForm\(\)/);
|
||||||
|
assert.match(formSource, /Save to vault & open service/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('actual vault modal save handler stores credentials then resumes the handoff', async () => {
|
||||||
|
const dom = new JSDOM('<!doctype html><body></body>', {
|
||||||
|
url: 'https://status.sami/?credentials=plex&return=https%3A%2F%2Fplex.sami%2Fweb%2F',
|
||||||
|
runScripts: 'outside-only',
|
||||||
|
});
|
||||||
|
const { window } = dom;
|
||||||
|
let stored = false;
|
||||||
|
const writes = [];
|
||||||
|
const resumed = [];
|
||||||
|
window.ErrorHandler = class { logError() {} };
|
||||||
|
window.SITE = { tld: '.sami' };
|
||||||
|
window.APPS = [{ id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' }];
|
||||||
|
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
||||||
|
window.fetch = async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
hasApiKey: false,
|
||||||
|
hasBasicAuth: stored,
|
||||||
|
username: stored ? 'vault-user' : null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
window.secureFetch = async (url, options) => {
|
||||||
|
writes.push({ url, body: JSON.parse(options.body) });
|
||||||
|
stored = true;
|
||||||
|
return { ok: true, json: async () => ({ success: true }) };
|
||||||
|
};
|
||||||
|
window.DCCredentialVault = {
|
||||||
|
isAllowedReturnUrl: () => true,
|
||||||
|
resume: async (returnUrl, serviceId) => { resumed.push({ returnUrl, serviceId }); return true; },
|
||||||
|
};
|
||||||
|
window.confirm = () => true;
|
||||||
|
window.eval(formSource);
|
||||||
|
|
||||||
|
await window.openServiceCredsModal(window.APPS[0], { returnUrl: 'https://plex.sami/web/' });
|
||||||
|
window.document.getElementById('svc-basic-user').value = 'vault-user';
|
||||||
|
window.document.getElementById('svc-basic-pass').value = 'vault-password';
|
||||||
|
window.document.getElementById('svc-creds-save').click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
assert.deepEqual(writes, [{
|
||||||
|
url: '/api/v1/services/plex/credentials',
|
||||||
|
body: { username: 'vault-user', password: 'vault-password' },
|
||||||
|
}]);
|
||||||
|
assert.deepEqual(resumed, [{ returnUrl: 'https://plex.sami/web/', serviceId: 'plex' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed credential write does not mint a handoff or navigate', async () => {
|
||||||
|
const dom = new JSDOM('<!doctype html><body></body>', {
|
||||||
|
url: 'https://status.sami/',
|
||||||
|
runScripts: 'outside-only',
|
||||||
|
});
|
||||||
|
const { window } = dom;
|
||||||
|
let resumeCalls = 0;
|
||||||
|
window.ErrorHandler = class { logError() {} };
|
||||||
|
window.SITE = { tld: '.sami' };
|
||||||
|
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
||||||
|
window.fetch = async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
||||||
|
});
|
||||||
|
window.secureFetch = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({ success: false, error: 'vault write rejected' }),
|
||||||
|
});
|
||||||
|
window.DCCredentialVault = {
|
||||||
|
isAllowedReturnUrl: () => true,
|
||||||
|
resume: async () => { resumeCalls++; return true; },
|
||||||
|
};
|
||||||
|
window.confirm = () => true;
|
||||||
|
window.eval(formSource);
|
||||||
|
|
||||||
|
const service = { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' };
|
||||||
|
await window.openServiceCredsModal(service, { returnUrl: 'https://plex.sami/web/' });
|
||||||
|
window.document.getElementById('svc-basic-user').value = 'vault-user';
|
||||||
|
window.document.getElementById('svc-basic-pass').value = 'vault-password';
|
||||||
|
window.document.getElementById('svc-creds-save').click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
assert.equal(resumeCalls, 0);
|
||||||
|
assert.match(window.document.getElementById('svc-creds-error').textContent, /vault write rejected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed ARR credential write does not mint a handoff or navigate', async () => {
|
||||||
|
await exerciseFailedModalWrite({
|
||||||
|
service: { id: 'radarr', name: 'Radarr', appTemplate: 'radarr', url: 'https://radarr.sami' },
|
||||||
|
fetchJson: (url) => url.includes('/services/')
|
||||||
|
? { success: true, hasApiKey: false, hasBasicAuth: false, username: null }
|
||||||
|
: { success: true, profiles: [] },
|
||||||
|
setupInputs: (document) => { document.getElementById('svc-apikey-input').value = 'arr-key'; },
|
||||||
|
expectedEndpoint: '/api/v1/arr/credentials',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed ARR quality-profile write does not mint a handoff or navigate', async () => {
|
||||||
|
await exerciseFailedModalWrite({
|
||||||
|
service: { id: 'radarr', name: 'Radarr', appTemplate: 'radarr', url: 'https://radarr.sami' },
|
||||||
|
fetchJson: (url) => url.includes('/services/')
|
||||||
|
? { success: true, hasApiKey: true, hasBasicAuth: false, username: null }
|
||||||
|
: { success: true, profiles: [{ id: 1, name: 'Default' }], storedProfileId: 1 },
|
||||||
|
setupInputs: () => {},
|
||||||
|
expectedEndpoint: '/api/v1/arr/quality-profiles',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed seedhost write does not mint a handoff or navigate', async () => {
|
||||||
|
await exerciseFailedModalWrite({
|
||||||
|
service: { id: 'torrent', name: 'qBittorrent', isExternal: true, externalUrl: 'https://torrent.sami' },
|
||||||
|
fetchJson: (url) => url.includes('/seedhost-creds')
|
||||||
|
? { success: true, hasCredentials: false, username: null }
|
||||||
|
: { success: true, hasApiKey: false, hasBasicAuth: false, username: null },
|
||||||
|
setupInputs: (document) => {
|
||||||
|
document.getElementById('svc-seedhost-user').value = 'seed-user';
|
||||||
|
document.getElementById('svc-seedhost-pass').value = 'seed-password';
|
||||||
|
},
|
||||||
|
expectedEndpoint: '/api/v1/seedhost-creds',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed generic API-key write does not mint a handoff or navigate', async () => {
|
||||||
|
await exerciseFailedModalWrite({
|
||||||
|
service: { id: 'custom', name: 'Custom', url: 'https://custom.sami' },
|
||||||
|
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
||||||
|
setupInputs: (document) => {
|
||||||
|
document.getElementById('svc-apikey-input').value = 'custom-key';
|
||||||
|
document.getElementById('svc-basic-user').value = 'user';
|
||||||
|
document.getElementById('svc-basic-pass').value = 'password';
|
||||||
|
},
|
||||||
|
expectedEndpoint: '/api/v1/services/custom/credentials',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP 2xx with malformed JSON does not mint a handoff or navigate', async () => {
|
||||||
|
await exerciseFailedModalWrite({
|
||||||
|
service: { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' },
|
||||||
|
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
||||||
|
setupInputs: (document) => {
|
||||||
|
document.getElementById('svc-basic-user').value = 'user';
|
||||||
|
document.getElementById('svc-basic-pass').value = 'password';
|
||||||
|
},
|
||||||
|
expectedEndpoint: '/api/v1/services/plex/credentials',
|
||||||
|
writeResponse: { ok: true, status: 200, json: async () => { throw new Error('bad json'); } },
|
||||||
|
expectedError: /invalid server response/,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP 2xx without success:true does not mint a handoff or navigate', async () => {
|
||||||
|
await exerciseFailedModalWrite({
|
||||||
|
service: { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' },
|
||||||
|
fetchJson: () => ({ success: true, hasApiKey: false, hasBasicAuth: false, username: null }),
|
||||||
|
setupInputs: (document) => {
|
||||||
|
document.getElementById('svc-basic-user').value = 'user';
|
||||||
|
document.getElementById('svc-basic-pass').value = 'password';
|
||||||
|
},
|
||||||
|
expectedEndpoint: '/api/v1/services/plex/credentials',
|
||||||
|
writeResponse: { ok: true, status: 200, json: async () => ({ message: 'ambiguous' }) },
|
||||||
|
expectedError: /failed \(200\)/,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed credential clear remains visibly failed and keeps stored-state UI', async () => {
|
||||||
|
const dom = new JSDOM('<!doctype html><body><button id="creds-btn-plex" class="has-creds"></button></body>', {
|
||||||
|
url: 'https://status.sami/',
|
||||||
|
runScripts: 'outside-only',
|
||||||
|
});
|
||||||
|
const { window } = dom;
|
||||||
|
window.ErrorHandler = class { logError() {} };
|
||||||
|
window.SITE = { tld: '.sami' };
|
||||||
|
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
||||||
|
window.fetch = async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, hasApiKey: false, hasBasicAuth: true, username: 'vault-user' }),
|
||||||
|
});
|
||||||
|
window.secureFetch = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({ success: false, error: 'clear rejected' }),
|
||||||
|
});
|
||||||
|
window.DCCredentialVault = { isAllowedReturnUrl: () => false };
|
||||||
|
window.confirm = () => true;
|
||||||
|
window.eval(formSource);
|
||||||
|
|
||||||
|
const service = { id: 'plex', name: 'Plex', appTemplate: 'plex', url: 'https://plex.sami' };
|
||||||
|
await window.openServiceCredsModal(service);
|
||||||
|
window.document.getElementById('svc-creds-clear').click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
assert.match(window.document.getElementById('svc-creds-error').textContent, /clear rejected/);
|
||||||
|
assert.equal(window.document.getElementById('creds-btn-plex').classList.contains('has-creds'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handoff rejects a private-TLD host that is not the requested protected service', async () => {
|
||||||
|
const vault = loadVault();
|
||||||
|
let fetchCalled = false;
|
||||||
|
const resumed = await vault.resume('https://dns1.sami/', 'plex', {
|
||||||
|
fetch: async () => { fetchCalled = true; },
|
||||||
|
location: { replace: () => assert.fail('must not navigate') },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resumed, false);
|
||||||
|
assert.equal(fetchCalled, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { JSDOM } = require('jsdom');
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '..', 'js', 'core', 'credentials.js'), 'utf8');
|
||||||
|
|
||||||
|
function buildDnsCredentialUi() {
|
||||||
|
const dom = new JSDOM('<!doctype html><body><button id="manage-tokens"></button></body>', {
|
||||||
|
url: 'https://status.sami/',
|
||||||
|
runScripts: 'outside-only',
|
||||||
|
});
|
||||||
|
const { window } = dom;
|
||||||
|
const local = new Map();
|
||||||
|
const session = new Map();
|
||||||
|
window.SITE = { dnsServers: { dns1: { name: 'Primary DNS' } } };
|
||||||
|
window.injectModal = (_id, html) => window.document.body.insertAdjacentHTML('beforeend', html);
|
||||||
|
window.safeGet = key => local.get(key) || null;
|
||||||
|
window.safeSet = (key, value) => local.set(key, value);
|
||||||
|
window.safeRemove = key => local.delete(key);
|
||||||
|
window.safeSessionGet = key => session.get(key) || null;
|
||||||
|
window.safeSessionSet = (key, value) => session.set(key, value);
|
||||||
|
window.closeModal = () => {};
|
||||||
|
window.confirm = () => true;
|
||||||
|
window.TextEncoder = TextEncoder;
|
||||||
|
window.setTimeout = () => 1;
|
||||||
|
window.eval(source);
|
||||||
|
window.document.getElementById('manage-tokens').click();
|
||||||
|
return { window, local };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('failed DNS credential save never populates browser cache or success UI', async () => {
|
||||||
|
const { window, local } = buildDnsCredentialUi();
|
||||||
|
window.secureFetch = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({ success: false, error: 'DNS vault rejected' }),
|
||||||
|
});
|
||||||
|
window.document.getElementById('dns1-admin-username').value = 'dns-admin';
|
||||||
|
window.document.getElementById('dns1-admin-token').value = 'dns-password';
|
||||||
|
window.document.getElementById('token-save').click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
assert.equal(local.has('dns1-admin-username-enc'), false);
|
||||||
|
assert.equal(local.has('dns1-admin-token-enc'), false);
|
||||||
|
assert.match(window.document.getElementById('dns1-token-status').textContent, /DNS vault rejected/);
|
||||||
|
assert.equal(window.document.getElementById('dns1-token-status').classList.contains('success'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed DNS credential clear preserves cached state and shows error', async () => {
|
||||||
|
const { window, local } = buildDnsCredentialUi();
|
||||||
|
local.set('dns1-admin-username-enc', 'existing-user');
|
||||||
|
local.set('dns1-admin-token-enc', 'existing-password');
|
||||||
|
window.secureFetch = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ message: 'ambiguous response' }),
|
||||||
|
});
|
||||||
|
window.document.getElementById('token-clear-all').click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
assert.equal(local.has('dns1-admin-username-enc'), true);
|
||||||
|
assert.equal(local.has('dns1-admin-token-enc'), true);
|
||||||
|
assert.match(window.document.getElementById('dns1-token-status').textContent, /DNS credential removal failed/);
|
||||||
|
assert.equal(window.document.getElementById('dns1-token-status').classList.contains('success'), false);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user