@strav/captcha 0.4.31 → 1.0.0-alpha.43
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/README.md +9 -0
- package/package.json +29 -20
- package/src/captcha_error.ts +14 -0
- package/src/captcha_manager.ts +132 -0
- package/src/captcha_provider.ts +49 -0
- package/src/challenges/{honeypot.ts → honeypot_challenge.ts} +6 -6
- package/src/challenges/{pow.ts → pow_challenge.ts} +7 -7
- package/src/challenges/{svg.ts → svg_challenge.ts} +8 -11
- package/src/http/captcha_middleware.ts +99 -0
- package/src/http/captcha_rule.ts +49 -0
- package/src/http/index.ts +5 -0
- package/src/http/refresh_routes.ts +59 -0
- package/src/index.ts +25 -42
- package/src/registry.ts +26 -22
- package/src/replay.ts +25 -0
- package/src/token.ts +78 -49
- package/src/types.ts +43 -37
- package/src/view/captcha_helper.ts +77 -0
- package/src/view/index.ts +3 -0
- package/src/challenges.ts +0 -89
- package/src/islands/pow.vue +0 -109
- package/src/islands/refresh.vue +0 -43
- package/src/middleware.ts +0 -125
- package/src/route.ts +0 -73
- package/src/validation_rule.ts +0 -31
- package/src/view_helper.ts +0 -105
- package/tsconfig.json +0 -5
package/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @strav/captcha
|
|
2
|
+
|
|
3
|
+
Stateless CAPTCHA primitives for Strav 1.0 — honeypot, proof-of-work,
|
|
4
|
+
distorted-text SVG. HMAC-signed tokens, pluggable challenge registry,
|
|
5
|
+
HTTP middleware + validation rule + refresh routes under
|
|
6
|
+
`@strav/captcha/http`, view helper under `@strav/captcha/view`.
|
|
7
|
+
|
|
8
|
+
See [docs/captcha/](../../docs/captcha/) for the full guide and API
|
|
9
|
+
reference.
|
package/package.json
CHANGED
|
@@ -1,31 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/captcha",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0-alpha.43",
|
|
4
|
+
"description": "Strav CAPTCHA primitive — stateless HMAC-signed tokens + pluggable challenge registry. Ships honeypot, hashcash-style proof of work, and inline-SVG distorted-text challenges in the root, with HTTP middleware + validation rule + refresh routes under @strav/captcha/http and a view helper under @strav/captcha/view.",
|
|
4
5
|
"type": "module",
|
|
5
|
-
"
|
|
6
|
-
"
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
7
8
|
"exports": {
|
|
8
9
|
".": "./src/index.ts",
|
|
9
|
-
"
|
|
10
|
-
|
|
11
|
-
"strav": {
|
|
12
|
-
"islands": {
|
|
13
|
-
"namespace": "captcha",
|
|
14
|
-
"dir": "./src/islands"
|
|
15
|
-
}
|
|
10
|
+
"./http": "./src/http/index.ts",
|
|
11
|
+
"./view": "./src/view/index.ts"
|
|
16
12
|
},
|
|
17
13
|
"files": [
|
|
18
|
-
"src
|
|
19
|
-
"
|
|
20
|
-
"tsconfig.json"
|
|
14
|
+
"src",
|
|
15
|
+
"README.md"
|
|
21
16
|
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"bun": ">=1.3.14"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@strav/kernel": "1.0.0-alpha.43"
|
|
25
|
+
},
|
|
22
26
|
"peerDependencies": {
|
|
23
|
-
"@strav/
|
|
24
|
-
"@strav/http": "0.
|
|
25
|
-
"@
|
|
27
|
+
"@strav/cache": "1.0.0-alpha.43",
|
|
28
|
+
"@strav/http": "1.0.0-alpha.43",
|
|
29
|
+
"@types/bun": ">=1.3.14"
|
|
30
|
+
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"@strav/cache": {
|
|
33
|
+
"optional": true
|
|
34
|
+
},
|
|
35
|
+
"@strav/http": {
|
|
36
|
+
"optional": true
|
|
37
|
+
}
|
|
26
38
|
},
|
|
27
|
-
"
|
|
28
|
-
"test": "bun test tests/",
|
|
29
|
-
"typecheck": "tsc --noEmit"
|
|
30
|
-
}
|
|
39
|
+
"devDependencies": null
|
|
31
40
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { StravError, type StravErrorOptions } from '@strav/kernel'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Misconfiguration of `@strav/captcha` — missing secret, unknown
|
|
5
|
+
* challenge type registered, etc.
|
|
6
|
+
*
|
|
7
|
+
* Verification *failures* are NOT exceptions — they return
|
|
8
|
+
* `{ ok: false, reason }` so callers can surface targeted form errors.
|
|
9
|
+
*/
|
|
10
|
+
export class CaptchaError extends StravError {
|
|
11
|
+
constructor(message: string, options: StravErrorOptions = {}) {
|
|
12
|
+
super(message, { code: 'captcha-error', status: 500 }, options)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CaptchaManager` — the DI-resolved unit that holds the signing
|
|
3
|
+
* secret + challenge registry and exposes `issue`/`verify`. The HTTP
|
|
4
|
+
* middleware and validation rule (in `./http/`) and the view helper
|
|
5
|
+
* (in `./view/`) resolve it from the container.
|
|
6
|
+
*
|
|
7
|
+
* Replay prevention is a separate concern from verification: a cache is
|
|
8
|
+
* optional on the manager so apps that only use the validation rule
|
|
9
|
+
* (idempotent endpoints, single-shot tokens) don't need to wire one.
|
|
10
|
+
* The middleware DOES require it — see `./http/captcha_middleware.ts`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { honeypotChallenge } from './challenges/honeypot_challenge.ts'
|
|
14
|
+
import { powChallenge } from './challenges/pow_challenge.ts'
|
|
15
|
+
import { svgChallenge } from './challenges/svg_challenge.ts'
|
|
16
|
+
import { CaptchaError } from './captcha_error.ts'
|
|
17
|
+
import { ChallengeRegistry } from './registry.ts'
|
|
18
|
+
import { hashAnswer, randomHex, signToken, verifyToken } from './token.ts'
|
|
19
|
+
import {
|
|
20
|
+
DEFAULTS,
|
|
21
|
+
type CaptchaConfig,
|
|
22
|
+
type CaptchaTokenPayload,
|
|
23
|
+
type ChallengeName,
|
|
24
|
+
type ChallengeType,
|
|
25
|
+
type IssueOptions,
|
|
26
|
+
type IssuedChallenge,
|
|
27
|
+
type VerifyResult,
|
|
28
|
+
} from './types.ts'
|
|
29
|
+
|
|
30
|
+
export interface CaptchaManagerOptions {
|
|
31
|
+
config: CaptchaConfig
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class CaptchaManager {
|
|
35
|
+
readonly registry = new ChallengeRegistry()
|
|
36
|
+
readonly config: Required<Omit<CaptchaConfig, 'secret'>> & { secret: string | Uint8Array }
|
|
37
|
+
|
|
38
|
+
constructor(opts: CaptchaManagerOptions) {
|
|
39
|
+
if (!opts.config?.secret) {
|
|
40
|
+
throw new CaptchaError(
|
|
41
|
+
'CaptchaManager: `config.secret` is required (HMAC signing key, ≥ 32 bytes of entropy).',
|
|
42
|
+
{ code: 'captcha.missing-secret' },
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
this.config = {
|
|
46
|
+
secret: opts.config.secret,
|
|
47
|
+
honeypotField: opts.config.honeypotField ?? DEFAULTS.honeypotField,
|
|
48
|
+
tokenField: opts.config.tokenField ?? DEFAULTS.tokenField,
|
|
49
|
+
responseField: opts.config.responseField ?? DEFAULTS.responseField,
|
|
50
|
+
difficulty: opts.config.difficulty ?? DEFAULTS.difficulty,
|
|
51
|
+
ttlMinutes: opts.config.ttlMinutes ?? DEFAULTS.ttlMinutes,
|
|
52
|
+
}
|
|
53
|
+
this.registry.register(honeypotChallenge)
|
|
54
|
+
this.registry.register(powChallenge)
|
|
55
|
+
this.registry.register(svgChallenge)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Register a custom challenge type. */
|
|
59
|
+
register(type: ChallengeType): void {
|
|
60
|
+
this.registry.register(type)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** All registered type names. */
|
|
64
|
+
listTypes(): ChallengeName[] {
|
|
65
|
+
return this.registry.list()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Issue a fresh challenge. Synchronous — sign + the built-in issuers
|
|
70
|
+
* all run without I/O. Caller embeds `token` in a hidden field and
|
|
71
|
+
* uses `props` (and `html` for SVG) to render the challenge.
|
|
72
|
+
*/
|
|
73
|
+
issue(type: ChallengeName, opts: IssueOptions = {}): IssuedChallenge {
|
|
74
|
+
const challenge = this.registry.get(type)
|
|
75
|
+
const ttlMinutes = opts.ttlMinutes ?? this.config.ttlMinutes
|
|
76
|
+
|
|
77
|
+
// 16 bytes (32 hex chars) — salts double as the PoW challenge value,
|
|
78
|
+
// so give them enough entropy that a precomputed PoW table is
|
|
79
|
+
// impractical.
|
|
80
|
+
const salt = randomHex(16)
|
|
81
|
+
|
|
82
|
+
const issued = challenge.issue({
|
|
83
|
+
...opts,
|
|
84
|
+
difficulty: opts.difficulty ?? this.config.difficulty,
|
|
85
|
+
salt,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const tokenData: Omit<CaptchaTokenPayload, 'v' | 'iat' | 'jti'> = {
|
|
89
|
+
t: type,
|
|
90
|
+
s: salt,
|
|
91
|
+
exp: ttlMinutes,
|
|
92
|
+
...(issued.answer !== undefined ? { ah: hashAnswer(issued.answer, salt) } : {}),
|
|
93
|
+
...(issued.extra ?? {}),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const { token } = signToken(this.config.secret, tokenData)
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
token,
|
|
100
|
+
props: issued.props,
|
|
101
|
+
...(issued.html !== undefined ? { html: issued.html } : {}),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Decode and verify a token + the user's response. Pure: no cache
|
|
107
|
+
* writes, no I/O. Replay prevention happens upstream in the middleware
|
|
108
|
+
* (after we know verification passed) so failed attempts don't burn
|
|
109
|
+
* the replay slot.
|
|
110
|
+
*/
|
|
111
|
+
verify(
|
|
112
|
+
token: string | undefined | null,
|
|
113
|
+
response: unknown,
|
|
114
|
+
body: Record<string, unknown> = {},
|
|
115
|
+
): VerifyResult {
|
|
116
|
+
if (!token || typeof token !== 'string') {
|
|
117
|
+
return { ok: false, reason: 'token_missing' }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const decoded = verifyToken(this.config.secret, token)
|
|
121
|
+
if (!decoded.ok) return decoded
|
|
122
|
+
|
|
123
|
+
if (!this.registry.has(decoded.payload.t)) {
|
|
124
|
+
return { ok: false, reason: 'unknown_type' }
|
|
125
|
+
}
|
|
126
|
+
const challenge = this.registry.get(decoded.payload.t)
|
|
127
|
+
const result = challenge.verify(decoded.payload, response, body)
|
|
128
|
+
if (!result.ok) return result
|
|
129
|
+
|
|
130
|
+
return { ok: true, payload: decoded.payload }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CaptchaProvider` — registers `CaptchaManager` under its own token,
|
|
3
|
+
* reading config from `config.captcha`. Apps that want to pre-register
|
|
4
|
+
* custom challenge types can pass a `define` hook.
|
|
5
|
+
*
|
|
6
|
+
* new CaptchaProvider({
|
|
7
|
+
* define(captcha) {
|
|
8
|
+
* captcha.register(myCustomChallenge)
|
|
9
|
+
* },
|
|
10
|
+
* })
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { type Application, ConfigRepository, ServiceProvider } from '@strav/kernel'
|
|
14
|
+
import { CaptchaError } from './captcha_error.ts'
|
|
15
|
+
import { CaptchaManager } from './captcha_manager.ts'
|
|
16
|
+
import type { CaptchaConfig } from './types.ts'
|
|
17
|
+
|
|
18
|
+
export interface CaptchaProviderOptions {
|
|
19
|
+
/** Hook invoked at boot, after the manager is created. */
|
|
20
|
+
define?: (captcha: CaptchaManager) => void | Promise<void>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class CaptchaProvider extends ServiceProvider {
|
|
24
|
+
override readonly name = 'captcha'
|
|
25
|
+
override readonly dependencies = ['config']
|
|
26
|
+
|
|
27
|
+
constructor(private readonly options: CaptchaProviderOptions = {}) {
|
|
28
|
+
super()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
override register(app: Application): void {
|
|
32
|
+
app.singleton(CaptchaManager, (c) => {
|
|
33
|
+
const cfg = c.resolve(ConfigRepository).get('captcha') as CaptchaConfig | undefined
|
|
34
|
+
if (!cfg) {
|
|
35
|
+
throw new CaptchaError(
|
|
36
|
+
'CaptchaProvider: missing `config.captcha`. Add a `captcha` config slice with at least `{ secret: env.required("APP_KEY") }`.',
|
|
37
|
+
{ code: 'captcha.missing-config' },
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
return new CaptchaManager({ config: cfg })
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override async boot(app: Application): Promise<void> {
|
|
45
|
+
if (this.options.define) {
|
|
46
|
+
await this.options.define(app.resolve(CaptchaManager))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import type { ChallengeType } from '../types.ts'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Invisible-field honeypot. The
|
|
5
|
-
* humans never see; bots that auto-fill every field on the page
|
|
6
|
-
*
|
|
4
|
+
* Invisible-field honeypot. The view helper injects a hidden input that
|
|
5
|
+
* humans never see; bots that auto-fill every field on the page trip it.
|
|
6
|
+
* Free, zero UX, layer it under everything else.
|
|
7
7
|
*
|
|
8
8
|
* The honeypot field name is checked at the middleware layer — this
|
|
9
|
-
* type just establishes the token shape so the protection still
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* type just establishes the token shape so the protection still surfaces
|
|
10
|
+
* a `pow_insufficient` / `token_*` error path consistent with other
|
|
11
|
+
* types when the form is submitted without a token.
|
|
12
12
|
*/
|
|
13
13
|
export const honeypotChallenge: ChallengeType = {
|
|
14
14
|
name: 'honeypot',
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type
|
|
3
|
-
import { DEFAULTS } from '../types.ts'
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { DEFAULTS, type ChallengeType } from '../types.ts'
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
5
|
* Hashcash-style proof of work. The client must find a nonce such that
|
|
@@ -27,13 +26,12 @@ export const powChallenge: ChallengeType = {
|
|
|
27
26
|
if (typeof response !== 'string' || response.length === 0) {
|
|
28
27
|
return { ok: false, reason: 'pow_insufficient' }
|
|
29
28
|
}
|
|
30
|
-
const difficulty = payload.d ?? DEFAULTS.difficulty
|
|
31
|
-
|
|
32
29
|
// Cap nonce length so a malicious client can't ship megabyte payloads
|
|
33
30
|
// through our hash function.
|
|
34
31
|
if (response.length > 64) return { ok: false, reason: 'pow_insufficient' }
|
|
35
32
|
|
|
36
|
-
const
|
|
33
|
+
const difficulty = payload.d ?? DEFAULTS.difficulty
|
|
34
|
+
const digest = createHash('sha256').update(`${payload.s}:${response}`).digest('hex')
|
|
37
35
|
if (countLeadingZeroBits(digest) < difficulty) {
|
|
38
36
|
return { ok: false, reason: 'pow_insufficient' }
|
|
39
37
|
}
|
|
@@ -48,7 +46,9 @@ export const powChallenge: ChallengeType = {
|
|
|
48
46
|
export function countLeadingZeroBits(hex: string): number {
|
|
49
47
|
let bits = 0
|
|
50
48
|
for (let i = 0; i < hex.length; i++) {
|
|
51
|
-
const
|
|
49
|
+
const ch = hex[i]!
|
|
50
|
+
const nibble = Number.parseInt(ch, 16)
|
|
51
|
+
if (Number.isNaN(nibble)) break
|
|
52
52
|
if (nibble === 0) {
|
|
53
53
|
bits += 4
|
|
54
54
|
continue
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { ChallengeType } from '../types.ts'
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
3
2
|
import { hashAnswer, safeEqual } from '../token.ts'
|
|
3
|
+
import type { ChallengeType } from '../types.ts'
|
|
4
4
|
|
|
5
5
|
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // no 0/O/I/1
|
|
6
6
|
const LENGTH = 6
|
|
@@ -44,7 +44,7 @@ export const svgChallenge: ChallengeType = {
|
|
|
44
44
|
|
|
45
45
|
/** Pick `n` characters from the confusables-pruned alphabet. */
|
|
46
46
|
function randomCode(n: number): string {
|
|
47
|
-
const bytes =
|
|
47
|
+
const bytes = randomBytes(n)
|
|
48
48
|
let out = ''
|
|
49
49
|
for (let i = 0; i < n; i++) {
|
|
50
50
|
out += ALPHABET[bytes[i]! % ALPHABET.length]
|
|
@@ -53,14 +53,11 @@ function randomCode(n: number): string {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
* Build the SVG body for a code.
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* across requests.
|
|
56
|
+
* Build the SVG body for a code. The jitter uses `Math.random()` — it's
|
|
57
|
+
* fine because security comes from the answer hash, not from making the
|
|
58
|
+
* SVG unpredictable.
|
|
60
59
|
*/
|
|
61
60
|
export function renderSvg(code: string): string {
|
|
62
|
-
// Bun's Math.random is fine for visual jitter — security comes from
|
|
63
|
-
// the answer hash, not from making the SVG unpredictable.
|
|
64
61
|
const glyphs: string[] = []
|
|
65
62
|
const stepX = (WIDTH - 30) / code.length
|
|
66
63
|
const baselineY = HEIGHT / 2 + FONT_SIZE / 3
|
|
@@ -76,7 +73,7 @@ export function renderSvg(code: string): string {
|
|
|
76
73
|
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" font-size="${FONT_SIZE}" ` +
|
|
77
74
|
`font-family="Helvetica, Arial, sans-serif" font-weight="700" fill="${fill}" ` +
|
|
78
75
|
`transform="rotate(${rot.toFixed(1)} ${x.toFixed(1)} ${y.toFixed(1)}) skewX(${skew.toFixed(1)})">` +
|
|
79
|
-
`${ch}</text
|
|
76
|
+
`${ch}</text>`,
|
|
80
77
|
)
|
|
81
78
|
}
|
|
82
79
|
|
|
@@ -89,7 +86,7 @@ export function renderSvg(code: string): string {
|
|
|
89
86
|
const y2 = Math.random() * HEIGHT
|
|
90
87
|
const stroke = `hsl(${Math.floor(Math.random() * 360)}, 50%, 40%)`
|
|
91
88
|
lines.push(
|
|
92
|
-
`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${stroke}" stroke-width="1.5"
|
|
89
|
+
`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${stroke}" stroke-width="1.5" />`,
|
|
93
90
|
)
|
|
94
91
|
}
|
|
95
92
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `captcha()` — form/JSON guard middleware. Place after `csrf()` and
|
|
3
|
+
* (optionally) after a rate limiter so cheap rejections never reach the
|
|
4
|
+
* CAPTCHA verifier.
|
|
5
|
+
*
|
|
6
|
+
* router.post('/register', [
|
|
7
|
+
* csrf(),
|
|
8
|
+
* captcha({ types: ['honeypot', 'pow'] }),
|
|
9
|
+
* ], handler)
|
|
10
|
+
*
|
|
11
|
+
* The middleware resolves a `CaptchaManager` and `Cache` from the
|
|
12
|
+
* request-scoped container. Apps that opt out of replay prevention
|
|
13
|
+
* (idempotent endpoints) should use the validation rule instead.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Cache } from '@strav/cache'
|
|
17
|
+
import type { HttpContext, MiddlewareFn } from '@strav/http'
|
|
18
|
+
import { CaptchaManager } from '../captcha_manager.ts'
|
|
19
|
+
import { consumeReplay } from '../replay.ts'
|
|
20
|
+
import type { ChallengeName, FailureReason } from '../types.ts'
|
|
21
|
+
|
|
22
|
+
export interface CaptchaMiddlewareOptions {
|
|
23
|
+
/** Challenge types accepted by this guard. @default ['honeypot', 'pow'] */
|
|
24
|
+
types?: ChallengeName[]
|
|
25
|
+
/** Override the field names from the manager config. */
|
|
26
|
+
honeypotField?: string
|
|
27
|
+
tokenField?: string
|
|
28
|
+
responseField?: string
|
|
29
|
+
/** Skip the guard for matching requests (e.g. internal health checks). */
|
|
30
|
+
skip?: (ctx: HttpContext) => boolean | Promise<boolean>
|
|
31
|
+
/** Custom failure response. Falls back to JSON 422. */
|
|
32
|
+
onFailure?: (ctx: HttpContext, reason: FailureReason) => Response | Promise<Response>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function captcha(options: CaptchaMiddlewareOptions = {}): MiddlewareFn {
|
|
36
|
+
const types = (options.types ?? ['honeypot', 'pow']) as ChallengeName[]
|
|
37
|
+
const useHoneypot = types.includes('honeypot')
|
|
38
|
+
const challengeTypes = types.filter((t) => t !== 'honeypot')
|
|
39
|
+
|
|
40
|
+
return async (ctx, next) => {
|
|
41
|
+
if (await options.skip?.(ctx)) return next()
|
|
42
|
+
|
|
43
|
+
// Only guard state-changing methods. GET/HEAD shouldn't carry forms.
|
|
44
|
+
const method = ctx.request.method.toUpperCase()
|
|
45
|
+
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return next()
|
|
46
|
+
|
|
47
|
+
const manager = ctx.container.resolve(CaptchaManager)
|
|
48
|
+
const honeypotField = options.honeypotField ?? manager.config.honeypotField
|
|
49
|
+
const tokenField = options.tokenField ?? manager.config.tokenField
|
|
50
|
+
const responseField = options.responseField ?? manager.config.responseField
|
|
51
|
+
|
|
52
|
+
const body = await readBody(ctx)
|
|
53
|
+
|
|
54
|
+
if (useHoneypot) {
|
|
55
|
+
const trap = body[honeypotField]
|
|
56
|
+
if (typeof trap === 'string' && trap.trim() !== '') {
|
|
57
|
+
return failure(ctx, 'honeypot_tripped', options)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (challengeTypes.length === 0) return next()
|
|
62
|
+
|
|
63
|
+
const token = typeof body[tokenField] === 'string' ? (body[tokenField] as string) : ''
|
|
64
|
+
if (!token) return failure(ctx, 'token_missing', options)
|
|
65
|
+
|
|
66
|
+
const result = manager.verify(token, body[responseField], body)
|
|
67
|
+
if (!result.ok) return failure(ctx, result.reason, options)
|
|
68
|
+
|
|
69
|
+
if (!challengeTypes.includes(result.payload.t)) {
|
|
70
|
+
// Token is valid but for a type this guard wasn't asked to accept —
|
|
71
|
+
// happens when a challenge was issued for a different form.
|
|
72
|
+
return failure(ctx, 'token_invalid', options)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const cache = ctx.container.resolve(Cache)
|
|
76
|
+
const fresh = await consumeReplay(cache, result.payload)
|
|
77
|
+
if (!fresh) return failure(ctx, 'replayed', options)
|
|
78
|
+
|
|
79
|
+
return next()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function readBody(ctx: HttpContext): Promise<Record<string, unknown>> {
|
|
84
|
+
try {
|
|
85
|
+
const body = await ctx.request.input()
|
|
86
|
+
return body && typeof body === 'object' ? (body as Record<string, unknown>) : {}
|
|
87
|
+
} catch {
|
|
88
|
+
return {}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function failure(
|
|
93
|
+
ctx: HttpContext,
|
|
94
|
+
reason: FailureReason,
|
|
95
|
+
options: CaptchaMiddlewareOptions,
|
|
96
|
+
): Promise<Response> {
|
|
97
|
+
if (options.onFailure) return options.onFailure(ctx, reason)
|
|
98
|
+
return ctx.response.json({ errors: { _captcha: [reason] } }, { status: 422 })
|
|
99
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation rule — `rule.custom('captcha')` integration for `FormRequest`.
|
|
3
|
+
*
|
|
4
|
+
* Register once at boot:
|
|
5
|
+
*
|
|
6
|
+
* import { registerCaptchaRule } from '@strav/captcha/http'
|
|
7
|
+
* registerCaptchaRule()
|
|
8
|
+
*
|
|
9
|
+
* Then reference in a `FormRequest`:
|
|
10
|
+
*
|
|
11
|
+
* rules() {
|
|
12
|
+
* return {
|
|
13
|
+
* _captcha: rule.custom('captcha'),
|
|
14
|
+
* email: rule.string().email(),
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* NOTE: the rule does NOT perform replay prevention (`FormRequest`
|
|
19
|
+
* validation runs before route handlers and may be re-run on retry).
|
|
20
|
+
* For replay-safe checks use the `captcha()` middleware. The rule fits
|
|
21
|
+
* idempotent endpoints where double-submit is harmless.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { registerRule } from '@strav/http'
|
|
25
|
+
import { CaptchaManager } from '../captcha_manager.ts'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register the `captcha` rule on the http rule registry. Idempotent —
|
|
29
|
+
* safe to call from boot in both production and tests.
|
|
30
|
+
*/
|
|
31
|
+
export function registerCaptchaRule(): void {
|
|
32
|
+
registerRule<unknown>('captcha', async (value, ctx) => {
|
|
33
|
+
const manager = ctx.container.resolve(CaptchaManager)
|
|
34
|
+
const token = typeof value === 'string' ? value : ''
|
|
35
|
+
const body = (await safeInput(ctx)) ?? {}
|
|
36
|
+
const responseField = manager.config.responseField
|
|
37
|
+
const result = manager.verify(token, body[responseField], body)
|
|
38
|
+
return result.ok ? true : `captcha.${result.reason}`
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function safeInput(ctx: Parameters<Parameters<typeof registerRule>[1]>[1]): Promise<Record<string, unknown> | undefined> {
|
|
43
|
+
try {
|
|
44
|
+
const body = await ctx.request.input()
|
|
45
|
+
return body && typeof body === 'object' ? (body as Record<string, unknown>) : undefined
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// `@strav/captcha/http` — middleware, validation rule, refresh routes.
|
|
2
|
+
|
|
3
|
+
export { captcha, type CaptchaMiddlewareOptions } from './captcha_middleware.ts'
|
|
4
|
+
export { registerCaptchaRule } from './captcha_rule.ts'
|
|
5
|
+
export { mountCaptchaRoutes, type MountCaptchaRoutesOptions } from './refresh_routes.ts'
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mountCaptchaRoutes(router)` — refresh endpoint at `<prefix>/:type` so
|
|
3
|
+
* client-side widgets can request a new challenge without a full page
|
|
4
|
+
* reload. Stateless, no session required.
|
|
5
|
+
*
|
|
6
|
+
* mountCaptchaRoutes(router)
|
|
7
|
+
* // → GET /__captcha/svg, GET /__captcha/pow
|
|
8
|
+
*
|
|
9
|
+
* The route handler resolves `CaptchaManager` from the per-request
|
|
10
|
+
* container. Layer your own rate-limit middleware on the group prefix —
|
|
11
|
+
* this package doesn't ship one (a leaky-bucket rate limiter is the
|
|
12
|
+
* caller's concern).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Router } from '@strav/http'
|
|
16
|
+
import { CaptchaManager } from '../captcha_manager.ts'
|
|
17
|
+
import type { ChallengeName } from '../types.ts'
|
|
18
|
+
|
|
19
|
+
export interface MountCaptchaRoutesOptions {
|
|
20
|
+
/** URL prefix. @default '/__captcha' */
|
|
21
|
+
prefix?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function mountCaptchaRoutes(router: Router, options: MountCaptchaRoutesOptions = {}): void {
|
|
25
|
+
const prefix = options.prefix ?? '/__captcha'
|
|
26
|
+
|
|
27
|
+
router.group({ prefix }, (r) => {
|
|
28
|
+
r.get('/:type', (ctx) => {
|
|
29
|
+
const manager = ctx.container.resolve(CaptchaManager)
|
|
30
|
+
const type = ctx.request.params.type as ChallengeName | undefined
|
|
31
|
+
if (!type || !manager.registry.has(type)) {
|
|
32
|
+
return ctx.response.json({ error: 'Unknown challenge type' }, { status: 404 })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const issued = manager.issue(type)
|
|
36
|
+
|
|
37
|
+
// SVG type returns the image directly — easy `<img src=…>` embed.
|
|
38
|
+
// Other types return JSON so the client can hydrate its own widget.
|
|
39
|
+
if (type === 'svg' && issued.html) {
|
|
40
|
+
return new Response(issued.html, {
|
|
41
|
+
status: 200,
|
|
42
|
+
headers: {
|
|
43
|
+
'Content-Type': 'image/svg+xml',
|
|
44
|
+
'Cache-Control': 'no-store',
|
|
45
|
+
// Pass the token via header so the client can stash it without
|
|
46
|
+
// re-parsing the SVG body.
|
|
47
|
+
'X-Captcha-Token': issued.token,
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return ctx.response.json({
|
|
53
|
+
token: issued.token,
|
|
54
|
+
props: issued.props,
|
|
55
|
+
html: issued.html,
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,44 +1,27 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
export {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export {
|
|
16
|
-
|
|
17
|
-
// View helper / @captcha directive support
|
|
1
|
+
// Public API of @strav/captcha.
|
|
2
|
+
//
|
|
3
|
+
// Root barrel exports the manager + provider + types + token + replay
|
|
4
|
+
// + challenge primitives. Integrations ship under subpaths:
|
|
5
|
+
// - `@strav/captcha/http` (middleware, validation rule, refresh routes)
|
|
6
|
+
// - `@strav/captcha/view` (form-fragment helper for view.globals)
|
|
7
|
+
|
|
8
|
+
export { honeypotChallenge } from './challenges/honeypot_challenge.ts'
|
|
9
|
+
export { countLeadingZeroBits, powChallenge } from './challenges/pow_challenge.ts'
|
|
10
|
+
export { renderSvg, svgChallenge } from './challenges/svg_challenge.ts'
|
|
11
|
+
export { CaptchaError } from './captcha_error.ts'
|
|
12
|
+
export { CaptchaManager, type CaptchaManagerOptions } from './captcha_manager.ts'
|
|
13
|
+
export { CaptchaProvider, type CaptchaProviderOptions } from './captcha_provider.ts'
|
|
14
|
+
export { ChallengeRegistry } from './registry.ts'
|
|
15
|
+
export { consumeReplay } from './replay.ts'
|
|
16
|
+
export { hashAnswer, randomHex, safeEqual, signToken, verifyToken } from './token.ts'
|
|
18
17
|
export {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
export { renderSvg } from './challenges/svg.ts'
|
|
29
|
-
|
|
30
|
-
// PoW utilities (count leading zero bits — useful for testing)
|
|
31
|
-
export { countLeadingZeroBits } from './challenges/pow.ts'
|
|
32
|
-
|
|
33
|
-
// Types
|
|
34
|
-
export { DEFAULTS } from './types.ts'
|
|
35
|
-
export type {
|
|
36
|
-
ChallengeName,
|
|
37
|
-
ChallengeType,
|
|
38
|
-
CaptchaTokenPayload,
|
|
39
|
-
CaptchaOptions,
|
|
40
|
-
IssueOptions,
|
|
41
|
-
IssuedChallenge,
|
|
42
|
-
VerifyResult,
|
|
43
|
-
FailureReason,
|
|
18
|
+
type CaptchaConfig,
|
|
19
|
+
type CaptchaTokenPayload,
|
|
20
|
+
type ChallengeName,
|
|
21
|
+
type ChallengeType,
|
|
22
|
+
DEFAULTS,
|
|
23
|
+
type FailureReason,
|
|
24
|
+
type IssueOptions,
|
|
25
|
+
type IssuedChallenge,
|
|
26
|
+
type VerifyResult,
|
|
44
27
|
} from './types.ts'
|