[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:
Hermes
2026-08-19 00:10:37 -07:00
parent 089f5d2902
commit 7e68955e66
5 changed files with 661 additions and 13 deletions
+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 ─────────────────────────────────────────────────────