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 +49 -0
- package/GUIDE.md +26 -0
- package/package.json +1 -1
- package/src/abuse.js +63 -3
- package/src/form.js +6 -1
- package/src/handlers.js +20 -1
- package/src/index.js +4 -0
- package/src/store.js +5 -0
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
|
+
"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
|
|
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
|
-
|
|
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
|
},
|