[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).
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user