Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
93 changes: 93 additions & 0 deletions samples/composed.yaml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<CodegenProperty> oneOf = composed != null ? composed.getOneOf() : null;
List<CodegenProperty> 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");

Expand All @@ -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<String> enumValues = (List<String>) 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.
*
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -368,10 +368,29 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
// We need to flatten the model into something that can be used in the template
// This will be a new vendor extension with an array of properties that represent
// the model. Both engines flatten JSON bodies into ARGS as "json.<path>".
// 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<CodegenProperty> rootVars = param.vars;
if ((rootVars == null || rootVars.isEmpty()) && bodyModel != null) {
rootVars = bodyModel.vars;
}
List<CodegenProperty> flattenedProperties = new ArrayList<CodegenProperty>();
for (CodegenProperty prop : param.vars) {
List<CodegenProperty> properties = flattenModel(prop, JSON_ARGS_PREFIX, 1, modelLookup);
flattenedProperties.addAll(properties);
if (rootVars != null) {
for (CodegenProperty prop : rootVars) {
List<CodegenProperty> 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) {
Expand Down Expand Up @@ -404,7 +423,14 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
}

if(patternString == null || patternString.isEmpty()) {
patternString = getParamPattern(param);
// anyOf/oneOf parameter: a value matching any member schema is valid, so
// the members' patterns are combined into one alternation.
List<CodegenProperty> 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);
}
Expand Down Expand Up @@ -475,6 +501,24 @@ public List<CodegenProperty> 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<CodegenProperty> 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<CodegenProperty> vars = (currentProperty.vars != null && !currentProperty.vars.isEmpty())
? currentProperty.vars
: lookupModelVars(currentProperty, modelLookup);
Expand All @@ -487,6 +531,13 @@ public List<CodegenProperty> 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;
}

Expand All @@ -495,6 +546,25 @@ public List<CodegenProperty> 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<CodegenProperty> unionMembers(org.openapitools.codegen.CodegenComposedSchemas composedSchemas) {
if (composedSchemas == null) {
return null;
}
List<CodegenProperty> members = new ArrayList<CodegenProperty>();
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
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CodegenProperty> members, boolean isRequired) {
List<String> 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.
*
Expand Down
Loading