knowless 0.1.3 → 0.1.4

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
@@ -8,6 +8,55 @@ Versioning is [SemVer](https://semver.org/).
8
8
  ## [Unreleased]
9
9
 
10
10
  - Caddy forward-auth Docker integration test (TASKS.md 6.8).
11
+ - `knowless-server --check-null-route`: CLI probe that submits a
12
+ test message to `shamRecipient` and confirms the local MTA
13
+ discarded it. Honest answer to "does the operator's null-route
14
+ actually work?" — the library can know what it submitted but
15
+ not what the MTA did, so this is the closest we can get.
16
+ Targeted for v0.2.0.
17
+
18
+ ## [0.1.4] — 2026-04-28
19
+
20
+ First real-world integration release. Bugs and ergonomics surfaced
21
+ by the addypin team's spike on v0.1.3, plus two minor security
22
+ hardenings that fell out of the audit.
23
+
24
+ ### Added
25
+
26
+ - **`auth.revokeSessions(handle)`** — log out everywhere without
27
+ deleting the account. Returns the number of sessions removed.
28
+ Closes AF-6.1.
29
+ - **`devLogMagicLinks: true`** opt-in — when SMTP fails AND this
30
+ flag is set, prints the magic link to stderr so a developer can
31
+ click through. Off by default; never fires for sham (silent-miss)
32
+ submissions; never replaces real SMTP delivery on success. Closes
33
+ AF-6.2.
34
+ - **CIDR support in `trustedProxies`** — accept `10.0.0.0/8`,
35
+ `fd00::/8`, etc. in addition to plain IPs. Uses `node:net`
36
+ `BlockList`, no new dep. Closes AF-6.3.
37
+
38
+ ### Security
39
+
40
+ - **CSRF on `POST /logout`.** Origin/Referer validation now mirrors
41
+ `POST /login` (AF-4.3). Without this, a malicious page could
42
+ force-logout an authenticated victim. Closes AF-6.4.
43
+ - **`confirmationMessage` is HTML-escaped before rendering.** The
44
+ message is operator-config (not user input), but a careless
45
+ operator interpolating user data into it would have produced an
46
+ XSS. The whole message is now escaped before `{email}` substitution
47
+ (which was already escaped). Operators who want HTML in the
48
+ confirmation message must pre-render upstream. Closes AF-6.5.
49
+
50
+ ### Documentation
51
+
52
+ - **SPEC §10.2** documents the new logout Origin check.
53
+ - **SPEC §7.3 Step 0** adds an explicit "do NOT add a CSRF token
54
+ upstream — the Origin/Referer whitelist IS the CSRF defense"
55
+ note for adopters. Closes AF-6.6.
56
+ - **GUIDE.md** front-matter now leads with the v1.0.0 walks-away
57
+ commitment. Procurement signal: a library that has explicitly
58
+ committed to *not growing* is a different risk profile from a
59
+ typical OSS package. Closes AF-6.7.
11
60
 
12
61
  ## [0.1.3] — 2026-04-28
13
62
 
package/GUIDE.md CHANGED
@@ -5,6 +5,32 @@
5
5
  > For the product philosophy, see
6
6
  > [`docs/01-product/PRD.md`](docs/01-product/PRD.md).
7
7
 
8
+ ## Read this first: knowless walks away at v1.0.0
9
+
10
+ knowless commits to a small, audit-able surface and a *closed* feature
11
+ list. v1.0.0 is the **terminal release** for new functionality: only
12
+ security fixes ship after that. There will be no v2.0 with sessions+,
13
+ no plugin system, no second mailer, no SaaS counterpart.
14
+
15
+ What this means for you as an adopter:
16
+
17
+ - **You own integration breadth.** If knowless's defaults don't fit
18
+ exactly, you patch around it (the API is small enough to do this) or
19
+ fork it (Apache 2.0). We won't add a config knob to absorb your
20
+ case.
21
+ - **You can pin and forget.** v1.0.0 will work the same way three
22
+ years later. Security patches will land in v1.x.
23
+ - **Procurement signal.** A library that has explicitly committed to
24
+ *not growing* is a different risk profile from a typical OSS
25
+ package. Most reviews assume "still actively developed" is good;
26
+ for an auth dependency, "still actively developed" is also "still
27
+ changing in ways you'll have to track." knowless inverts that.
28
+
29
+ If you need a kitchen-sink auth library with active feature
30
+ development, this isn't the right tool. See
31
+ [Lucia](https://lucia-auth.com/), [Auth.js](https://authjs.dev/),
32
+ or commercial offerings.
33
+
8
34
  ## Who this is for
9
35
 
10
36
  Three audiences, in order of fit:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knowless",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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/abuse.js CHANGED
@@ -1,3 +1,62 @@
1
+ import net from 'node:net';
2
+
3
+ /**
4
+ * Build a peer-IP matcher from a list of plain IPs and/or CIDR ranges.
5
+ * AF-6.3 — CIDR support so docker / k8s / cgnat ranges can be trusted
6
+ * without enumerating every address.
7
+ *
8
+ * Accepts:
9
+ * - bare IPv4/IPv6 ("127.0.0.1", "::1")
10
+ * - CIDR ("10.0.0.0/8", "fd00::/8")
11
+ * - a Set or array of either
12
+ * - a `node:net` BlockList (passed through)
13
+ *
14
+ * @param {Set<string>|string[]|net.BlockList} trustedProxies
15
+ * @returns {{ has: (ip: string) => boolean }}
16
+ */
17
+ export function buildTrustedPeers(trustedProxies) {
18
+ if (trustedProxies && typeof trustedProxies.check === 'function') {
19
+ return { has: (ip) => safeBlockListCheck(trustedProxies, ip) };
20
+ }
21
+ const list = Array.isArray(trustedProxies)
22
+ ? trustedProxies
23
+ : trustedProxies instanceof Set
24
+ ? [...trustedProxies]
25
+ : [];
26
+ const exact = new Set();
27
+ const block = new net.BlockList();
28
+ for (const entry of list) {
29
+ if (typeof entry !== 'string' || !entry) continue;
30
+ const slash = entry.indexOf('/');
31
+ if (slash >= 0) {
32
+ const addr = entry.slice(0, slash);
33
+ const prefix = Number(entry.slice(slash + 1));
34
+ const family = net.isIPv6(addr) ? 'ipv6' : 'ipv4';
35
+ try {
36
+ block.addSubnet(addr, prefix, family);
37
+ } catch {
38
+ /* skip malformed CIDR rather than crash */
39
+ }
40
+ } else {
41
+ exact.add(entry);
42
+ }
43
+ }
44
+ return {
45
+ has: (ip) => exact.has(ip) || safeBlockListCheck(block, ip),
46
+ };
47
+ }
48
+
49
+ function safeBlockListCheck(block, ip) {
50
+ if (typeof ip !== 'string' || ip.length === 0) return false;
51
+ const family = net.isIPv6(ip) ? 'ipv6' : net.isIPv4(ip) ? 'ipv4' : null;
52
+ if (!family) return false;
53
+ try {
54
+ return block.check(ip, family);
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
1
60
  /**
2
61
  * Determine the source IP of a request per FR-42 and SPEC §7.6.
3
62
  *
@@ -6,19 +65,20 @@
6
65
  * fall back to the connection's remote address. This prevents IP
7
66
  * spoofing from clients while supporting forward-auth deployments.
8
67
  *
68
+ * `trustedProxies` accepts plain IPs and CIDR ranges (AF-6.3).
69
+ *
9
70
  * @param {{
10
71
  * socket?: { remoteAddress?: string },
11
72
  * connection?: { remoteAddress?: string },
12
73
  * headers?: Record<string, string|string[]|undefined>
13
74
  * }} req a node:http request (or shape-compatible)
14
- * @param {Set<string>|string[]} trustedProxies set or array of trusted peer IPs
75
+ * @param {Set<string>|string[]|net.BlockList} trustedProxies trusted peer IPs / CIDRs
15
76
  * @returns {string} the determined IP, or '' if undeterminable
16
77
  */
17
78
  export function determineSourceIp(req, trustedProxies) {
18
79
  const peer =
19
80
  req?.socket?.remoteAddress ?? req?.connection?.remoteAddress ?? '';
20
- const trusted =
21
- trustedProxies instanceof Set ? trustedProxies : new Set(trustedProxies ?? []);
81
+ const trusted = buildTrustedPeers(trustedProxies);
22
82
  if (!trusted.has(peer)) {
23
83
  return peer;
24
84
  }
package/src/form.js CHANGED
@@ -56,10 +56,15 @@ export function renderLoginForm(args) {
56
56
  next,
57
57
  } = args;
58
58
 
59
+ // confirmationMessage is operator-supplied config, not user input — but
60
+ // operators may naively interpolate user data into it. Escape the whole
61
+ // message before substituting {email} (which is itself escaped). The
62
+ // contract is "confirmationMessage is plain text + {email} placeholder";
63
+ // operators who want HTML can pre-render upstream. Closes AF-6.5.
59
64
  const messageBlock =
60
65
  confirmationMessage != null
61
66
  ? `<div class="msg" role="status">${
62
- confirmationMessage.replace(
67
+ htmlEscape(confirmationMessage).replace(
63
68
  /\{email\}/g,
64
69
  htmlEscape(echoedEmail ?? ''),
65
70
  )
package/src/handlers.js CHANGED
@@ -5,6 +5,7 @@ import { newSid, signSession, verifySessionSignature } from './session.js';
5
5
  import { composeBody } from './mailer.js';
6
6
  import { renderLoginForm } from './form.js';
7
7
  import {
8
+ buildTrustedPeers,
8
9
  determineSourceIp,
9
10
  rateLimitExceeded,
10
11
  rateLimitIncrement,
@@ -178,7 +179,8 @@ export function createHandlers({ store, mailer, config }) {
178
179
  }
179
180
  }
180
181
 
181
- const trustedProxies = new Set(cfg.trustedProxies);
182
+ // Build once at handler creation; supports plain IPs and CIDRs (AF-6.3).
183
+ const trustedProxies = buildTrustedPeers(cfg.trustedProxies);
182
184
 
183
185
  // SPEC §5.4 / FR-30: build the cookie-attribute suffix once. Secure is
184
186
  // emitted by default and omitted only when cookieSecure: false (localhost
@@ -319,6 +321,14 @@ export function createHandlers({ store, mailer, config }) {
319
321
  } catch (err) {
320
322
  // Per NFR-10: SMTP failure logged, NEVER leaked to response shape.
321
323
  console.error('[knowless] mail submit failed:', err.message);
324
+ // AF-6.2: dev-mode fallback. When SMTP is unreachable in local
325
+ // development the operator otherwise has no way to obtain the magic
326
+ // link. Print it to stderr only when explicitly opted in. Sham
327
+ // submissions are NOT logged (would leak silent-miss outcome).
328
+ if (cfg.devLogMagicLinks && !isSham) {
329
+ const link = `${cfg.baseUrl}${cfg.linkPath}?t=${token.raw}`;
330
+ process.stderr.write(`[knowless dev] magic link: ${link}\n`);
331
+ }
322
332
  }
323
333
 
324
334
  rateLimitIncrement(store, 'login_ip', ip, HOUR_MS);
@@ -400,6 +410,15 @@ export function createHandlers({ store, mailer, config }) {
400
410
  }
401
411
 
402
412
  async function logout(req, res) {
413
+ // CSRF defense — same Origin/Referer check as POST /login (AF-4.3).
414
+ // Without this, a third-party page can force-logout a victim. Closes
415
+ // AF-6.4. Browser-absent (curl/programmatic) is allowed.
416
+ if (!validateOrigin(req, cfg.cookieDomain)) {
417
+ res.statusCode = 403;
418
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
419
+ res.end('forbidden\n');
420
+ return;
421
+ }
403
422
  const cookie = getCookie(req, cfg.cookieName);
404
423
  if (cookie) {
405
424
  const sid = verifySessionSignature(cookie, cfg.secret);
package/src/index.js CHANGED
@@ -137,6 +137,10 @@ export function knowless(options = {}) {
137
137
  handleFromRequest: handlers.handleFromRequest,
138
138
  /** Delete a handle + all tokens + all sessions atomically (FR-37a). */
139
139
  deleteHandle: (handle) => store.deleteHandle(handle),
140
+ /** Revoke every session for `handle` without deleting the handle.
141
+ * "Log out everywhere." Returns the number of sessions removed.
142
+ * AF-6.1. */
143
+ revokeSessions: (handle) => store.revokeSessions(handle),
140
144
  /** Effective config (with defaults applied), useful for routing. */
141
145
  config: handlers._config,
142
146
  /** Run a sweep tick on demand. Useful for tests and operator scripts. */
package/src/store.js CHANGED
@@ -278,6 +278,11 @@ export function createStore(dbPath = ':memory:') {
278
278
  assertHexHash(sidHash, 'sidHash');
279
279
  return stmt.deleteSession.run(sidHash).changes > 0;
280
280
  },
281
+ /** Delete every session for `handle`. Returns rows-deleted. AF-6.1. */
282
+ revokeSessions(handle) {
283
+ assertHexHash(handle, 'handle');
284
+ return stmt.deleteHandleSessions.run(handle).changes;
285
+ },
281
286
  sweepSessions(now = Date.now()) {
282
287
  return stmt.sweepSessions.run(now).changes;
283
288
  },