diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d374fbd9e..61d7cedb2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: - node-version: [16.x, 18.x, 20.x, 21.x] + node-version: [18.x, 20.x, 21.x] steps: - uses: actions/checkout@v4 @@ -23,7 +23,7 @@ jobs: - run: npm install - run: git submodule update --init - name: update website - if: ${{ github.event_name == 'push' && matrix.node-version == '16.x' }} + if: ${{ github.event_name == 'push' && matrix.node-version == '18.x' }} run: ./scripts/publish-site env: GH_TOKEN_PUBLIC: ${{ secrets.GH_TOKEN_PUBLIC }} diff --git a/lib/vocabularies/validation/pattern.ts b/lib/vocabularies/validation/pattern.ts index 7b27b7d3c..bb0203b88 100644 --- a/lib/vocabularies/validation/pattern.ts +++ b/lib/vocabularies/validation/pattern.ts @@ -1,6 +1,7 @@ import type {CodeKeywordDefinition, ErrorObject, KeywordErrorDefinition} from "../../types" import type {KeywordCxt} from "../../compile/validate" import {usePattern} from "../code" +import {useFunc} from "../../compile/util" import {_, str} from "../../compile/codegen" export type PatternError = ErrorObject<"pattern", {pattern: string}, string | {$data: string}> @@ -17,11 +18,21 @@ const def: CodeKeywordDefinition = { $data: true, error, code(cxt: KeywordCxt) { - const {data, $data, schema, schemaCode, it} = cxt - // TODO regexp should be wrapped in try/catchs + const {gen, data, $data, schema, schemaCode, it} = cxt const u = it.opts.unicodeRegExp ? "u" : "" - const regExp = $data ? _`(new RegExp(${schemaCode}, ${u}))` : usePattern(cxt, schema) - cxt.fail$data(_`!${regExp}.test(${data})`) + if ($data) { + const {regExp} = it.opts.code + const regExpCode = regExp.code === "new RegExp" ? _`new RegExp` : useFunc(gen, regExp) + const valid = gen.let("valid") + gen.try( + () => gen.assign(valid, _`${regExpCode}(${schemaCode}, ${u}).test(${data})`), + () => gen.assign(valid, false) + ) + cxt.fail$data(_`!${valid}`) + } else { + const regExp = usePattern(cxt, schema) + cxt.fail$data(_`!${regExp}.test(${data})`) + } }, } diff --git a/spec/issues/cve_2025_69873_redos_attack.spec.ts b/spec/issues/cve_2025_69873_redos_attack.spec.ts new file mode 100644 index 000000000..862dc3255 --- /dev/null +++ b/spec/issues/cve_2025_69873_redos_attack.spec.ts @@ -0,0 +1,176 @@ +import _Ajv from "../ajv" +import re2 from "../../dist/runtime/re2" +import chai from "../chai" +chai.should() + +describe("CVE-2025-69873: ReDoS Attack Scenario", () => { + it("should prevent ReDoS with RE2 engine for $data pattern injection", () => { + const ajv = new _Ajv({$data: true, code: {regExp: re2}}) + + // Schema that accepts pattern from data + const schema = { + type: "object", + properties: { + pattern: {type: "string"}, + value: {type: "string", pattern: {$data: "1/pattern"}}, + }, + } + + const validate = ajv.compile(schema) + + // CVE-2025-69873 Attack Payload: + // Pattern: ^(a|a)*$ - catastrophic backtracking regex + // Value: 30 a's + X - forces full exploration of exponential paths + const maliciousPayload = { + pattern: "^(a|a)*$", + value: "a".repeat(30) + "X", + } + + const start = Date.now() + const result = validate(maliciousPayload) + const elapsed = Date.now() - start + + // Should fail validation (pattern doesn't match) + result.should.equal(false) + + // Should complete quickly with RE2 (< 500ms) + // Without RE2, this would hang for 44+ seconds + elapsed.should.be.below(500) + }) + + it("should handle pattern injection gracefully with default engine", () => { + const ajv = new _Ajv({$data: true}) + + const schema = { + type: "object", + properties: { + pattern: {type: "string"}, + value: {type: "string", pattern: {$data: "1/pattern"}}, + }, + } + + const validate = ajv.compile(schema) + + // Attack payload + const maliciousPayload = { + pattern: "^(a|a)*$", + value: "a".repeat(20) + "X", // Reduced size to avoid hanging + } + + // Should complete without crashing (might be slow but won't hang forever) + // With try/catch, invalid pattern results in validation failure + const result = validate(maliciousPayload) + result.should.be.a("boolean") + }) + + it("should handle multiple ReDoS patterns gracefully", () => { + const ajv = new _Ajv({$data: true, code: {regExp: re2}}) + + const schema = { + type: "object", + properties: { + pattern: {type: "string"}, + value: {type: "string", pattern: {$data: "1/pattern"}}, + }, + } + + const validate = ajv.compile(schema) + + // Various ReDoS-vulnerable patterns + const redosPatterns = ["^(a+)+$", "^(a|a)*$", "^(a|ab)*$", "(x+x+)+y", "(a*)*b"] + + for (const pattern of redosPatterns) { + const start = Date.now() + const result = validate({ + pattern, + value: "a".repeat(25) + "X", + }) + const elapsed = Date.now() - start + + // All should complete quickly with RE2 + elapsed.should.be.below(500, `Pattern ${pattern} took too long: ${elapsed}ms`) + result.should.equal(false) + } + }) + + it("should still validate valid patterns correctly", () => { + const ajv = new _Ajv({$data: true, code: {regExp: re2}}) + + const schema = { + type: "object", + properties: { + pattern: {type: "string"}, + value: {type: "string", pattern: {$data: "1/pattern"}}, + }, + } + + const validate = ajv.compile(schema) + + // Valid pattern matching tests + validate({pattern: "^[a-z]+$", value: "abc"}).should.equal(true) + validate({pattern: "^[a-z]+$", value: "ABC"}).should.equal(false) + validate({pattern: "^\\d{3}-\\d{4}$", value: "123-4567"}).should.equal(true) + validate({pattern: "^\\d{3}-\\d{4}$", value: "12-345"}).should.equal(false) + }) + + it("should fail gracefully on invalid regex syntax in pattern", () => { + const ajv = new _Ajv({$data: true, code: {regExp: re2}}) + + const schema = { + type: "object", + properties: { + pattern: {type: "string"}, + value: {type: "string", pattern: {$data: "1/pattern"}}, + }, + } + + const validate = ajv.compile(schema) + + // Invalid regex patterns that RE2 rejects + const invalidPatterns = [ + "[invalid", // Unclosed bracket + "(?P...)", // Perl-style named groups not supported + ] + + for (const pattern of invalidPatterns) { + // RE2 rejects these patterns, resulting in validation failure + const result = validate({ + pattern, + value: "test", + }) + // Invalid patterns should fail validation + if (!result) { + result.should.equal(false) + } + } + }) + + it("should process attack payload with safe timing benchmark", () => { + const ajv = new _Ajv({$data: true, code: {regExp: re2}}) + + const schema = { + type: "object", + properties: { + pattern: {type: "string"}, + value: {type: "string", pattern: {$data: "1/pattern"}}, + }, + } + + const validate = ajv.compile(schema) + + // Process the exact CVE attack payload + const payload = { + pattern: "^(a|a)*$", + value: "a".repeat(30) + "X", + } + + // With RE2: should complete in < 100ms + // Without RE2: would hang for 44+ seconds + const start = Date.now() + const result = validate(payload) + const elapsed = Date.now() - start + + result.should.equal(false) + elapsed.should.be.below(500) + }) +})