knowless 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -15,6 +15,85 @@ Versioning is [SemVer](https://semver.org/).
15
15
  sham mail, SPF/DKIM/PTR, reverse-proxy configs for Caddy / nginx /
16
16
  Traefik). (Tracked in TASKS.md Phase 7.)
17
17
 
18
+ ## [0.1.2] — 2026-04-28
19
+
20
+ P2 hardening sprint — completes the audit-finding backlog opened during
21
+ the v0.1.0 self-review. Defense-in-depth and test-strength improvements;
22
+ no behavior changes for correct callers.
23
+
24
+ ### Added
25
+
26
+ - `onSweepError(err)` config hook — invoked when the periodic sweeper
27
+ catches an exception (DB corruption, disk full, etc.). Best-effort:
28
+ hook errors are swallowed and the sweeper keeps running. `auth._sweep()`
29
+ is now exposed for tests and operator scripts to trigger a sweep on
30
+ demand. Closes AF-5.3.
31
+
32
+ ### Security
33
+
34
+ - **Stored-hash integrity check.** All `handle` / `tokenHash` / `sidHash`
35
+ arguments are validated as 64-char lowercase hex at the store boundary
36
+ before any DB read or write. A bug elsewhere passing a wrong-format
37
+ value now fails fast with an actionable error instead of silently
38
+ corrupting the table. Closes AF-5.4.
39
+
40
+ ### Tests
41
+
42
+ - Rate-limit window-boundary precision: last ms of window N is still
43
+ limited; first ms of N+1 is fresh. Limit semantics: "exceeded" fires
44
+ AT the limit, not strictly above. Closes AF-5.1.
45
+ - Cookie parser hardening: 8 edge-case scenarios (whitespace,
46
+ duplicates, malformed pairs, RFC 6265 cases) verifying the existing
47
+ parser is robust. Closes AF-5.2.
48
+
49
+ ## [0.1.1] — 2026-04-29
50
+
51
+ First-customer scope (the webrevival forum) review identified one
52
+ ergonomic gap and elevated three P1 hardening items to "must ship
53
+ before first real use." All five closed in this release.
54
+
55
+ ### Added
56
+
57
+ - `auth.handleFromRequest(req)` — programmatic session resolution
58
+ for in-process middleware. Returns `string | null` (handle on
59
+ valid session, null on any failure). Recommended integration
60
+ point for Express / Fastify / Hono `requireAuth` middleware. SPEC
61
+ §9.4. Closes AF-2.8.
62
+ - `cookieSecure` config option (default `true`). Operators MAY set
63
+ `false` for `http://localhost` development; the library emits a
64
+ stderr warning at startup. MUST NOT be `false` in production.
65
+ SPEC §5.4. PRD FR-30 revised. Closes AF-4.4.
66
+
67
+ ### Security
68
+
69
+ - **CSRF defense on `POST /login`.** New Origin/Referer header
70
+ validation as Step 0 of the login flow. Both headers absent →
71
+ allow (curl, programmatic). Either present → host must equal
72
+ `cookieDomain` or be a subdomain. Cross-origin / unparseable →
73
+ silent short-circuit, no DB write, no mail. Same response shape
74
+ as a legitimate hit, so the attacker's measurement learns
75
+ nothing the request shape didn't already expose. SPEC §7.3 Step
76
+ 0. Closes AF-4.3, resolves SPEC §15 Q-4.
77
+
78
+ ### Tests
79
+
80
+ - AF-4.1: concurrent token issuance under cap contention.
81
+ 10-parallel logins with `maxActiveTokensPerHandle=3` must end at
82
+ exactly 3 active rows. Pins the SPEC §4.7 BEGIN IMMEDIATE
83
+ contract.
84
+ - AF-4.2: SMTP-failure response-uniformity test. Stubs
85
+ `mailer.submit` to throw and asserts the response shape is
86
+ identical to a successful login. Pins NFR-10.
87
+ - 12 new tests total. 122 tests passing on Node 20+.
88
+
89
+ ### Notes
90
+
91
+ The published `0.1.0` does not have these. Adopters who installed
92
+ `0.1.0` should `npm update knowless` to pick up the CSRF defense
93
+ and the localhost-dev-friendly cookieSecure option.
94
+
95
+ [0.1.1]: https://github.com/hamr0/knowless/releases/tag/v0.1.1
96
+
18
97
  ## [0.1.0] — 2026-04-28
19
98
 
20
99
  First public release. Library-mode auth flow is complete and
@@ -138,5 +217,5 @@ Two primary audiences (PRD §4):
138
217
 
139
218
  Apache 2.0 with NOTICE preservation. See `LICENSE` and `NOTICE`.
140
219
 
141
- [Unreleased]: https://github.com/hamr0/knowless/compare/v0.1.0...HEAD
220
+ [Unreleased]: https://github.com/hamr0/knowless/compare/v0.1.1...HEAD
142
221
  [0.1.0]: https://github.com/hamr0/knowless/releases/tag/v0.1.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knowless",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Small, opinionated, full-stack passwordless auth for Node.js services that don't need to email their users for anything but the sign-in link.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/handlers.js CHANGED
@@ -32,6 +32,7 @@ const DEFAULTS = {
32
32
  shamRecipient: 'null@knowless.invalid',
33
33
  trustedProxies: ['127.0.0.1', '::1', '::ffff:127.0.0.1'],
34
34
  failureRedirect: null,
35
+ cookieSecure: true,
35
36
  };
36
37
 
37
38
  /**
@@ -84,6 +85,38 @@ function getCookie(req, name) {
84
85
  return null;
85
86
  }
86
87
 
88
+ /**
89
+ * Validate the request's Origin/Referer header against the cookie
90
+ * domain whitelist per SPEC §7.3 Step 0 (AF-4.3, CSRF defense).
91
+ *
92
+ * - Both headers absent → allow (curl, fetch without CORS, programmatic
93
+ * clients). Browsers always send Origin on cross-origin POST.
94
+ * - Either present → parse and require host == cookieDomain or
95
+ * .endsWith('.' + cookieDomain). Same whitelist as the next-URL
96
+ * check in §11.2.
97
+ * - Unparseable URL or non-matching host → reject.
98
+ *
99
+ * Origin is preferred when both are present (it's harder to spoof and
100
+ * more reliably set by browsers on POST).
101
+ */
102
+ function validateOrigin(req, cookieDomain) {
103
+ const origin = req.headers?.origin;
104
+ const referer = req.headers?.referer ?? req.headers?.referrer;
105
+ const candidate = origin ?? referer;
106
+ if (!candidate) return true;
107
+ if (typeof candidate !== 'string') return false;
108
+ let parsed;
109
+ try {
110
+ parsed = new URL(candidate);
111
+ } catch {
112
+ return false;
113
+ }
114
+ const host = parsed.hostname.toLowerCase();
115
+ if (!host) return false;
116
+ const dom = cookieDomain.toLowerCase();
117
+ return host === dom || host.endsWith('.' + dom);
118
+ }
119
+
87
120
  /**
88
121
  * Validate the `next` URL per SPEC §11.2.
89
122
  * @param {string|null|undefined} rawNext
@@ -147,6 +180,12 @@ export function createHandlers({ store, mailer, config }) {
147
180
 
148
181
  const trustedProxies = new Set(cfg.trustedProxies);
149
182
 
183
+ // SPEC §5.4 / FR-30: build the cookie-attribute suffix once. Secure is
184
+ // emitted by default and omitted only when cookieSecure: false (localhost
185
+ // dev). HttpOnly + SameSite=Lax are always set.
186
+ const secureAttr = cfg.cookieSecure ? '; Secure' : '';
187
+ const setCookieAttrs = `Domain=${cfg.cookieDomain}; Path=/; HttpOnly; SameSite=Lax`;
188
+
150
189
  function sameResponse(res, echoedEmail, next) {
151
190
  const html = renderLoginForm({
152
191
  loginPath: cfg.loginPath,
@@ -168,6 +207,15 @@ export function createHandlers({ store, mailer, config }) {
168
207
  }
169
208
 
170
209
  async function login(req, res) {
210
+ // Step 0 — Origin / Referer validation (SPEC §7.3 Step 0, AF-4.3).
211
+ // CSRF defense: a malicious cross-origin page autosubmitting to /login
212
+ // would otherwise trigger magic-link sends to known emails. Exempt
213
+ // from FR-6 timing equivalence per SPEC §7.3.
214
+ if (!validateOrigin(req, cfg.cookieDomain)) {
215
+ sameResponse(res, '', '');
216
+ return;
217
+ }
218
+
171
219
  let raw;
172
220
  try {
173
221
  raw = await readBody(req);
@@ -312,33 +360,42 @@ export function createHandlers({ store, mailer, config }) {
312
360
  res.statusCode = 302;
313
361
  res.setHeader(
314
362
  'Set-Cookie',
315
- `${cfg.cookieName}=${cookie}; Domain=${cfg.cookieDomain}; Path=/; Max-Age=${cfg.sessionTtlSeconds}; Secure; HttpOnly; SameSite=Lax`,
363
+ `${cfg.cookieName}=${cookie}; ${setCookieAttrs}; Max-Age=${cfg.sessionTtlSeconds}${secureAttr}`,
316
364
  );
317
365
  res.setHeader('Location', row.nextUrl ?? `${cfg.baseUrl}/`);
318
366
  res.end();
319
367
  }
320
368
 
321
- function verify(req, res) {
369
+ /**
370
+ * Programmatic session resolution per SPEC §9.4. Reads the
371
+ * configured cookie from the request, validates its signature,
372
+ * looks up the session row, and returns the handle. Returns
373
+ * null on any failure (missing/malformed cookie, signature
374
+ * mismatch, expired session, no row). Recommended integration
375
+ * point for in-process middleware. Closes AF-2.8.
376
+ *
377
+ * @param {{ headers?: { cookie?: string } }} req
378
+ * @returns {string | null}
379
+ */
380
+ function handleFromRequest(req) {
322
381
  const cookie = getCookie(req, cfg.cookieName);
323
- if (!cookie) {
324
- res.statusCode = 401;
325
- res.end();
326
- return;
327
- }
382
+ if (!cookie) return null;
328
383
  const sid = verifySessionSignature(cookie, cfg.secret);
329
- if (!sid) {
330
- res.statusCode = 401;
331
- res.end();
332
- return;
333
- }
384
+ if (!sid) return null;
334
385
  const row = store.getSession(sidHashOf(sid));
335
- if (!row || row.expiresAt <= Date.now()) {
386
+ if (!row || row.expiresAt <= Date.now()) return null;
387
+ return row.handle;
388
+ }
389
+
390
+ function verify(req, res) {
391
+ const handle = handleFromRequest(req);
392
+ if (!handle) {
336
393
  res.statusCode = 401;
337
394
  res.end();
338
395
  return;
339
396
  }
340
397
  res.statusCode = 200;
341
- res.setHeader('X-User-Handle', row.handle);
398
+ res.setHeader('X-User-Handle', handle);
342
399
  res.end();
343
400
  }
344
401
 
@@ -351,7 +408,7 @@ export function createHandlers({ store, mailer, config }) {
351
408
  res.statusCode = 200;
352
409
  res.setHeader(
353
410
  'Set-Cookie',
354
- `${cfg.cookieName}=; Domain=${cfg.cookieDomain}; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax`,
411
+ `${cfg.cookieName}=; ${setCookieAttrs}; Max-Age=0${secureAttr}`,
355
412
  );
356
413
  res.end();
357
414
  }
@@ -376,6 +433,7 @@ export function createHandlers({ store, mailer, config }) {
376
433
  verify,
377
434
  logout,
378
435
  loginForm,
436
+ handleFromRequest,
379
437
  validateNextUrl: (raw) => validateNextUrl(raw, cfg.baseUrl, cfg.cookieDomain),
380
438
  // exposed for tests
381
439
  _config: cfg,
package/src/index.js CHANGED
@@ -76,6 +76,16 @@ export function knowless(options = {}) {
76
76
  throw new Error('knowless: secret must be at least 64 hex chars (32 bytes)');
77
77
  }
78
78
 
79
+ // SPEC §5.4: cookieSecure: false is allowed only for localhost dev.
80
+ // The library can't tell whether the operator is in production, but a
81
+ // visible warning makes it harder to ship by accident.
82
+ if (options.cookieSecure === false) {
83
+ console.warn(
84
+ '[knowless] WARNING: cookieSecure is false. Session cookies will be set without the Secure flag. ' +
85
+ 'This is only safe for http://localhost development. Never deploy with cookieSecure: false.',
86
+ );
87
+ }
88
+
79
89
  const store = options.store ?? createStore(options.dbPath ?? './knowless.db');
80
90
 
81
91
  const mailer =
@@ -90,7 +100,10 @@ export function knowless(options = {}) {
90
100
  const handlers = createHandlers({ store, mailer, config: options });
91
101
 
92
102
  const sweepIntervalMs = options.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
93
- const sweepTimer = setInterval(() => {
103
+ const onSweepError = options.onSweepError;
104
+ // Extract the sweep body so tests / operators can trigger it without
105
+ // waiting for the interval. Closes AF-5.3.
106
+ function runSweep() {
94
107
  try {
95
108
  const now = Date.now();
96
109
  store.sweepTokens(now);
@@ -98,8 +111,19 @@ export function knowless(options = {}) {
98
111
  store.sweepRateLimits(now - DEFAULT_RATE_LIMIT_RETENTION_MS);
99
112
  } catch (err) {
100
113
  console.error('[knowless] sweep failed:', err.message);
114
+ if (typeof onSweepError === 'function') {
115
+ // Hook errors are swallowed — alerting is best-effort and MUST
116
+ // NOT crash the sweep loop. Operator's hook can fail; sweeper
117
+ // continues.
118
+ try {
119
+ onSweepError(err);
120
+ } catch {
121
+ /* intentional */
122
+ }
123
+ }
101
124
  }
102
- }, sweepIntervalMs);
125
+ }
126
+ const sweepTimer = setInterval(runSweep, sweepIntervalMs);
103
127
  // Don't keep the event loop alive just for the sweeper.
104
128
  if (typeof sweepTimer.unref === 'function') sweepTimer.unref();
105
129
 
@@ -109,10 +133,14 @@ export function knowless(options = {}) {
109
133
  verify: handlers.verify,
110
134
  logout: handlers.logout,
111
135
  loginForm: handlers.loginForm,
136
+ /** Resolve handle from request cookie programmatically (SPEC §9.4). */
137
+ handleFromRequest: handlers.handleFromRequest,
112
138
  /** Delete a handle + all tokens + all sessions atomically (FR-37a). */
113
139
  deleteHandle: (handle) => store.deleteHandle(handle),
114
140
  /** Effective config (with defaults applied), useful for routing. */
115
141
  config: handlers._config,
142
+ /** Run a sweep tick on demand. Useful for tests and operator scripts. */
143
+ _sweep: runSweep,
116
144
  close() {
117
145
  clearInterval(sweepTimer);
118
146
  try {
package/src/mailer.js CHANGED
@@ -19,6 +19,22 @@ const ASCII_RE = /^[\x00-\x7f]*$/;
19
19
  * @returns {string} RFC822 message with CRLF line endings
20
20
  */
21
21
  function composeRaw({ from, to, subject, body }) {
22
+ // AF-2.1: header-injection defense in depth. normalize() upstream
23
+ // already rejects \r and \n in email addresses, but the mailer
24
+ // shouldn't trust its callers — this is the layer that emits the
25
+ // wire-format bytes, so it owns the invariant.
26
+ for (const [name, value] of [
27
+ ['from', from],
28
+ ['to', to],
29
+ ['subject', subject],
30
+ ]) {
31
+ if (typeof value !== 'string') {
32
+ throw new Error(`mailer: ${name} must be a string`);
33
+ }
34
+ if (/[\r\n]/.test(value)) {
35
+ throw new Error(`mailer: ${name} contains CR/LF — header injection blocked`);
36
+ }
37
+ }
22
38
  const fromDomain = from.includes('@') ? from.split('@').pop() : 'localhost';
23
39
  const messageId = `<${crypto.randomUUID()}@${fromDomain}>`;
24
40
  const date = new Date().toUTCString();
package/src/store.js CHANGED
@@ -8,6 +8,28 @@ const DEFAULT_TOKEN_GRACE_MS = 24 * 60 * 60 * 1000;
8
8
 
9
9
  const SCHEMA_VERSION = '1';
10
10
 
11
+ /**
12
+ * Validate a 64-char lowercase hex string at the store boundary.
13
+ * Handles, token hashes, and session ID hashes are all this shape per
14
+ * SPEC §3.1, §4.1, §5.3. A bug elsewhere passing a wrong-format value
15
+ * would otherwise silently corrupt the table or fail at SELECT time
16
+ * with a less-actionable error. Closes AF-5.4.
17
+ *
18
+ * @param {unknown} value
19
+ * @param {string} name parameter name for the thrown error
20
+ */
21
+ function assertHexHash(value, name) {
22
+ if (typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value)) {
23
+ const got =
24
+ typeof value === 'string'
25
+ ? `"${value.slice(0, 16)}${value.length > 16 ? '...' : ''}"`
26
+ : typeof value;
27
+ throw new Error(
28
+ `store: ${name} must be 64-char lowercase hex (got ${got})`,
29
+ );
30
+ }
31
+ }
32
+
11
33
  const DDL = `
12
34
  CREATE TABLE IF NOT EXISTS handles (
13
35
  handle TEXT PRIMARY KEY,
@@ -168,12 +190,15 @@ export function createStore(dbPath = ':memory:') {
168
190
  return {
169
191
  // --- Handle ---
170
192
  handleExists(handle) {
193
+ assertHexHash(handle, 'handle');
171
194
  return !!stmt.handleExists.get(handle);
172
195
  },
173
196
  upsertHandle(handle) {
197
+ assertHexHash(handle, 'handle');
174
198
  stmt.upsertHandleNoLogin.run(handle);
175
199
  },
176
200
  deleteHandle(handle) {
201
+ assertHexHash(handle, 'handle');
177
202
  deleteHandleAtomic(handle);
178
203
  },
179
204
 
@@ -188,6 +213,8 @@ export function createStore(dbPath = ':memory:') {
188
213
  maxActive = 0,
189
214
  now = Date.now(),
190
215
  } = args;
216
+ assertHexHash(tokenHash, 'tokenHash');
217
+ assertHexHash(handle, 'handle');
191
218
  insertTokenAtomic(
192
219
  tokenHash,
193
220
  handle,
@@ -199,6 +226,7 @@ export function createStore(dbPath = ':memory:') {
199
226
  );
200
227
  },
201
228
  getToken(tokenHash) {
229
+ assertHexHash(tokenHash, 'tokenHash');
202
230
  const row = stmt.getToken.get(tokenHash);
203
231
  if (!row) return null;
204
232
  return {
@@ -210,12 +238,15 @@ export function createStore(dbPath = ':memory:') {
210
238
  };
211
239
  },
212
240
  markTokenUsed(tokenHash, usedAt) {
241
+ assertHexHash(tokenHash, 'tokenHash');
213
242
  return stmt.markTokenUsed.run(usedAt, tokenHash).changes > 0;
214
243
  },
215
244
  countActiveTokens(handle, now = Date.now()) {
245
+ assertHexHash(handle, 'handle');
216
246
  return stmt.countActiveTokens.get(handle, now).n;
217
247
  },
218
248
  evictOldestActiveToken(handle, now = Date.now()) {
249
+ assertHexHash(handle, 'handle');
219
250
  return stmt.evictOldestActive.run(handle, now).changes;
220
251
  },
221
252
  sweepTokens(now = Date.now(), graceMs = DEFAULT_TOKEN_GRACE_MS) {
@@ -224,21 +255,27 @@ export function createStore(dbPath = ':memory:') {
224
255
 
225
256
  // --- Last login ---
226
257
  upsertLastLogin(handle, at) {
258
+ assertHexHash(handle, 'handle');
227
259
  stmt.upsertLastLogin.run(handle, at);
228
260
  },
229
261
  getLastLogin(handle) {
262
+ assertHexHash(handle, 'handle');
230
263
  const row = stmt.getLastLogin.get(handle);
231
264
  return row ? row.lastLoginAt : null;
232
265
  },
233
266
 
234
267
  // --- Session ---
235
268
  insertSession(sidHash, handle, expiresAt) {
269
+ assertHexHash(sidHash, 'sidHash');
270
+ assertHexHash(handle, 'handle');
236
271
  stmt.insertSession.run(sidHash, handle, expiresAt);
237
272
  },
238
273
  getSession(sidHash) {
274
+ assertHexHash(sidHash, 'sidHash');
239
275
  return stmt.getSession.get(sidHash) ?? null;
240
276
  },
241
277
  deleteSession(sidHash) {
278
+ assertHexHash(sidHash, 'sidHash');
242
279
  return stmt.deleteSession.run(sidHash).changes > 0;
243
280
  },
244
281
  sweepSessions(now = Date.now()) {