Files
dashcaddy/dashcaddy-api/src/utilities/constants.js
T
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

198 lines
7.5 KiB
JavaScript

// DashCaddy Shared Constants
// Centralizes configuration values used across the application.
// Edit values here instead of hunting through server.js.
// ── App Identity ──────────────────────────────────────────────
const APP = {
NAME: 'DashCaddy',
VERSION: '1.1',
PORT: 3001,
USER_AGENTS: {
PROBE: 'DashCaddy-Probe/1.0',
API: 'DashCaddy/1.0',
HEALTH: 'DashCaddy-HealthCheck/1.0',
},
DEVICE_IDS: {
SSO: 'dashcaddy-sso', // Backend auth gate (never invalidates browser token)
BROWSER: 'dashcaddy-browser', // Browser-side localStorage token
},
};
// ── Default Ports for Media/Arr Apps ──────────────────────────
const APP_PORTS = {
plex: 32400,
radarr: 7878,
sonarr: 8989,
seerr: 5055,
lidarr: 8686,
prowlarr: 9696,
};
// Arr service discovery config (used by /api/arr/* endpoints)
const ARR_SERVICES = {
plex: { names: ['plex'], port: APP_PORTS.plex, configPath: 'Plex' },
radarr: { names: ['radarr'], port: APP_PORTS.radarr, configPath: 'radarr' },
sonarr: { names: ['sonarr'], port: APP_PORTS.sonarr, configPath: 'sonarr' },
seerr: { names: ['seerr', 'requests'], port: APP_PORTS.seerr, configPath: 'seerr' },
lidarr: { names: ['lidarr'], port: APP_PORTS.lidarr, configPath: 'lidarr' },
prowlarr: { names: ['prowlarr'], port: APP_PORTS.prowlarr, configPath: 'prowlarr' },
};
// ── Timeouts (ms) ─────────────────────────────────────────────
const TIMEOUTS = {
HTTP_DEFAULT: 5000, // Standard fetch/http timeout
HTTP_LONG: 10000, // DNS ops, downloads, login requests
DEPLOY_SETTLE: 3000, // Wait after container start before health check
CADDY_PRE_RELOAD: 2000, // Pause before Caddy reload
RETRY_SHORT: 1000, // Short retry delay
RETRY_MEDIUM: 2000, // Medium retry delay
SHUTDOWN_GRACE: 5000, // Graceful shutdown window
SHUTDOWN_ERROR: 1000, // Error shutdown window
PORT_CHECK: 2000, // TCP port availability check
};
// ── Retry Configuration ───────────────────────────────────────
const RETRIES = {
CADDY_RELOAD: 3, // Max attempts to reload Caddy
};
// ── Session / Cache Expiry (ms) ───────────────────────────────
const SESSION_TTL = {
IP_SESSION: 30 * 60 * 1000, // 30 min — router IP-based sessions
COOKIE_SESSION: 30 * 60 * 1000, // 30 min — cookie-based login sessions
TOKEN_SESSION: 60 * 60 * 1000, // 60 min — JWT/access token sessions (Jellyfin, Plex, etc.)
FAILED_LOGIN: 5 * 60 * 1000, // 5 min — cooldown after failed login
DNS_TOKEN: 6 * 60 * 60 * 1000, // 6 hrs — DNS auto-refresh interval
};
// ── Rate Limiting ─────────────────────────────────────────────
const RATE_LIMITS = {
GENERAL: {
windowMs: 15 * 60 * 1000, // 15 minutes
max: 1000,
},
STRICT: {
windowMs: 15 * 60 * 1000,
max: 20,
},
TOTP: {
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 ─────────────────────────────────────────────────────
const CADDY = {
CONTENT_TYPE: 'text/caddyfile',
DEFAULT_DNS_PORT: '5380',
DEFAULT_TTL: 300,
TTL_MIN: 60,
TTL_MAX: 86400,
};
// ── Validation Patterns ─────────────────────────────────────────
const REGEX = {
SUBDOMAIN: /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i,
DOMAIN: /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/i,
};
// ── DNS ─────────────────────────────────────────────────────────
const DNS_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'PTR', 'SOA'];
// ── Docker ──────────────────────────────────────────────────────
const DOCKER = {
CONTAINER_PREFIX: 'sami-',
TIMEOUT: 300000, // 300s — timeout for docker pull/create operations
LOG_CONFIG: {
Type: 'json-file',
Config: { 'max-size': '10m', 'max-file': '3' } // 30MB max per container
},
MAINTENANCE: {
INTERVAL: 24 * 60 * 60 * 1000, // 24 hours
DISK_WARN_GB: 20, // Warn when Docker uses more than 20GB
},
DIGEST: {
COLLECT_INTERVAL: 60 * 60 * 1000, // Hourly log collection
DIGEST_HOUR: 0, // Generate daily digest at midnight
MAX_HOURLY_ENTRIES: 24, // Keep 24 hours of hourly summaries
MAX_DIGEST_FILES: 30, // Keep 30 days of daily digests
LOG_TAIL: 500, // Lines to fetch per container per hour
},
};
// ── Emby/Jellyfin Auth Header Builder ─────────────────────────
function buildMediaAuth(deviceId) {
return `MediaBrowser Client="${APP.NAME}", Device="${APP.NAME}", DeviceId="${deviceId}", Version="${APP.VERSION}"`;
}
// ── Plex Auth Headers ─────────────────────────────────────────
const PLEX = {
AUTH_URL: 'https://plex.tv/users/sign_in.json',
};
// ── Tailscale API ────────────────────────────────────────────
const TAILSCALE = {
API_BASE: 'https://api.tailscale.com/api/v2',
OAUTH_TOKEN_URL: 'https://api.tailscale.com/api/v2/oauth/token',
};
// ── Error Log ─────────────────────────────────────────────────
const LIMITS = {
ERROR_LOG_SIZE: 5 * 1024 * 1024, // 5 MB
BODY_DEFAULT: '1mb',
BODY_UPLOAD: '10mb',
};
// HTTP Status Codes
const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
INTERNAL_ERROR: 500,
SERVICE_UNAVAILABLE: 503,
};
// Network Constants
const NETWORK = {
LOCALHOST: '127.0.0.1',
PRIVATE_RANGES: ['192.168.', '10.', /^172\.(1[6-9]|2[0-9]|3[0-1])\./],
BUFFER_SIZE: 1024,
};
module.exports = {
APP,
TAILSCALE,
APP_PORTS,
ARR_SERVICES,
TIMEOUTS,
RETRIES,
SESSION_TTL,
RATE_LIMITS,
CADDY,
PLEX,
LIMITS,
REGEX,
DNS_RECORD_TYPES,
DOCKER,
buildMediaAuth,
HTTP_STATUS,
NETWORK,
};