From 51d423244abaad2c135f8db0d73538bfa8f04966 Mon Sep 17 00:00:00 2001 From: Nathan Byrd Date: Sat, 4 Jul 2026 12:54:36 -0500 Subject: [PATCH] Added anyof --- docs/configuration.md | 9 ++ samples/composed.yaml | 93 +++++++++++++ .../modsecurity3/JsonSchemaGenerator.java | 56 +++++++- .../modsecurity3/Modsecurity3Generator.java | 80 ++++++++++- .../PatternGenerationService.java | 29 ++++ .../tests/ComposedSchemaTest.java | 125 ++++++++++++++++++ .../modsecurity_rule_generation.feature | 18 +++ .../composed/contact/invalid/bad_id.json | 7 + .../composed/contact/invalid/bad_phone.json | 7 + .../test-data/composed/contact/valid.json | 7 + .../dog/invalid/missing_required.json | 3 + .../test-data/composed/dog/valid.json | 4 + 12 files changed, 431 insertions(+), 7 deletions(-) create mode 100644 samples/composed.yaml create mode 100644 src/test/java/com/oashield/openapi/generators/modsecurity3/tests/ComposedSchemaTest.java create mode 100644 src/test/resources/test-data/composed/contact/invalid/bad_id.json create mode 100644 src/test/resources/test-data/composed/contact/invalid/bad_phone.json create mode 100644 src/test/resources/test-data/composed/contact/valid.json create mode 100644 src/test/resources/test-data/composed/dog/invalid/missing_required.json create mode 100644 src/test/resources/test-data/composed/dog/valid.json diff --git a/docs/configuration.md b/docs/configuration.md index 99f08ec..579186c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -47,3 +47,12 @@ These apply to the `modsecurity3` flavor's per-field checks. Coraza's are covered only by the unknown-property allowlist. - **Numeric bounds on path parameters:** `minimum`/`maximum` on *path* parameters is enforced only lexically, via the embedded path pattern. +- **`oneOf` / `anyOf` composition:** enforced as the *union* of the member + schemas. Primitive unions (including composed query/path parameters) are + validated against an alternation of the member patterns; model unions + validate and allowlist the properties of every branch, but `required` + properties inside a branch are not enforced (only one branch need be + present), and `oneOf`'s exactly-one semantics are not distinguished from + `anyOf`. `allOf` models are validated fully (members are merged). The + generated JSON Schema keeps the exact `oneOf`/`anyOf` keywords for + Coraza's `@validateSchema`. diff --git a/samples/composed.yaml b/samples/composed.yaml new file mode 100644 index 0000000..5f538ca --- /dev/null +++ b/samples/composed.yaml @@ -0,0 +1,93 @@ +openapi: 3.0.3 +info: + title: Composed Schemas API + description: Sample API exercising anyOf / allOf / oneOf compositions + version: 1.0.0 +paths: + /contact: + post: + operationId: addContact + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Contact' + responses: + '200': + description: OK + /dog: + post: + operationId: addDog + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Dog' + responses: + '200': + description: OK + /items: + get: + operationId: findItems + parameters: + - name: code + in: query + required: false + schema: + oneOf: + - type: integer + - type: string + enum: [red, green, blue] + responses: + '200': + description: OK +components: + schemas: + Contact: + type: object + required: + - name + properties: + name: + type: string + id: + anyOf: + - type: integer + - type: string + format: uuid + contactMethod: + oneOf: + - $ref: '#/components/schemas/EmailContact' + - $ref: '#/components/schemas/PhoneContact' + EmailContact: + type: object + required: + - email + properties: + email: + type: string + format: email + PhoneContact: + type: object + required: + - phone + properties: + phone: + type: string + pattern: '^[0-9]{10}$' + Animal: + type: object + required: + - species + properties: + species: + type: string + Dog: + allOf: + - $ref: '#/components/schemas/Animal' + - type: object + properties: + breed: + type: string diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java index e1f065b..2faf265 100644 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java +++ b/src/main/java/com/oashield/openapi/generators/modsecurity3/JsonSchemaGenerator.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.oashield.openapi.generators.modsecurity3.types.JsonSchemaTypeMapper; +import org.openapitools.codegen.CodegenComposedSchemas; import org.openapitools.codegen.CodegenModel; import org.openapitools.codegen.CodegenProperty; import org.openapitools.codegen.model.ModelMap; @@ -94,6 +95,20 @@ public ObjectNode generateModelSchema(CodegenModel model) { schemaNode.put("description", model.description); } + // oneOf/anyOf composition: emit the composition keyword instead of a plain + // object schema (allOf models already arrive with their vars merged). + CodegenComposedSchemas composed = model.getComposedSchemas(); + List oneOf = composed != null ? composed.getOneOf() : null; + List anyOf = composed != null ? composed.getAnyOf() : null; + if ((oneOf != null && !oneOf.isEmpty()) || (anyOf != null && !anyOf.isEmpty())) { + boolean isOneOf = oneOf != null && !oneOf.isEmpty(); + ArrayNode members = schemaNode.putArray(isOneOf ? "oneOf" : "anyOf"); + for (CodegenProperty member : (isOneOf ? oneOf : anyOf)) { + members.add(composedMemberSchema(member)); + } + return schemaNode; + } + // Set type to object schemaNode.put("type", "object"); @@ -114,6 +129,42 @@ public ObjectNode generateModelSchema(CodegenModel model) { } } + /** + * Build the schema for one oneOf/anyOf member: a $ref for model members, an + * inline primitive schema (type/format/enum/constraints) otherwise. + * + * @param member The composed schema member + * @return An ObjectNode representing the member schema + */ + private ObjectNode composedMemberSchema(CodegenProperty member) { + ObjectNode node = objectMapper.createObjectNode(); + if (member.isNull) { + node.put("type", "null"); + return node; + } + if (member.isModel && member.complexType != null && !isPrimitiveType(member.complexType)) { + node.put("$ref", "#/definitions/" + member.complexType); + return node; + } + // openApiType holds the raw OAS type (integer/number/string/boolean) + JsonSchemaTypeMapper.applyPrimitiveType(member.openApiType, node); + if (member.dataFormat != null && !member.dataFormat.isEmpty()) { + node.put("format", member.dataFormat); + } + if (member.allowableValues != null && member.allowableValues.containsKey("values")) { + @SuppressWarnings("unchecked") + List enumValues = (List) member.allowableValues.get("values"); + if (enumValues != null && !enumValues.isEmpty()) { + ArrayNode enumNode = node.putArray("enum"); + for (String value : enumValues) { + enumNode.add(value); + } + } + } + processValidationConstraints(member, node); + return node; + } + /** * Extract JSON Schema compatible data from a ModelMap. * @@ -333,9 +384,10 @@ private void processValidationConstraints(CodegenProperty var, ObjectNode proper property.put("maxLength", var.getMaxLength()); } - // Pattern + // Pattern (strip the /.../ delimiters DefaultCodegen wraps spec patterns in; + // JSON Schema patterns are undelimited) if (var.pattern != null) { - property.put("pattern", var.pattern); + property.put("pattern", Modsecurity3Generator.sanitizeSpecPattern(var.pattern)); } // Minimum items (for arrays) diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java index 28ab072..3ea97e4 100644 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java +++ b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java @@ -368,10 +368,29 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List". + // Composed (allOf/oneOf/anyOf) body models carry no vars on the parameter + // itself; resolve them via the model list. + CodegenModel bodyModel = modelLookup.get(param.baseType); + if (bodyModel == null) { + bodyModel = modelLookup.get(param.dataType); + } + List rootVars = param.vars; + if ((rootVars == null || rootVars.isEmpty()) && bodyModel != null) { + rootVars = bodyModel.vars; + } List flattenedProperties = new ArrayList(); - for (CodegenProperty prop : param.vars) { - List properties = flattenModel(prop, JSON_ARGS_PREFIX, 1, modelLookup); - flattenedProperties.addAll(properties); + if (rootVars != null) { + for (CodegenProperty prop : rootVars) { + List properties = flattenModel(prop, JSON_ARGS_PREFIX, 1, modelLookup); + flattenedProperties.addAll(properties); + } + } + if (bodyModel != null && unionMembers(bodyModel.getComposedSchemas()) != null) { + // oneOf/anyOf body: vars is the union of all branches, only one of which + // must be present, so no property can be individually required + for (CodegenProperty prop : flattenedProperties) { + prop.required = false; + } } for (CodegenProperty prop : flattenedProperties) { @@ -404,7 +423,14 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List paramUnion = unionMembers(param.getComposedSchemas()); + if (paramUnion != null) { + patternString = patternGenerationService.getComposedPattern(paramUnion, param.required); + } else { + patternString = getParamPattern(param); + } LOGGER.debug("Calculated pattern string {}", patternString); param.setPattern(patternString); } @@ -475,6 +501,24 @@ public List flattenModel(CodegenProperty currentProperty, Strin if (currentProperty.isModel) { LOGGER.debug("Flattening model property: {}", currentProperty.baseName); + CodegenModel refModel = currentProperty.complexType != null ? modelLookup.get(currentProperty.complexType) : null; + List union = refModel != null ? unionMembers(refModel.getComposedSchemas()) : null; + + boolean unionHasModel = false; + if (union != null) { + for (CodegenProperty member : union) { + unionHasModel |= member.isModel; + } + if (!unionHasModel) { + // anyOf/oneOf of primitives: a single leaf validated against the + // alternation of the member patterns + CodegenProperty leaf = flattenedLeaf(currentProperty, baseNamePrefix); + leaf.pattern = patternGenerationService.getComposedPattern(union, true); + properties.add(leaf); + return properties; + } + } + List vars = (currentProperty.vars != null && !currentProperty.vars.isEmpty()) ? currentProperty.vars : lookupModelVars(currentProperty, modelLookup); @@ -487,6 +531,13 @@ public List flattenModel(CodegenProperty currentProperty, Strin for (CodegenProperty prop : vars) { properties.addAll(flattenModel(prop, baseNamePrefix, depth + 1, modelLookup)); } + if (union != null) { + // oneOf/anyOf of models: vars holds the union of all branches, only one of + // which must be present, so no branch property can be individually required + for (CodegenProperty prop : properties) { + prop.required = false; + } + } return properties; } @@ -495,6 +546,25 @@ public List flattenModel(CodegenProperty currentProperty, Strin return properties; } + /** + * Collect the oneOf/anyOf members of a composed schema. Both keywords get the + * same WAF treatment (a value passing any member is allowed), so they are merged. + * Returns null when the schema is not a oneOf/anyOf composition. + */ + static List unionMembers(org.openapitools.codegen.CodegenComposedSchemas composedSchemas) { + if (composedSchemas == null) { + return null; + } + List members = new ArrayList(); + if (composedSchemas.getOneOf() != null) { + members.addAll(composedSchemas.getOneOf()); + } + if (composedSchemas.getAnyOf() != null) { + members.addAll(composedSchemas.getAnyOf()); + } + return members.isEmpty() ? null : members; + } + /** * Resolve the referenced model's properties for a $ref property (whose own vars * list is empty). Returns null when the property is not a model reference or the @@ -575,7 +645,7 @@ String buildPathMatchRegex(CodegenOperation co) { return regex.toString(); } - private static String stripAnchors(String pattern) { + static String stripAnchors(String pattern) { String result = pattern; if (result.startsWith("^")) { result = result.substring(1); diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/PatternGenerationService.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/PatternGenerationService.java index 0473aeb..dea6e54 100644 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/PatternGenerationService.java +++ b/src/main/java/com/oashield/openapi/generators/modsecurity3/PatternGenerationService.java @@ -151,6 +151,35 @@ public String getPropertyPattern(CodegenProperty prop) { return getParamPattern(param); } + /** + * Builds a validation pattern for an anyOf/oneOf composition: a value is valid + * when it matches any member schema, so the member patterns are joined into one + * alternation. Members with a spec-provided pattern use it; others get their + * type-derived pattern. + * + * @param members the composed schema members (oneOf and/or anyOf entries) + * @param isRequired whether an empty value should be rejected + * @return an anchored alternation regex covering all members + */ + public String getComposedPattern(List members, boolean isRequired) { + List alternatives = new java.util.ArrayList<>(); + for (CodegenProperty member : members) { + if (member.isNull) { + continue; + } + String pattern = Modsecurity3Generator.sanitizeSpecPattern(member.pattern); + if (pattern == null || pattern.isEmpty() + || pattern.contains("(?!") || pattern.contains("(?=") || pattern.contains("(?<")) { + pattern = getPropertyPattern(member); + } + alternatives.add(Modsecurity3Generator.stripAnchors(pattern)); + } + if (alternatives.isEmpty()) { + return isRequired ? "^.+$" : "^.*$"; + } + return "^(?:" + String.join("|", alternatives) + ")" + (isRequired ? "" : "?") + "$"; + } + /** * Based on the type of parameter, returns the allowed input pattern. * diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/ComposedSchemaTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/ComposedSchemaTest.java new file mode 100644 index 0000000..4f92ee1 --- /dev/null +++ b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/ComposedSchemaTest.java @@ -0,0 +1,125 @@ +package com.oashield.openapi.generators.modsecurity3.tests; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.openapitools.codegen.ClientOptInput; +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.config.CodegenConfigurator; + +import java.io.File; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * End-to-end generator tests for anyOf / allOf / oneOf support (issue #13): + * generated per-field rules and schema.json must both reflect the composition. + */ +public class ComposedSchemaTest { + + private static final String OUTPUT_DIR = "target/test-composed"; + private static String rules; + private static JsonNode schema; + + @BeforeAll + public static void generate() throws Exception { + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("modsecurity3") + .setInputSpec("samples/composed.yaml") + .setOutputDir(OUTPUT_DIR); + final ClientOptInput clientOptInput = configurator.toClientOptInput(); + new DefaultGenerator().opts(clientOptInput).generate(); + + rules = new String(Files.readAllBytes(new File(OUTPUT_DIR, "DefaultApi.conf").toPath())); + schema = new ObjectMapper().readTree(new File(OUTPUT_DIR, "schema.json")); + } + + // --- Rule generation (used by both modsecurity3 and coraza flavors) --- + + @Test + public void anyOfPrimitivePropertyGetsUnionPatternAndIsAllowlisted() { + // Contact.id is anyOf [integer, string(uuid)] + assertTrue(rules.contains("SecRule ARGS:json.id \"!@rx ^(?:[0-9]{1,19}|"), + "json.id should be validated against an alternation of the anyOf member patterns"); + assertTrue(rules.contains("|json\\.id|"), + "json.id must appear in the ARGS_NAMES allowlist"); + } + + @Test + public void oneOfModelPropertyFlattensAllBranchesWithoutRequiringThem() { + // contactMethod is oneOf [EmailContact, PhoneContact]: both branches validated... + assertTrue(rules.contains("SecRule ARGS:json.contactMethod.email"), + "email branch should have a value rule"); + assertTrue(rules.contains("SecRule ARGS:json.contactMethod.phone \"!@rx ^[0-9]{10}$\""), + "phone branch should keep its spec pattern"); + // ...but neither branch's required properties may be enforced (only one branch is present) + assertFalse(rules.contains("Missing required property json.contactMethod.email"), + "required inside a oneOf branch must not produce a presence rule"); + assertFalse(rules.contains("Missing required property json.contactMethod.phone"), + "required inside a oneOf branch must not produce a presence rule"); + // required outside the composition still applies + assertTrue(rules.contains("Missing required property json.name")); + } + + @Test + public void allOfBodyModelIsFlattenedWithMergedRequired() { + // Dog = allOf [Animal, {breed}]; previously produced no rules and an empty allowlist + assertTrue(rules.contains("Missing required property json.species"), + "required property from the allOf parent must be enforced"); + assertTrue(rules.contains("SecRule ARGS:json.breed"), + "property from the inline allOf member must be validated"); + assertFalse(rules.contains("ARGS_NAMES \"!@rx ^(?:)$\""), + "allOf body must not produce an empty ARGS_NAMES allowlist"); + assertTrue(rules.contains("json\\.species") && rules.contains("json\\.breed"), + "allOf properties must be allowlisted"); + } + + @Test + public void oneOfQueryParameterGetsUnionPattern() { + // code is oneOf [integer, string enum]; optional, so the alternation is optional too + assertTrue(rules.contains("SecRule ARGS_GET:code \"!@rx ^(?:[0-9]{1,19}|(red|green|blue))?$\""), + "oneOf query parameter should be validated against the member pattern alternation"); + } + + // --- schema.json (used by the coraza @validateSchema rule) --- + + @Test + public void schemaEmitsOneOfRefsForModelComposition() { + JsonNode def = schema.get("definitions").get("Contact_contactMethod"); + assertNotNull(def); + assertTrue(def.has("oneOf"), "composed model should use the oneOf keyword"); + assertFalse(def.has("required"), "union of branch requireds must not be emitted"); + assertEquals("#/definitions/EmailContact", def.get("oneOf").get(0).get("$ref").asText()); + assertEquals("#/definitions/PhoneContact", def.get("oneOf").get(1).get("$ref").asText()); + } + + @Test + public void schemaEmitsAnyOfPrimitiveMembers() { + JsonNode def = schema.get("definitions").get("Contact_id"); + assertNotNull(def); + assertTrue(def.has("anyOf"), "composed model should use the anyOf keyword"); + assertFalse(def.has("type"), "anyOf schema must not also claim type object"); + assertEquals("integer", def.get("anyOf").get(0).get("type").asText()); + assertEquals("string", def.get("anyOf").get(1).get("type").asText()); + assertEquals("uuid", def.get("anyOf").get(1).get("format").asText()); + } + + @Test + public void schemaMergesAllOfIntoObject() { + JsonNode dog = schema.get("definitions").get("Dog"); + assertNotNull(dog); + assertEquals("object", dog.get("type").asText()); + assertTrue(dog.get("properties").has("species")); + assertTrue(dog.get("properties").has("breed")); + assertEquals("species", dog.get("required").get(0).asText()); + } + + @Test + public void schemaPatternsAreUndelimited() { + JsonNode phone = schema.get("definitions").get("PhoneContact").get("properties").get("phone"); + assertEquals("^[0-9]{10}$", phone.get("pattern").asText(), + "spec patterns must not keep DefaultCodegen's /.../ delimiters"); + } +} diff --git a/src/test/resources/features/modsecurity_rule_generation.feature b/src/test/resources/features/modsecurity_rule_generation.feature index 19b0f95..fbda397 100644 --- a/src/test/resources/features/modsecurity_rule_generation.feature +++ b/src/test/resources/features/modsecurity_rule_generation.feature @@ -67,6 +67,24 @@ Feature: ModSecurity Rule Generation and Testing | coraza | | modsecurity3 | + Scenario Outline: Composed schema (anyOf / allOf / oneOf) validation + Given an OpenAPI specification file at "samples/composed.yaml" + When I generate rules with body validation for "" + And I start the WAF server with the generated rules + Then a GET request to "/items?code=5" should return a 200 status code + And a GET request to "/items?code=red" should return a 200 status code + And a GET request to "/items?code=purple" should be blocked with a 403 status code + And a POST request to "/contact" with a valid body should return a 200 status code + And a POST request to "/contact" with an invalid body "bad_id" should be blocked with a 403 status code + And a POST request to "/contact" with an invalid body "bad_phone" should be blocked with a 403 status code + And a POST request to "/dog" with a valid body should return a 200 status code + And a POST request to "/dog" with an invalid body "missing_required" should be blocked with a 403 status code + + Examples: + | engine | + | coraza | + | modsecurity3 | + Scenario Outline: Request body object validation Given an OpenAPI specification file at "samples/petstore.yaml" When I generate rules with body validation for "" diff --git a/src/test/resources/test-data/composed/contact/invalid/bad_id.json b/src/test/resources/test-data/composed/contact/invalid/bad_id.json new file mode 100644 index 0000000..9b23346 --- /dev/null +++ b/src/test/resources/test-data/composed/contact/invalid/bad_id.json @@ -0,0 +1,7 @@ +{ + "name": "John Doe", + "id": "neither-integer-nor-uuid", + "contactMethod": { + "email": "john@example.com" + } +} diff --git a/src/test/resources/test-data/composed/contact/invalid/bad_phone.json b/src/test/resources/test-data/composed/contact/invalid/bad_phone.json new file mode 100644 index 0000000..c916eb3 --- /dev/null +++ b/src/test/resources/test-data/composed/contact/invalid/bad_phone.json @@ -0,0 +1,7 @@ +{ + "name": "John Doe", + "id": 42, + "contactMethod": { + "phone": "not-a-phone" + } +} diff --git a/src/test/resources/test-data/composed/contact/valid.json b/src/test/resources/test-data/composed/contact/valid.json new file mode 100644 index 0000000..282e8ea --- /dev/null +++ b/src/test/resources/test-data/composed/contact/valid.json @@ -0,0 +1,7 @@ +{ + "name": "John Doe", + "id": 42, + "contactMethod": { + "email": "john@example.com" + } +} diff --git a/src/test/resources/test-data/composed/dog/invalid/missing_required.json b/src/test/resources/test-data/composed/dog/invalid/missing_required.json new file mode 100644 index 0000000..64ab9b6 --- /dev/null +++ b/src/test/resources/test-data/composed/dog/invalid/missing_required.json @@ -0,0 +1,3 @@ +{ + "breed": "labrador" +} diff --git a/src/test/resources/test-data/composed/dog/valid.json b/src/test/resources/test-data/composed/dog/valid.json new file mode 100644 index 0000000..58cb4ac --- /dev/null +++ b/src/test/resources/test-data/composed/dog/valid.json @@ -0,0 +1,4 @@ +{ + "species": "canine", + "breed": "labrador" +}