diff --git a/.changeset/safe-json-schema-patterns.md b/.changeset/safe-json-schema-patterns.md new file mode 100644 index 00000000000..ebe0787ae61 --- /dev/null +++ b/.changeset/safe-json-schema-patterns.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Ignore regular expression patterns when importing JSON Schema documents by default, with explicit modes to apply trusted patterns or reject documents containing them. diff --git a/packages/effect/SCHEMA.md b/packages/effect/SCHEMA.md index 1e437f1d2dd..6de392a5944 100644 --- a/packages/effect/SCHEMA.md +++ b/packages/effect/SCHEMA.md @@ -6437,6 +6437,12 @@ Import is best-effort: JSON Schema constructs are translated to Effect schemas w lossless reconstruction of an original Effect schema. The optional `onEnter` callback can normalize each JSON Schema node before it is translated. +Imported `pattern`, `patternProperties`, and patterns nested in `propertyNames` are ignored by default, so they do not +weaken availability by running in the runtime's native regular expression engine. Each skipped source is retained in an +`ignoredJsonSchemaPattern` annotation so the weaker validation remains observable. Set `patterns: "apply"` only when +importing trusted documents and accepting the risk of validation blocking for an unbounded amount of time. Set +`patterns: "error"` to reject documents containing patterns instead of weakening validation. + ## Code generation ### `toCodeDocument` diff --git a/packages/effect/src/SchemaRepresentation.ts b/packages/effect/src/SchemaRepresentation.ts index 7796ec4c50b..41feb2bf86a 100644 --- a/packages/effect/src/SchemaRepresentation.ts +++ b/packages/effect/src/SchemaRepresentation.ts @@ -615,13 +615,26 @@ export const makeFilterGroupReviver:

( * * **Gotchas** * - * `onEnter` must return a JSON Schema object. Its result is used directly, and exceptions raised by the callback pass through unchanged. + * JSON Schema patterns are ignored by default and their source is retained in an `ignoredJsonSchemaPattern` annotation. + * Use `patterns: "apply"` only for trusted documents because validation uses the runtime's native regular expression + * engine and may block for an unbounded amount of time. Use `patterns: "error"` to reject documents containing patterns + * instead of weakening validation. + * + * `onEnter` must return a JSON Schema object. Its result is used directly, and exceptions raised by the callback pass + * through unchanged. * * @category models * @since 4.0.0 */ export interface FromJsonSchemaOptions { readonly onEnter?: ((schema: JsonSchema.JsonSchema) => JsonSchema.JsonSchema) | undefined + /** + * Controls how `pattern`, `patternProperties`, and patterns nested in `propertyNames` are imported. Defaults to + * `"ignore"`, which records the skipped source in an `ignoredJsonSchemaPattern` annotation. `"apply"` compiles and + * enforces patterns with the runtime's native regular expression engine. `"error"` rejects a document containing a + * pattern. + */ + readonly patterns?: "ignore" | "apply" | "error" | undefined } /** @@ -1166,7 +1179,11 @@ export function fromRepresentations( * * **Gotchas** * - * Import is best-effort. Built-in declarations and checks are reconstructed with importer-owned revivers. Callback results are used directly, and exceptions raised by a callback pass through unchanged. + * Import is best-effort. Built-in declarations and checks are reconstructed with importer-owned revivers. Patterns are + * ignored by default and recorded in an `ignoredJsonSchemaPattern` annotation. Use `patterns: "apply"` only for trusted + * documents because validation uses the runtime's native regular expression engine, or `patterns: "error"` to reject + * documents containing patterns. Callback results are used directly, and exceptions raised by a callback pass through + * unchanged. * * @see {@link fromJsonSchemaMultiDocument} for multiple roots sharing definitions * @see {@link toRepresentation} for converting the result to a representation document @@ -1190,7 +1207,10 @@ export function fromJsonSchemaDocument( * * **Gotchas** * - * Every definition is translated, including definitions that no root references. Callback results are used directly, and exceptions raised by a callback pass through unchanged. + * Every definition is translated, including definitions that no root references. Patterns are ignored by default and + * recorded in an `ignoredJsonSchemaPattern` annotation. Use `patterns: "apply"` only for trusted documents because + * validation uses the runtime's native regular expression engine, or `patterns: "error"` to reject documents containing + * patterns. Callback results are used directly, and exceptions raised by a callback pass through unchanged. * * @see {@link fromJsonSchemaDocument} for a single root * @see {@link fromSchemaMultiDocument} for converting the result to a representation document diff --git a/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts index 8301d808e72..2c0be4cc3a2 100644 --- a/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts +++ b/packages/effect/src/internal/schema/fromJsonSchemaDocument.ts @@ -741,7 +741,7 @@ function translateJsonSchemaMultiDocument( case "string": return { _tag: "String", - checks: collectStringChecks(schema) + ...collectString(schema, path) } case "number": case "integer": @@ -786,14 +786,33 @@ function translateJsonSchemaMultiDocument( } } - function collectStringChecks(schema: JsonSchema.JsonSchema): Array { + function importPattern(pattern: string, path: Path): { + readonly check?: Check | undefined + readonly annotations?: Schema.Annotations.Annotations | undefined + } { + switch (options?.patterns ?? "ignore") { + case "ignore": + return { annotations: { ignoredJsonSchemaPattern: pattern } } + case "apply": + return { check: jsonSchemaFilter("effect/schema/isPattern", { source: pattern, flags: "" }) } + case "error": + throw errorWithPath(`Pattern encountered while patterns is set to "error"`, path) + } + } + + function collectString(schema: JsonSchema.JsonSchema, path: Path): { + readonly checks: Array + readonly annotations?: Schema.Annotations.Annotations | undefined + } { const checks: Array = [] addNumberCheck(checks, schema.minLength, "effect/schema/isMinLength", "minLength") addNumberCheck(checks, schema.maxLength, "effect/schema/isMaxLength", "maxLength") if (typeof schema.pattern === "string") { - checks.push(jsonSchemaFilter("effect/schema/isPattern", { source: schema.pattern, flags: "" })) + const imported = importPattern(schema.pattern, [...path, "pattern"]) + if (imported.check !== undefined) checks.push(imported.check) + return { checks, annotations: imported.annotations } } - return checks + return { checks } } function collectNumberChecks(schema: JsonSchema.JsonSchema): Array { @@ -849,10 +868,12 @@ function translateJsonSchemaMultiDocument( !Array.isArray(schema.patternProperties) ) { for (const [pattern, value] of Object.entries(schema.patternProperties)) { + const imported = importPattern(pattern, [...path, "patternProperties", pattern]) signatures.push({ parameter: { _tag: "String", - checks: [jsonSchemaFilter("effect/schema/isPattern", { source: pattern, flags: "" })] + checks: imported.check === undefined ? [] : [imported.check], + ...imported.annotations === undefined ? undefined : { annotations: imported.annotations } }, type: recur(value, [...path, "patternProperties", pattern]) }) diff --git a/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts b/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts index f272dd61306..2f492477cd3 100644 --- a/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts +++ b/packages/effect/test/schema/representation/fromJsonSchemaDocument.test.ts @@ -6,14 +6,16 @@ function toSchemaFromJsonSchemaDocument( document: JsonSchema.Document<"draft-2020-12">, options?: SchemaRepresentation.FromJsonSchemaOptions ): Schema.Top { - return SchemaRepresentation.fromJsonSchemaDocument(document, options) + return SchemaRepresentation.fromJsonSchemaDocument(document, { patterns: "apply", ...options }) } function fromJsonSchemaRepresentation( document: JsonSchema.Document<"draft-2020-12">, options?: SchemaRepresentation.FromJsonSchemaOptions ): SchemaRepresentation.Document { - return SchemaRepresentation.toRepresentation(SchemaRepresentation.fromJsonSchemaDocument(document, options).ast) + return SchemaRepresentation.toRepresentation( + SchemaRepresentation.fromJsonSchemaDocument(document, { patterns: "apply", ...options }).ast + ) } describe("fromJsonSchemaDocument", () => { @@ -25,7 +27,7 @@ describe("fromJsonSchemaDocument", () => { expected: Schema.Json ) { const jsonDocument = JsonSchema.fromSchemaDraft2020_12(input.schema) - const schema = SchemaRepresentation.fromJsonSchemaDocument(jsonDocument, input.options) + const schema = SchemaRepresentation.fromJsonSchemaDocument(jsonDocument, { patterns: "apply", ...input.options }) const document = SchemaRepresentation.toRepresentation(schema.ast) deepStrictEqual(SchemaRepresentation.toJson(document), expected) return schema @@ -844,7 +846,7 @@ describe("fromJsonSchemaDocument", () => { ) }) - it("pattern", () => { + it("round-trips ordinary patterns", () => { assertFromJsonSchema( { schema: { type: "string", pattern: "a*" } }, { @@ -910,6 +912,68 @@ describe("fromJsonSchemaDocument", () => { } ) }) + + it("ignores patterns by default and annotates the skip", () => { + const schema = SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern: "^a+$" }) + ) + const is = Schema.is(schema) + assertTrue(is("aaa")) + assertTrue(is("bbb")) + deepStrictEqual(Schema.resolveAnnotations(schema), { ignoredJsonSchemaPattern: "^a+$" }) + + const object = SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ + type: "object", + patternProperties: { "^a+$": { type: "string" } }, + additionalProperties: false + }) + ) + const representation = SchemaRepresentation.toRepresentation(object.ast).representation + strictEqual(representation._tag, "Objects") + if (representation._tag === "Objects") { + const parameter = representation.indexSignatures[0].parameter + strictEqual(parameter._tag, "String") + if (parameter._tag === "String") { + deepStrictEqual(parameter.annotations, { ignoredJsonSchemaPattern: "^a+$" }) + } + } + }) + + it("applies patterns explicitly", () => { + const schema = SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12({ type: "string", pattern: "^a+$" }), + { patterns: "apply" } + ) + const is = Schema.is(schema) + assertTrue(is("aaa")) + assertFalse(is("bbb")) + }) + + it("rejects patterns explicitly", () => { + for ( + const [schema, path] of [ + [{ type: "string", pattern: "^a+$" }, `["schema"]["pattern"]`], + [ + { type: "object", patternProperties: { "^a+$": { type: "string" } } }, + `["schema"]["patternProperties"]["^a+$"]` + ], + [ + { type: "object", propertyNames: { pattern: "^a+$" } }, + `["schema"]["propertyNames"]["pattern"]` + ] + ] as const + ) { + throws( + () => + SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12(schema), + { patterns: "error" } + ), + `Pattern encountered while patterns is set to "error"\n at ${path}` + ) + } + }) }) })