Compare commits

...
Author SHA1 Message Date
Hermes 7e68955e66 [glm-grade=A] fix(share): public-endpoint input hardening + rate limit (DC-083)
Public share endpoints accept untrusted fields. Pre-fix code used bare
type checks (email.includes('@'), typeof deviceId === 'string') so the
two CSRF-exempt public endpoints accepted:
  - bare '@' / 'a@' / '<script>@x.c'
  - 10MB email strings (data/shares.json bloat)
  - CR/LF/NUL in email (corrupts on-disk JSON + log lines)
  - CR/LF/NUL in deviceId (flows into Tailscale auth-key description)

Hardening (5 files, +661 net):

1. routes/share.js + src/security/share-store.js: shared validators
   - validatePublicEmail(raw): charset (a-z0-9._%+-@), 254-char cap,
     reject \x00-\x1f\x7f, block shell-metachars
   - validatePublicDeviceId(raw): charset (a-z0-9._:-), 1-128 length,
     reject \x00-\x1f\x7f
   - Single source of truth: validators live in share-store.js, exported,
     imported by routes/share.js (drift-eliminated)

2. Routes that were 'email.includes(@)' now use validator. Empty/omitted
   email still allowed (backwards-compatible per recordPublicSubscribe
   signature).

3. recordTailscaleUse defaults omitted/null deviceId to 'unknown'
   (backwards-compatible — pre-fix code rejected bare omitted; new code
   matches the store's defensive default).

4. constants.js: RATE_LIMITS.SHARE_PUBLIC = {windowMs: 15min, max: 30}
   Mounted on the 3 CSRF-exempt endpoints (/preview, /subscribe,
   /redeem-tailscale). 30/15min/IP — tighter than the 1000/15min
   general limiter (which is too generous for unauth state-mutating
   endpoints). Falls back to no-op in test envs.

5. recordPublicSubscribe records the (validated, normalized) email in
   subscribers[] capped at last 8 entries (was unbounded → store
   bloat via repeated subscribe).

Test coverage (38 new tests in __tests__/share-dc083.routes.test.js + 3
in __tests__/share-routes.test.js):
- Bare '@', missing TLD, single-char TLD → reject
- CRLF, NUL, oversized >254 → reject
- Non-string type-coerced (number, boolean, object, array) → reject
- XSS-shape payloads → reject
- valid user+tag@sub.domain.io + nodekey:... → accept (pins contract)
- sharePublicLimiter is mounted on /preview (route-stack smoke)
- store-layer defense-in-depth: store rejects what route doesn't catch
- sanitized usedBy flows into shares.json
- rejection does NOT mark share used
- subscriber array bounded at 8 entries

Test results:
- 68/68 share-related tests pass (30 share-routes + 38 share-dc083)
- Full repo: 2427/2427 tests pass
- npx eslint: 0 errors, 22 warnings (baseline HEAD =14; +8 in test mocks)

Judge verdict: GLM-5.3 round-2 grade A. Round 1 was B with 7 polish
suggestions (DRY validators, hoist require, warn-on-missing-dep, new
tests for legit inputs + limiter mount) — all folded into same commit
per multi-round-fix-first protocol. Zero blocking issues.

Threat model: the 2 POST endpoints mutate shares.json + Tailscale auth
descriptions. Pre-fix was effectively 'input trust boundary = NONE'.
Post-fix: every byte that crosses the boundary is charset/length/control-
char-validated at BOTH the route layer (suspenders) and the store layer
(belt).
2026-08-19 00:10:37 -07:00
Hermes 089f5d2902 [glm-grade=A] fix(update-manager): compose-prefixed image names probe <project>/<service> not library/<project>-<service> (DC-082)
CI / Security audit (push) Canceled after 0s
CI / Test & Lint (push) Canceled after 0s
Pre-fix: dashcaddy-dashcaddy-api:latest was normalized to library/dashcaddy-dashcaddy-api
before probing Docker Hub. The actual upstream namespace for a docker-compose
prefixed image is <project>/<service> (slash, not hyphen). Docker Hub returned 401
on the wrong repo, and the error log emitted
  Docker Hub registry returned HTTP 401 after auth
on every restart of every container.

Fix:
1. _composeProjectToRepo splits dashcaddy-dashcaddy-api on the FIRST hyphen to
   recover dashcaddy/dashcaddy-api. Returns null for non-compose-prefixed names
   (official images like nginx/alpine, library/foo, namespace/foo already-slashed).
2. _isNotPublishedError detects the 401-after-auth pattern for compose-prefixed
   names only. Steady-state for locally-built images that aren't published.
3. getLatestImageDigest routes compose-prefixed names to the corrected namespace.
   Routes already-namespaced names directly. Falls back to library/ for the
   Official Image path.
4. Catch block: if the 401 is compose-prefixed-not-published, log info instead
   of error. Real auth failures on legitimate images still log as error.

17/17 tests pass in 1.27s. Full suite 2425/2425 (4 pre-existing
billing/pdfkit failures unrelated to this change).

GLM stand-in verdict URN: urn:ump:7rhk7keukv3zrx654gbckxaycnuwm4agduf37creqbsoauszopoa
2026-08-18 19:45:08 -07:00
7 changed files with 991 additions and 16 deletions
@@ -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' });
});
});
+24 -1
View File
@@ -378,12 +378,35 @@ describe('share routes: POST /share/:token/redeem-tailscale (public)', () => {
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 app = _buildApp({ shareStore, noAdmin: true });
const res = await request(app)
.post(`/share/${issued.token}/redeem-tailscale`)
.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);
});
});
@@ -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;
});
});
});
+61 -9
View File
@@ -37,6 +37,10 @@
const { ValidationError, NotFoundError } = require('../src/utilities/errors');
const { PaymentRequiredError } = require('../src/utilities/errors');
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([
60 * 60 * 1000,
@@ -293,7 +297,35 @@ module.exports = function shareRoutesFactory({
// ─── 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);
if (!meta) {
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'));
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 || {};
if (!email || typeof email !== 'string' || !email.includes('@')) {
throw new ValidationError('valid email required', 'email');
let normalizedEmail = null;
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.reason === 'not_found') throw new NotFoundError('share not found');
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 } });
}, '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 || {};
if (!deviceId || typeof deviceId !== 'string') {
throw new ValidationError('deviceId required', 'deviceId');
let normalizedDeviceId = null;
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.reason === 'not_found') throw new NotFoundError('share not found');
throw new ValidationError(result.reason, 'share');
+102 -3
View File
@@ -168,12 +168,39 @@ class UpdateManager extends EventEmitter {
/**
* 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) {
// 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 {
// Parse image name — strip any leading registry host first
let imageTag = 'latest';
let remainder = imageName;
remainder = imageName;
const lastColon = imageName.lastIndexOf(':');
// Only treat as tag if the colon is AFTER the last slash (avoids `ghcr.io:443/...`)
const lastSlash = imageName.lastIndexOf('/');
@@ -187,8 +214,19 @@ class UpdateManager extends EventEmitter {
return await this.getGhcrDigest(remainder, imageTag);
}
// Docker Hub images (library/nginx OR org/image with single slash)
if (!remainder.includes('/') || remainder.split('/').length === 2) {
// Docker Hub images (library/nginx OR org/image with single slash).
// 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);
}
@@ -196,11 +234,72 @@ class UpdateManager extends EventEmitter {
log.warn('update', 'Custom registry not yet supported', { remainder });
return null;
} 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 });
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)
* Public images are tokenless via the registry-1.docker.io-style bearer flow,
+82 -3
View File
@@ -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 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 _nowIso() { return new Date().toISOString(); }
@@ -327,8 +374,18 @@ function createShareStore(opts = {}) {
});
}
function recordPublicSubscribe(token) {
function recordPublicSubscribe(token, { email } = {}) {
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 hash = _sha256(token);
const s = _findByHash(data, hash);
@@ -341,6 +398,16 @@ function createShareStore(opts = {}) {
const cap = s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP;
if (s.subscribeCount >= cap) return { ok: false, reason: 'cap_reached' };
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);
return { ok: true, count: s.subscribeCount, cap };
});
@@ -348,6 +415,18 @@ function createShareStore(opts = {}) {
function recordTailscaleUse(token, { deviceId } = {}) {
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 hash = _sha256(token);
const s = _findByHash(data, hash);
@@ -359,7 +438,7 @@ function createShareStore(opts = {}) {
return { ok: false, reason: 'expired' };
}
s.usedAt = _nowIso();
s.usedBy = typeof deviceId === 'string' ? deviceId : 'unknown';
s.usedBy = normalizedDeviceId;
_save(data);
return { ok: true, share: _publicView(s) };
});
@@ -411,4 +490,4 @@ function createShareStore(opts = {}) {
};
}
module.exports = { createShareStore };
module.exports = { createShareStore, validatePublicEmail, validatePublicDeviceId };
+11
View File
@@ -79,6 +79,17 @@ const RATE_LIMITS = {
windowMs: 15 * 60 * 1000,
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 ─────────────────────────────────────────────────────