@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.
@@ -1,109 +0,0 @@
1
- <script setup lang="ts">
2
- import { onMounted, ref } from 'vue'
3
-
4
- interface Props {
5
- challenge: string
6
- difficulty: number
7
- tokenField?: string
8
- responseField?: string
9
- }
10
-
11
- const props = withDefaults(defineProps<Props>(), {
12
- tokenField: '_captcha',
13
- responseField: '_captcha_answer',
14
- })
15
-
16
- const status = ref<'idle' | 'solving' | 'solved' | 'error'>('idle')
17
- const elapsedMs = ref(0)
18
-
19
- onMounted(() => {
20
- void solve()
21
- })
22
-
23
- async function solve() {
24
- status.value = 'solving'
25
- const start = performance.now()
26
-
27
- try {
28
- const nonce = await findNonce(props.challenge, props.difficulty)
29
- elapsedMs.value = Math.round(performance.now() - start)
30
- writeAnswer(nonce)
31
- status.value = 'solved'
32
- } catch (err) {
33
- console.error('[captcha/pow] failed', err)
34
- status.value = 'error'
35
- }
36
- }
37
-
38
- /**
39
- * Find a nonce N such that sha256(challenge + ':' + N) has at least
40
- * `difficulty` leading zero bits. We try a counter — simple and easy to
41
- * verify on the server with the same construction.
42
- *
43
- * Yields back to the event loop every 256 attempts so the page stays
44
- * responsive on phones / weaker CPUs.
45
- */
46
- async function findNonce(challenge: string, difficulty: number): Promise<string> {
47
- if (typeof crypto === 'undefined' || !crypto.subtle) {
48
- throw new Error('Web Crypto API unavailable')
49
- }
50
-
51
- const encoder = new TextEncoder()
52
- let nonce = 0
53
-
54
- while (true) {
55
- for (let i = 0; i < 256; i++) {
56
- const candidate = String(nonce++)
57
- const buffer = await crypto.subtle.digest(
58
- 'SHA-256',
59
- encoder.encode(challenge + ':' + candidate)
60
- )
61
- if (leadingZeroBits(new Uint8Array(buffer)) >= difficulty) {
62
- return candidate
63
- }
64
- }
65
- // Yield to keep the UI alive
66
- await new Promise(resolve => setTimeout(resolve, 0))
67
- }
68
- }
69
-
70
- function leadingZeroBits(bytes: Uint8Array): number {
71
- let bits = 0
72
- for (let i = 0; i < bytes.length; i++) {
73
- const b = bytes[i]!
74
- if (b === 0) {
75
- bits += 8
76
- continue
77
- }
78
- let n = b
79
- while ((n & 0x80) === 0) {
80
- bits++
81
- n <<= 1
82
- }
83
- break
84
- }
85
- return bits
86
- }
87
-
88
- /**
89
- * Write the nonce into the form's hidden answer field. We look up the
90
- * nearest enclosing form and find the input by name — robust against
91
- * forms that re-render or move the widget around.
92
- */
93
- function writeAnswer(nonce: string) {
94
- const root = document.querySelector(`[data-vue="captcha/pow"]`)
95
- const form = root?.closest('form')
96
- if (!form) return
97
- const input = form.querySelector(`input[name="${props.responseField}"]`)
98
- if (input instanceof HTMLInputElement) input.value = nonce
99
- }
100
- </script>
101
-
102
- <template>
103
- <div class="captcha-pow" :data-status="status">
104
- <span v-if="status === 'idle'" aria-hidden="true">⋯</span>
105
- <span v-else-if="status === 'solving'">Verifying you're human…</span>
106
- <span v-else-if="status === 'solved'" aria-live="polite">✓ Verified ({{ elapsedMs }}ms)</span>
107
- <span v-else class="captcha-pow-error">Verification failed</span>
108
- </div>
109
- </template>
@@ -1,43 +0,0 @@
1
- <script setup lang="ts">
2
- interface Props {
3
- tokenField?: string
4
- refreshUrl?: string
5
- }
6
-
7
- const props = withDefaults(defineProps<Props>(), {
8
- tokenField: '_captcha',
9
- refreshUrl: '/__captcha/svg',
10
- })
11
-
12
- async function refresh() {
13
- const response = await fetch(props.refreshUrl, { headers: { Accept: 'image/svg+xml' } })
14
- if (!response.ok) return
15
- const newToken = response.headers.get('X-Captcha-Token')
16
- const svgBody = await response.text()
17
-
18
- // Find the SVG sibling — refresh button is a span inside the
19
- // `.captcha-svg` wrapper that also holds the SSR-rendered SVG.
20
- const root = document.querySelector('[data-vue="captcha/refresh"]')
21
- const wrapper = root?.parentElement
22
- const oldSvg = wrapper?.querySelector('svg')
23
- if (oldSvg) {
24
- const tmp = document.createElement('div')
25
- tmp.innerHTML = svgBody
26
- const newSvg = tmp.querySelector('svg')
27
- if (newSvg) oldSvg.replaceWith(newSvg)
28
- }
29
-
30
- // Update the hidden token in the same <form>.
31
- if (newToken) {
32
- const form = wrapper?.closest('form')
33
- const input = form?.querySelector(`input[name="${props.tokenField}"]`)
34
- if (input instanceof HTMLInputElement) input.value = newToken
35
- }
36
- }
37
- </script>
38
-
39
- <template>
40
- <button type="button" class="captcha-refresh" @click="refresh" aria-label="Refresh challenge">
41
-
42
- </button>
43
- </template>
package/src/middleware.ts DELETED
@@ -1,125 +0,0 @@
1
- import type { Middleware, Context, Session } from '@strav/http'
2
- import { CacheManager } from '@strav/kernel'
3
- import type { CacheStore } from '@strav/kernel'
4
- import type { CaptchaOptions, FailureReason, ChallengeName } from './types.ts'
5
- import { DEFAULTS } from './types.ts'
6
- import { verifyChallenge } from './challenges.ts'
7
- import { consumeReplay } from './token.ts'
8
-
9
- /**
10
- * Form/JSON guard. Place after `csrf()` and (optionally) after `rateLimit()`
11
- * so cheap rejections don't even attempt CAPTCHA verification.
12
- *
13
- * @example
14
- * router.post('/register', [
15
- * rateLimit({ window: 60_000, max: 5 }),
16
- * csrf(),
17
- * captcha({ types: ['honeypot', 'pow'] }),
18
- * ], handler)
19
- */
20
- export function captcha(options: CaptchaOptions = {}): Middleware {
21
- const types = (options.types ?? ['honeypot', 'pow']) as ChallengeName[]
22
- const honeypotField = options.honeypotField ?? DEFAULTS.honeypotField
23
- const tokenField = options.tokenField ?? DEFAULTS.tokenField
24
- const responseField = options.responseField ?? DEFAULTS.responseField
25
-
26
- // Lazy default — calling CacheManager.store at module-load would throw
27
- // when the captcha import lands before the container is wired.
28
- const resolveStore = (): CacheStore => options.store ?? CacheManager.store
29
-
30
- const useHoneypot = types.includes('honeypot')
31
-
32
- return async (ctx, next) => {
33
- if (options.skip?.(ctx)) return next()
34
-
35
- // Only guard state-changing methods. GET/HEAD shouldn't carry forms.
36
- if (['GET', 'HEAD', 'OPTIONS'].includes(ctx.method)) return next()
37
-
38
- const body = await readBody(ctx)
39
-
40
- if (useHoneypot) {
41
- const trap = body[honeypotField]
42
- if (typeof trap === 'string' && trap.trim() !== '') {
43
- return failure(ctx, 'honeypot_tripped', body, options)
44
- }
45
- }
46
-
47
- // If honeypot is the only requested type, we're done.
48
- const challengeTypes = types.filter(t => t !== 'honeypot')
49
- if (challengeTypes.length === 0) return next()
50
-
51
- const token = typeof body[tokenField] === 'string' ? (body[tokenField] as string) : ''
52
- if (!token) return failure(ctx, 'token_missing', body, options)
53
-
54
- const result = verifyChallenge(token, body[responseField], body)
55
- if (!result.ok) return failure(ctx, result.reason, body, options)
56
-
57
- if (!challengeTypes.includes(result.payload.t)) {
58
- // Token is valid but for a type this guard wasn't asked to accept —
59
- // could happen if a challenge was issued for a different form.
60
- return failure(ctx, 'token_invalid', body, options)
61
- }
62
-
63
- const fresh = await consumeReplay(resolveStore(), result.payload)
64
- if (!fresh) return failure(ctx, 'replayed', body, options)
65
-
66
- return next()
67
- }
68
- }
69
-
70
- /**
71
- * Read the parsed body without crashing on text/plain or empty bodies.
72
- * Returns `{}` so callers can index without null-checks.
73
- */
74
- async function readBody(ctx: Context): Promise<Record<string, unknown>> {
75
- try {
76
- const body = await ctx.body<unknown>()
77
- return body && typeof body === 'object' ? (body as Record<string, unknown>) : {}
78
- } catch {
79
- return {}
80
- }
81
- }
82
-
83
- async function failure(
84
- ctx: Context,
85
- reason: FailureReason,
86
- body: Record<string, unknown>,
87
- options: CaptchaOptions
88
- ): Promise<Response> {
89
- if (options.onFailure) return options.onFailure(ctx, reason)
90
-
91
- if (wantsJson(ctx)) {
92
- return ctx.json({ errors: { _captcha: [reason] } }, 422)
93
- }
94
-
95
- // Form path: flash errors + old input, redirect back to the referer
96
- // (or root). Mirrors the convention used by `validate()` consumers.
97
- const session = ctx.get<Session | undefined>('session')
98
- if (session) {
99
- session.flash('errors', { _captcha: [reason] })
100
- session.flash('old', stripCaptchaFields(body, options))
101
- await session.save()
102
- }
103
-
104
- const referer = ctx.header('referer') ?? '/'
105
- return ctx.redirect(referer, 303)
106
- }
107
-
108
- function wantsJson(ctx: Context): boolean {
109
- const accept = ctx.header('accept') ?? ''
110
- if (accept.includes('application/json')) return true
111
- if (ctx.header('x-requested-with') === 'XMLHttpRequest') return true
112
- const contentType = ctx.header('content-type') ?? ''
113
- return contentType.includes('application/json')
114
- }
115
-
116
- function stripCaptchaFields(
117
- body: Record<string, unknown>,
118
- options: CaptchaOptions
119
- ): Record<string, unknown> {
120
- const out = { ...body }
121
- delete out[options.honeypotField ?? DEFAULTS.honeypotField]
122
- delete out[options.tokenField ?? DEFAULTS.tokenField]
123
- delete out[options.responseField ?? DEFAULTS.responseField]
124
- return out
125
- }
package/src/route.ts DELETED
@@ -1,73 +0,0 @@
1
- import type { Router, Context } from '@strav/http'
2
- import { rateLimit } from '@strav/http'
3
- import { issueChallenge } from './challenges.ts'
4
- import { listChallengeTypes } from './registry.ts'
5
- import type { ChallengeName } from './types.ts'
6
-
7
- /**
8
- * Mount a refresh endpoint at `<prefix>/:type` so client-side widgets
9
- * (e.g. the SVG refresh button) can request a new challenge without a
10
- * full page reload. Stateless, no session required.
11
- *
12
- * Auto-rate-limited per IP — 30 issuances/minute by default. Override
13
- * by passing your own `rateLimit()` middleware in `extraMiddleware`,
14
- * or set `rateLimit: false` to disable (not recommended in production).
15
- *
16
- * @example
17
- * mountCaptchaRoutes(router)
18
- * // → GET /__captcha/svg, GET /__captcha/pow
19
- */
20
- export function mountCaptchaRoutes(
21
- router: Router,
22
- options: {
23
- prefix?: string
24
- rateLimit?: false | { window?: number; max?: number }
25
- extraMiddleware?: ReturnType<typeof rateLimit>[]
26
- } = {}
27
- ): void {
28
- const prefix = options.prefix ?? '/__captcha'
29
-
30
- const middleware = []
31
- if (options.extraMiddleware?.length) {
32
- middleware.push(...options.extraMiddleware)
33
- } else if (options.rateLimit !== false) {
34
- middleware.push(
35
- rateLimit({
36
- window: options.rateLimit?.window ?? 60_000,
37
- max: options.rateLimit?.max ?? 30,
38
- })
39
- )
40
- }
41
-
42
- router.group({ prefix, middleware }, r => {
43
- r.get('/:type', async (ctx: Context) => {
44
- const type = ctx.params.type as ChallengeName
45
- if (!listChallengeTypes().includes(type)) {
46
- return ctx.json({ error: 'Unknown challenge type' }, 404)
47
- }
48
-
49
- const issued = issueChallenge(type)
50
-
51
- // SVG type returns the image directly — easy <img src=…> embed.
52
- // Other types return JSON so the client can hydrate its own widget.
53
- if (type === 'svg' && issued.html) {
54
- return new Response(issued.html, {
55
- status: 200,
56
- headers: {
57
- 'Content-Type': 'image/svg+xml',
58
- 'Cache-Control': 'no-store',
59
- // Pass the token via header so the client can stash it without
60
- // re-parsing the SVG body. Browsers expose this on fetch responses.
61
- 'X-Captcha-Token': issued.token,
62
- },
63
- })
64
- }
65
-
66
- return ctx.json({
67
- token: issued.token,
68
- props: issued.props,
69
- html: issued.html,
70
- })
71
- })
72
- })
73
- }
@@ -1,31 +0,0 @@
1
- import type { Rule } from '@strav/http'
2
- import { verifyChallenge } from './challenges.ts'
3
-
4
- /**
5
- * Validation rule for handlers that prefer `validate()` integration over
6
- * the middleware. Bind the user's response (and optionally the full body
7
- * for honeypot/extension checks) when constructing the rule — closure
8
- * keeps it per-request, no shared mutable state.
9
- *
10
- * NOTE: this rule does not perform replay prevention — `validate()` is
11
- * sync and has no place to read a cache store. For replay-safe checks
12
- * use the `captcha()` middleware. The rule fits idempotent endpoints
13
- * where double-submit is harmless.
14
- *
15
- * @example
16
- * const body = await ctx.body<Record<string, unknown>>()
17
- * const { errors } = validate(body, {
18
- * _captcha: [captchaRule(body._captcha_answer, body)],
19
- * email: [required(), email()],
20
- * })
21
- */
22
- export function captchaRule(response: unknown, body?: Record<string, unknown>): Rule {
23
- return {
24
- name: 'captcha',
25
- validate(value) {
26
- const token = typeof value === 'string' ? value : ''
27
- const result = verifyChallenge(token, response, body ?? {})
28
- return result.ok ? null : `captcha.${result.reason}`
29
- },
30
- }
31
- }
@@ -1,105 +0,0 @@
1
- import { ViewEngine } from '@strav/view'
2
- import { issueChallenge } from './challenges.ts'
3
- import { DEFAULTS } from './types.ts'
4
-
5
- /**
6
- * Default field names. Kept on the helper itself so a single config
7
- * point flows into both middleware and template rendering — change
8
- * these and `@captcha` re-emits matching field names automatically.
9
- */
10
- const config = {
11
- honeypotField: DEFAULTS.honeypotField,
12
- tokenField: DEFAULTS.tokenField,
13
- responseField: DEFAULTS.responseField,
14
- difficulty: DEFAULTS.difficulty,
15
- ttlMinutes: DEFAULTS.ttlMinutes,
16
- }
17
-
18
- /**
19
- * Optionally configure helper-side defaults so the markup matches the
20
- * server-side `captcha()` middleware options. Required only when an app
21
- * customizes field names — defaults match middleware defaults.
22
- *
23
- * @example
24
- * configureCaptchaHelper({ honeypotField: 'phone_number' })
25
- */
26
- export function configureCaptchaHelper(opts: Partial<typeof config>): void {
27
- Object.assign(config, opts)
28
- }
29
-
30
- /**
31
- * Render the form fragment for a challenge. Returns ready-to-emit HTML
32
- * including the honeypot, the hidden token, and (for visible types) the
33
- * answer input + island/SVG.
34
- *
35
- * Synchronous: `issueChallenge` is sync, the renderer just concatenates
36
- * strings — safe to call from inside the template render loop.
37
- */
38
- export function captchaHelper(variant?: string): string {
39
- const honeypot = renderHoneypot()
40
-
41
- if (!variant || variant === 'honeypot') return honeypot
42
-
43
- if (variant === 'pow') {
44
- const issued = issueChallenge('pow', { difficulty: config.difficulty, ttlMinutes: config.ttlMinutes })
45
- const props = JSON.stringify({
46
- ...issued.props,
47
- tokenField: config.tokenField,
48
- responseField: config.responseField,
49
- }).replace(/'/g, '&#39;')
50
- return (
51
- honeypot +
52
- `<input type="hidden" name="${attr(config.tokenField)}" value="${attr(issued.token)}">` +
53
- `<input type="hidden" name="${attr(config.responseField)}" value="">` +
54
- `<div data-vue="captcha/pow" data-props='${props}'></div>`
55
- )
56
- }
57
-
58
- if (variant === 'svg') {
59
- const issued = issueChallenge('svg', { ttlMinutes: config.ttlMinutes })
60
- const refreshProps = JSON.stringify({ tokenField: config.tokenField }).replace(/'/g, '&#39;')
61
- return (
62
- honeypot +
63
- `<input type="hidden" name="${attr(config.tokenField)}" value="${attr(issued.token)}">` +
64
- `<div class="captcha-svg">` +
65
- (issued.html ?? '') +
66
- // Sibling-not-child: the Vue island replaces this element's content,
67
- // but the SVG above stays untouched until the user clicks refresh.
68
- `<span data-vue="captcha/refresh" data-props='${refreshProps}'></span>` +
69
- `</div>` +
70
- `<input type="text" name="${attr(config.responseField)}" autocomplete="off" ` +
71
- `inputmode="latin" autocapitalize="characters" required aria-label="Type the characters shown">`
72
- )
73
- }
74
-
75
- throw new Error(`@captcha: unknown variant "${variant}" (expected 'pow' | 'svg' | 'honeypot' or none)`)
76
- }
77
-
78
- function renderHoneypot(): string {
79
- const name = attr(config.honeypotField)
80
- // Triple defense: aria-hidden, tabindex=-1, off-screen via inline style.
81
- // Don't use `display:none` — some bots skip those fields.
82
- return (
83
- `<div aria-hidden="true" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden">` +
84
- `<label for="${name}">Leave this field empty</label>` +
85
- `<input type="text" id="${name}" name="${name}" tabindex="-1" autocomplete="off" value="">` +
86
- `</div>`
87
- )
88
- }
89
-
90
- function attr(s: string): string {
91
- return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
92
- }
93
-
94
- /**
95
- * Wire `__captcha(variant)` into the view engine so `@captcha` works.
96
- * Call once at boot, after ViewEngine is constructed.
97
- *
98
- * @example
99
- * import { installCaptchaHelpers } from '@strav/captcha'
100
- * // …after ViewEngine is registered in the container
101
- * installCaptchaHelpers()
102
- */
103
- export function installCaptchaHelpers(): void {
104
- ViewEngine.setGlobal('__captcha', captchaHelper)
105
- }
package/tsconfig.json DELETED
@@ -1,5 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "include": ["src/**/*.ts"],
4
- "exclude": ["node_modules", "tests"]
5
- }