k9guard 1.0.1 → 1.0.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/LICENSE +21 -0
- package/README.md +74 -34
- package/docs/tr/README.md +76 -36
- package/package.json +7 -7
- package/src/K9Guard.ts +18 -10
- package/src/core/captchaGenerator.ts +131 -66
- package/src/core/captchaValidator.ts +76 -11
- package/src/types/index.ts +34 -22
- package/src/utils/crypto.ts +83 -250
- package/src/utils/emojiGenerator.ts +88 -0
- package/src/utils/imageGenerator.ts +152 -0
- package/src/utils/reverseGenerator.ts +5 -1
- package/src/utils/scrambleGenerator.ts +25 -13
- package/src/utils/sequenceGenerator.ts +5 -8
- package/src/locale/en.json +0 -46
- package/src/locale/tr.json +0 -46
- package/src/utils/logicGenerator.ts +0 -18
- package/src/utils/riddleBank.ts +0 -18
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { createHash, randomBytes } from '../utils/crypto';
|
|
2
2
|
import { Random } from '../utils/random';
|
|
3
|
-
import { RiddleBank } from '../utils/riddleBank';
|
|
4
3
|
import { SequenceGenerator } from '../utils/sequenceGenerator';
|
|
5
4
|
import { ScrambleGenerator } from '../utils/scrambleGenerator';
|
|
6
|
-
import { LogicGenerator } from '../utils/logicGenerator';
|
|
7
5
|
import { ReverseGenerator } from '../utils/reverseGenerator';
|
|
8
6
|
import { CustomQuestionGenerator } from '../utils/customQuestionGenerator';
|
|
9
7
|
import { CustomQuestionValidator } from '../validators/customQuestionValidator';
|
|
10
|
-
import
|
|
8
|
+
import { ImageGenerator } from '../utils/imageGenerator';
|
|
9
|
+
import { EmojiGenerator } from '../utils/emojiGenerator';
|
|
10
|
+
import type { K9GuardOptions, K9GuardCustomOptions, CaptchaChallenge, StoredChallenge, MathCaptcha, TextCaptcha, SequenceCaptcha, ScrambleCaptcha, ReverseCaptcha, MixedCaptcha, CustomCaptcha, ImageCaptcha, EmojiCaptcha, CustomQuestion } from '../types';
|
|
11
|
+
|
|
12
|
+
// Bounded nonce store: evicts the oldest entry once capacity is reached to
|
|
13
|
+
// prevent unbounded memory growth while still blocking same-process replays.
|
|
14
|
+
const NONCE_STORE_MAX = 10_000;
|
|
11
15
|
|
|
12
16
|
export class CaptchaGenerator {
|
|
13
17
|
private standardOptions: K9GuardOptions | null = null;
|
|
14
18
|
private customOptions: K9GuardCustomOptions | null = null;
|
|
15
19
|
private customGenerator: CustomQuestionGenerator | null = null;
|
|
16
|
-
|
|
20
|
+
// Keyed by nonce; stores the full server-side record including answer hash and salt.
|
|
21
|
+
// answer, hashedAnswer and salt are never included in the public CaptchaChallenge.
|
|
22
|
+
private store: Map<string, StoredChallenge> = new Map();
|
|
17
23
|
|
|
18
24
|
// set up the generator and check custom questions if they exist
|
|
19
25
|
constructor(options: K9GuardOptions | K9GuardCustomOptions) {
|
|
@@ -46,39 +52,41 @@ export class CaptchaGenerator {
|
|
|
46
52
|
return this.standardOptions.difficulty;
|
|
47
53
|
}
|
|
48
54
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
return this.standardOptions.locale || 'en';
|
|
55
|
+
// Look up the internal StoredChallenge by nonce for use during validation.
|
|
56
|
+
lookup(nonce: string): StoredChallenge | undefined {
|
|
57
|
+
return this.store.get(nonce);
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
//
|
|
57
|
-
|
|
60
|
+
// Stores the full record server-side; returns a public CaptchaChallenge
|
|
61
|
+
// that is safe to send to the client (no answer, hashedAnswer or salt).
|
|
62
|
+
private createChallenge(base: Omit<StoredChallenge, 'nonce' | 'expiry' | 'hashedAnswer' | 'salt'>): CaptchaChallenge {
|
|
58
63
|
let nonce: string;
|
|
59
64
|
do {
|
|
60
|
-
// NOTE: make sure each nonce is unique to stop replay attacks
|
|
61
65
|
nonce = Random.generateNonce();
|
|
62
|
-
} while (this.
|
|
66
|
+
} while (this.store.has(nonce));
|
|
63
67
|
|
|
64
|
-
|
|
68
|
+
// evict oldest entry before inserting to cap memory at NONCE_STORE_MAX
|
|
69
|
+
if (this.store.size >= NONCE_STORE_MAX) {
|
|
70
|
+
const oldest = this.store.keys().next().value;
|
|
71
|
+
if (oldest !== undefined) {
|
|
72
|
+
this.store.delete(oldest);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
65
75
|
|
|
66
|
-
// challenge will expire in 5 minutes
|
|
67
76
|
const expiry = Date.now() + 5 * 60 * 1000;
|
|
68
77
|
const salt = Random.generateSalt();
|
|
69
|
-
// never
|
|
78
|
+
// answer is never sent to the client; only its salted hash is stored
|
|
70
79
|
const hashedAnswer = createHash('sha256').update(base.answer.toString() + salt).digest('hex');
|
|
71
80
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
};
|
|
81
|
+
const stored: StoredChallenge = { ...base, nonce, expiry, hashedAnswer, salt };
|
|
82
|
+
this.store.set(nonce, stored);
|
|
83
|
+
|
|
84
|
+
// strip sensitive fields before returning to caller
|
|
85
|
+
const { answer: _answer, hashedAnswer: _ha, salt: _salt, ...publicChallenge } = stored;
|
|
86
|
+
return publicChallenge as CaptchaChallenge;
|
|
79
87
|
}
|
|
80
88
|
|
|
81
|
-
//
|
|
89
|
+
// evaluate arithmetic expression for the math captcha type
|
|
82
90
|
private calculateMath(a: number, op: string, b: number): number {
|
|
83
91
|
if (op === '+') {
|
|
84
92
|
return a + b;
|
|
@@ -91,16 +99,15 @@ export class CaptchaGenerator {
|
|
|
91
99
|
}
|
|
92
100
|
if (op === '/') {
|
|
93
101
|
if (b === 0) {
|
|
94
|
-
// can't divide by zero, return NaN
|
|
95
102
|
return Number.NaN;
|
|
96
103
|
}
|
|
97
|
-
// round to 2 decimals to avoid
|
|
104
|
+
// round to 2 decimals to avoid floating point representation issues
|
|
98
105
|
return Math.round((a / b) * 100) / 100;
|
|
99
106
|
}
|
|
100
107
|
return 0;
|
|
101
108
|
}
|
|
102
109
|
|
|
103
|
-
//
|
|
110
|
+
// dispatch to the correct generator based on configured captcha type
|
|
104
111
|
generate(): CaptchaChallenge {
|
|
105
112
|
if (this.customOptions) {
|
|
106
113
|
return this.generateCustom();
|
|
@@ -118,24 +125,24 @@ export class CaptchaGenerator {
|
|
|
118
125
|
if (captchaType === 'text') {
|
|
119
126
|
return this.generateText();
|
|
120
127
|
}
|
|
121
|
-
if (captchaType === 'riddle') {
|
|
122
|
-
return this.generateRiddle();
|
|
123
|
-
}
|
|
124
128
|
if (captchaType === 'sequence') {
|
|
125
129
|
return this.generateSequence();
|
|
126
130
|
}
|
|
127
131
|
if (captchaType === 'scramble') {
|
|
128
132
|
return this.generateScramble();
|
|
129
133
|
}
|
|
130
|
-
if (captchaType === 'logic') {
|
|
131
|
-
return this.generateLogic();
|
|
132
|
-
}
|
|
133
134
|
if (captchaType === 'reverse') {
|
|
134
135
|
return this.generateReverse();
|
|
135
136
|
}
|
|
136
137
|
if (captchaType === 'multi') {
|
|
137
138
|
return this.generateMulti();
|
|
138
139
|
}
|
|
140
|
+
if (captchaType === 'image') {
|
|
141
|
+
return this.generateImage();
|
|
142
|
+
}
|
|
143
|
+
if (captchaType === 'emoji') {
|
|
144
|
+
return this.generateEmoji();
|
|
145
|
+
}
|
|
139
146
|
return this.generateMixed();
|
|
140
147
|
}
|
|
141
148
|
|
|
@@ -145,26 +152,42 @@ export class CaptchaGenerator {
|
|
|
145
152
|
}
|
|
146
153
|
|
|
147
154
|
const custom = this.customGenerator.generate();
|
|
155
|
+
|
|
156
|
+
// an empty answer would allow bypass with any blank input; reject early
|
|
157
|
+
if (!custom.question || !custom.answer) {
|
|
158
|
+
throw new Error('Custom question pool returned an empty question or answer');
|
|
159
|
+
}
|
|
160
|
+
|
|
148
161
|
return this.createChallenge({ type: 'custom', question: custom.question, answer: custom.answer }) as CustomCaptcha;
|
|
149
162
|
}
|
|
150
163
|
|
|
151
164
|
private generateMath(): MathCaptcha {
|
|
152
|
-
|
|
153
|
-
let
|
|
165
|
+
const difficulty = this.getDifficulty();
|
|
166
|
+
let num1 = Random.getRandomNumber(difficulty);
|
|
167
|
+
let num2 = Random.getRandomNumber(difficulty);
|
|
154
168
|
const operator = Random.getRandomOperator();
|
|
155
169
|
|
|
170
|
+
// guarantee non-zero divisor; add 1 so the result is always >= 1
|
|
156
171
|
if (operator === '/' && num2 === 0) {
|
|
157
|
-
num2 = Random.getRandomNumber(
|
|
172
|
+
num2 = Random.getRandomNumber(difficulty) + 1;
|
|
158
173
|
}
|
|
159
174
|
|
|
160
|
-
const question = `${num1} ${operator} ${num2}`;
|
|
161
175
|
const answer = this.calculateMath(num1, operator, num2);
|
|
162
176
|
|
|
163
|
-
|
|
164
|
-
|
|
177
|
+
// calculateMath only returns NaN for division by zero, which is already
|
|
178
|
+
// prevented above — but guard here avoids any future regression without
|
|
179
|
+
// unbounded recursion: iterate instead of recurse
|
|
180
|
+
if (isNaN(answer) || !isFinite(answer)) {
|
|
181
|
+
num1 = Random.getRandomNumber(difficulty);
|
|
182
|
+
num2 = Random.getRandomNumber(difficulty) + 1;
|
|
183
|
+
return this.createChallenge({
|
|
184
|
+
type: 'math',
|
|
185
|
+
question: `${num1} + ${num2}`,
|
|
186
|
+
answer: num1 + num2
|
|
187
|
+
}) as MathCaptcha;
|
|
165
188
|
}
|
|
166
189
|
|
|
167
|
-
return this.createChallenge({ type: 'math', question
|
|
190
|
+
return this.createChallenge({ type: 'math', question: `${num1} ${operator} ${num2}`, answer }) as MathCaptcha;
|
|
168
191
|
}
|
|
169
192
|
|
|
170
193
|
private generateText(): TextCaptcha {
|
|
@@ -172,11 +195,6 @@ export class CaptchaGenerator {
|
|
|
172
195
|
return this.createChallenge({ type: 'text', question: text, answer: text }) as TextCaptcha;
|
|
173
196
|
}
|
|
174
197
|
|
|
175
|
-
private generateRiddle(): RiddleCaptcha {
|
|
176
|
-
const riddle = RiddleBank.getRandom(this.getLocale(), this.getDifficulty());
|
|
177
|
-
return this.createChallenge({ type: 'riddle', question: riddle.question, answer: riddle.answer }) as RiddleCaptcha;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
198
|
private generateSequence(): SequenceCaptcha {
|
|
181
199
|
const seq = SequenceGenerator.generate(this.getDifficulty());
|
|
182
200
|
return this.createChallenge({ type: 'sequence', question: seq.question, answer: seq.answer }) as SequenceCaptcha;
|
|
@@ -187,41 +205,65 @@ export class CaptchaGenerator {
|
|
|
187
205
|
return this.createChallenge({ type: 'scramble', question: scr.question, answer: scr.answer }) as ScrambleCaptcha;
|
|
188
206
|
}
|
|
189
207
|
|
|
190
|
-
private generateLogic(): LogicCaptcha {
|
|
191
|
-
const logic = LogicGenerator.getRandom(this.getLocale(), this.getDifficulty());
|
|
192
|
-
return this.createChallenge({ type: 'logic', question: logic.question, answer: logic.answer }) as LogicCaptcha;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
208
|
private generateReverse(): ReverseCaptcha {
|
|
196
209
|
const rev = ReverseGenerator.generate(this.getDifficulty());
|
|
197
210
|
return this.createChallenge({ type: 'reverse', question: rev.question, answer: rev.answer }) as ReverseCaptcha;
|
|
198
211
|
}
|
|
199
212
|
|
|
200
|
-
//
|
|
213
|
+
// randomly selects one of the available non-compound types
|
|
201
214
|
private generateMixed(): MixedCaptcha {
|
|
202
|
-
const types
|
|
215
|
+
const types = ['math', 'text', 'sequence', 'scramble', 'reverse'] as const;
|
|
203
216
|
const buffer = randomBytes(1) as any;
|
|
204
217
|
const randomType = types[buffer[0]! % types.length]!;
|
|
205
218
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
219
|
+
// resolve the raw answer directly so we can pass it to createChallenge;
|
|
220
|
+
// avoids mutating this.standardOptions and prevents race conditions
|
|
221
|
+
let question: string;
|
|
222
|
+
let answer: string | number;
|
|
223
|
+
|
|
224
|
+
if (randomType === 'math') {
|
|
225
|
+
const difficulty = this.getDifficulty();
|
|
226
|
+
let num1 = Random.getRandomNumber(difficulty);
|
|
227
|
+
let num2 = Random.getRandomNumber(difficulty);
|
|
228
|
+
const operator = Random.getRandomOperator();
|
|
229
|
+
if (operator === '/' && num2 === 0) {
|
|
230
|
+
num2 = Random.getRandomNumber(difficulty) + 1;
|
|
231
|
+
}
|
|
232
|
+
const result = this.calculateMath(num1, operator, num2);
|
|
233
|
+
if (isNaN(result) || !isFinite(result)) {
|
|
234
|
+
num1 = Random.getRandomNumber(difficulty);
|
|
235
|
+
num2 = Random.getRandomNumber(difficulty) + 1;
|
|
236
|
+
question = `${num1} + ${num2}`;
|
|
237
|
+
answer = num1 + num2;
|
|
238
|
+
} else {
|
|
239
|
+
question = `${num1} ${operator} ${num2}`;
|
|
240
|
+
answer = result;
|
|
241
|
+
}
|
|
242
|
+
} else if (randomType === 'text') {
|
|
243
|
+
const text = Random.generateRandomString(this.getDifficulty());
|
|
244
|
+
question = text;
|
|
245
|
+
answer = text;
|
|
246
|
+
} else if (randomType === 'sequence') {
|
|
247
|
+
const seq = SequenceGenerator.generate(this.getDifficulty());
|
|
248
|
+
question = seq.question;
|
|
249
|
+
answer = seq.answer;
|
|
250
|
+
} else if (randomType === 'scramble') {
|
|
251
|
+
const scr = ScrambleGenerator.generate(this.getDifficulty());
|
|
252
|
+
question = scr.question;
|
|
253
|
+
answer = scr.answer;
|
|
254
|
+
} else {
|
|
255
|
+
const rev = ReverseGenerator.generate(this.getDifficulty());
|
|
256
|
+
question = rev.question;
|
|
257
|
+
answer = rev.answer;
|
|
216
258
|
}
|
|
217
259
|
|
|
218
|
-
return this.createChallenge({
|
|
260
|
+
return this.createChallenge({ type: 'mixed', question, answer }) as MixedCaptcha;
|
|
219
261
|
}
|
|
220
262
|
|
|
221
|
-
//
|
|
263
|
+
// two-step captcha: math + scramble, both must be solved correctly
|
|
222
264
|
private generateMulti(): CaptchaChallenge {
|
|
223
|
-
const step1 = this.generateMath()
|
|
224
|
-
const step2 = this.
|
|
265
|
+
const step1 = this.store.get(this.generateMath().nonce)!;
|
|
266
|
+
const step2 = this.store.get(this.generateScramble().nonce)!;
|
|
225
267
|
|
|
226
268
|
return this.createChallenge({
|
|
227
269
|
type: 'multi',
|
|
@@ -230,4 +272,27 @@ export class CaptchaGenerator {
|
|
|
230
272
|
steps: [step1, step2]
|
|
231
273
|
});
|
|
232
274
|
}
|
|
275
|
+
|
|
276
|
+
// generates an SVG-based visual CAPTCHA immune to trivial OCR attacks
|
|
277
|
+
private generateImage(): ImageCaptcha {
|
|
278
|
+
const result = ImageGenerator.generate(this.getDifficulty());
|
|
279
|
+
return this.createChallenge({
|
|
280
|
+
type: 'image',
|
|
281
|
+
question: result.question,
|
|
282
|
+
answer: result.answer,
|
|
283
|
+
image: result.image
|
|
284
|
+
}) as ImageCaptcha;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// generates an emoji selection CAPTCHA: user picks all emojis from a given category
|
|
288
|
+
private generateEmoji(): EmojiCaptcha {
|
|
289
|
+
const result = EmojiGenerator.generate(this.getDifficulty());
|
|
290
|
+
return this.createChallenge({
|
|
291
|
+
type: 'emoji',
|
|
292
|
+
question: result.question,
|
|
293
|
+
answer: result.answer,
|
|
294
|
+
emojis: result.emojis,
|
|
295
|
+
category: result.category
|
|
296
|
+
}) as EmojiCaptcha;
|
|
297
|
+
}
|
|
233
298
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { createHash } from '../utils/crypto';
|
|
2
|
-
import
|
|
2
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import type { StoredChallenge } from '../types';
|
|
3
4
|
|
|
4
5
|
export class CaptchaValidator {
|
|
5
6
|
private static readonly MAX_INPUT_LENGTH = 1000;
|
|
6
7
|
private static readonly VALID_CHAR_REGEX = /^[a-zA-Z0-9\s\-çÇğĞıİöÖşŞüÜ.,'!?]*$/;
|
|
7
8
|
|
|
8
9
|
// main validation entry point, routes to the correct validator based on challenge type
|
|
9
|
-
static validate(challenge:
|
|
10
|
+
static validate(challenge: StoredChallenge, userInput: string): boolean {
|
|
10
11
|
if (!this.isValidInput(userInput)) {
|
|
11
12
|
return false;
|
|
12
13
|
}
|
|
@@ -19,6 +20,14 @@ export class CaptchaValidator {
|
|
|
19
20
|
return this.validateCustom(challenge, userInput);
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
if (challenge.type === 'image') {
|
|
24
|
+
return this.validateImage(challenge, userInput);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (challenge.type === 'emoji') {
|
|
28
|
+
return this.validateEmoji(challenge, userInput);
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
if (this.isNumericChallenge(challenge)) {
|
|
23
32
|
return this.validateNumeric(challenge, userInput);
|
|
24
33
|
}
|
|
@@ -37,7 +46,7 @@ export class CaptchaValidator {
|
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
// validates multi step captchas by checking each step individually
|
|
40
|
-
private static validateMulti(challenge:
|
|
49
|
+
private static validateMulti(challenge: StoredChallenge, userInput: string): boolean {
|
|
41
50
|
if (!challenge.steps || challenge.steps.length === 0) {
|
|
42
51
|
return false;
|
|
43
52
|
}
|
|
@@ -71,11 +80,14 @@ export class CaptchaValidator {
|
|
|
71
80
|
return true;
|
|
72
81
|
}
|
|
73
82
|
|
|
74
|
-
private static isNumericChallenge(challenge:
|
|
75
|
-
|
|
83
|
+
private static isNumericChallenge(challenge: StoredChallenge): boolean {
|
|
84
|
+
// sequence can return string answers (e.g. letters for medium difficulty), so check the actual answer type
|
|
85
|
+
return challenge.type === 'math' ||
|
|
86
|
+
(challenge.type === 'sequence' && typeof challenge.answer === 'number') ||
|
|
87
|
+
(challenge.type === 'mixed' && typeof challenge.answer === 'number');
|
|
76
88
|
}
|
|
77
89
|
|
|
78
|
-
private static validateNumeric(challenge:
|
|
90
|
+
private static validateNumeric(challenge: StoredChallenge, userInput: string): boolean {
|
|
79
91
|
const inputNum = parseFloat(userInput);
|
|
80
92
|
|
|
81
93
|
// make sure we got a valid number, not NaN or Infinity
|
|
@@ -86,7 +98,7 @@ export class CaptchaValidator {
|
|
|
86
98
|
return this.verifyAnswer(challenge, inputNum.toString());
|
|
87
99
|
}
|
|
88
100
|
|
|
89
|
-
private static validateText(challenge:
|
|
101
|
+
private static validateText(challenge: StoredChallenge, userInput: string): boolean {
|
|
90
102
|
const sanitized = userInput.trim();
|
|
91
103
|
|
|
92
104
|
if (!this.VALID_CHAR_REGEX.test(sanitized)) {
|
|
@@ -96,16 +108,26 @@ export class CaptchaValidator {
|
|
|
96
108
|
return this.verifyAnswer(challenge, sanitized);
|
|
97
109
|
}
|
|
98
110
|
|
|
99
|
-
//
|
|
100
|
-
|
|
111
|
+
// hash the user input with the same salt and compare using a constant-time
|
|
112
|
+
// equality check to eliminate timing side-channels
|
|
113
|
+
private static verifyAnswer(challenge: StoredChallenge, userInput: string): boolean {
|
|
101
114
|
const userHash = createHash('sha256')
|
|
102
115
|
.update(userInput + challenge.salt)
|
|
103
116
|
.digest('hex');
|
|
104
117
|
|
|
105
|
-
|
|
118
|
+
// both buffers must be the same length for timingSafeEqual; hex-encoded
|
|
119
|
+
// SHA-256 is always 64 chars so this holds unless hashedAnswer is corrupted
|
|
120
|
+
if (userHash.length !== challenge.hashedAnswer.length) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return timingSafeEqual(
|
|
125
|
+
Buffer.from(userHash, 'hex'),
|
|
126
|
+
Buffer.from(challenge.hashedAnswer, 'hex')
|
|
127
|
+
);
|
|
106
128
|
}
|
|
107
129
|
|
|
108
|
-
private static validateCustom(challenge:
|
|
130
|
+
private static validateCustom(challenge: StoredChallenge, userInput: string): boolean {
|
|
109
131
|
const sanitized = userInput.trim();
|
|
110
132
|
|
|
111
133
|
if (!this.VALID_CHAR_REGEX.test(sanitized)) {
|
|
@@ -114,4 +136,47 @@ export class CaptchaValidator {
|
|
|
114
136
|
|
|
115
137
|
return this.verifyAnswer(challenge, sanitized);
|
|
116
138
|
}
|
|
139
|
+
|
|
140
|
+
// image answers are case-insensitive; only alphanumeric chars are accepted
|
|
141
|
+
private static validateImage(challenge: StoredChallenge, userInput: string): boolean {
|
|
142
|
+
const sanitized = userInput.trim().toLowerCase();
|
|
143
|
+
|
|
144
|
+
if (!/^[a-z0-9]+$/.test(sanitized)) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (sanitized.length < 1 || sanitized.length > 20) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return this.verifyAnswer(challenge, sanitized);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// emoji answers: comma-separated zero-based indices e.g. "0,2,4"
|
|
156
|
+
// parsed, deduplicated, sorted numerically then re-joined to produce the canonical form
|
|
157
|
+
private static validateEmoji(challenge: StoredChallenge, userInput: string): boolean {
|
|
158
|
+
const trimmed = userInput.trim();
|
|
159
|
+
|
|
160
|
+
// only digits and commas are valid; reject anything else to prevent injection
|
|
161
|
+
if (!/^[0-9,]+$/.test(trimmed)) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const parts = trimmed.split(',').filter(s => s.length > 0);
|
|
166
|
+
|
|
167
|
+
if (parts.length === 0 || parts.length > 20) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const indices = parts.map(Number);
|
|
172
|
+
|
|
173
|
+
if (indices.some(n => isNaN(n) || n < 0 || !Number.isInteger(n))) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// canonical form matches what EmojiGenerator stored: sorted unique indices
|
|
178
|
+
const normalized = [...new Set(indices)].sort((a, b) => a - b).join(',');
|
|
179
|
+
|
|
180
|
+
return this.verifyAnswer(challenge, normalized);
|
|
181
|
+
}
|
|
117
182
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
export interface K9GuardOptions {
|
|
2
|
-
type: 'math' | 'text' | '
|
|
2
|
+
type: 'math' | 'text' | 'sequence' | 'scramble' | 'reverse' | 'mixed' | 'multi' | 'image' | 'emoji';
|
|
3
3
|
difficulty: 'easy' | 'medium' | 'hard';
|
|
4
|
-
locale?: 'en' | 'tr';
|
|
5
4
|
}
|
|
6
5
|
|
|
7
6
|
export interface CustomQuestion {
|
|
@@ -15,58 +14,71 @@ export interface K9GuardCustomOptions {
|
|
|
15
14
|
questions: CustomQuestion[];
|
|
16
15
|
}
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
// Internal: full challenge record kept server-side only.
|
|
18
|
+
// answer, hashedAnswer and salt must never leave the server.
|
|
19
|
+
export interface StoredChallenge {
|
|
20
|
+
type: 'math' | 'text' | 'sequence' | 'scramble' | 'reverse' | 'mixed' | 'multi' | 'custom' | 'image' | 'emoji';
|
|
20
21
|
question: string;
|
|
21
22
|
answer: string | number;
|
|
22
23
|
nonce: string;
|
|
23
24
|
expiry: number;
|
|
24
25
|
hashedAnswer: string;
|
|
25
26
|
salt: string;
|
|
27
|
+
steps?: StoredChallenge[];
|
|
28
|
+
image?: string;
|
|
29
|
+
emojis?: string[];
|
|
30
|
+
category?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Public: safe subset sent to the client.
|
|
34
|
+
// answer, hashedAnswer and salt are intentionally omitted to prevent
|
|
35
|
+
// hash-injection attacks where an attacker forges hashedAnswer on the client.
|
|
36
|
+
export interface CaptchaChallenge {
|
|
37
|
+
type: StoredChallenge['type'];
|
|
38
|
+
question: string;
|
|
39
|
+
nonce: string;
|
|
40
|
+
expiry: number;
|
|
26
41
|
steps?: CaptchaChallenge[];
|
|
42
|
+
image?: string;
|
|
43
|
+
emojis?: string[];
|
|
44
|
+
category?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ImageCaptcha extends CaptchaChallenge {
|
|
48
|
+
type: 'image';
|
|
49
|
+
image: string;
|
|
27
50
|
}
|
|
28
51
|
|
|
29
52
|
export interface MathCaptcha extends CaptchaChallenge {
|
|
30
53
|
type: 'math';
|
|
31
|
-
answer: number;
|
|
32
54
|
}
|
|
33
55
|
|
|
34
56
|
export interface TextCaptcha extends CaptchaChallenge {
|
|
35
57
|
type: 'text';
|
|
36
|
-
answer: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface RiddleCaptcha extends CaptchaChallenge {
|
|
40
|
-
type: 'riddle';
|
|
41
|
-
answer: string;
|
|
42
58
|
}
|
|
43
59
|
|
|
44
60
|
export interface SequenceCaptcha extends CaptchaChallenge {
|
|
45
61
|
type: 'sequence';
|
|
46
|
-
answer: string | number;
|
|
47
62
|
}
|
|
48
63
|
|
|
49
64
|
export interface ScrambleCaptcha extends CaptchaChallenge {
|
|
50
65
|
type: 'scramble';
|
|
51
|
-
answer: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface LogicCaptcha extends CaptchaChallenge {
|
|
55
|
-
type: 'logic';
|
|
56
|
-
answer: string;
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
export interface ReverseCaptcha extends CaptchaChallenge {
|
|
60
69
|
type: 'reverse';
|
|
61
|
-
answer: string;
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
export interface MixedCaptcha extends CaptchaChallenge {
|
|
65
73
|
type: 'mixed';
|
|
66
|
-
answer: string | number;
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
export interface CustomCaptcha extends CaptchaChallenge {
|
|
70
77
|
type: 'custom';
|
|
71
|
-
|
|
72
|
-
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface EmojiCaptcha extends CaptchaChallenge {
|
|
81
|
+
type: 'emoji';
|
|
82
|
+
emojis: string[];
|
|
83
|
+
category: string;
|
|
84
|
+
}
|