/* * RESTHeart - the Web API for MongoDB * Copyright (C) SoftInstigate Srl * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.restheart.metadata.checkers; import io.undertow.server.HttpServerExchange; import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.regex.Pattern; import org.bson.BsonArray; import org.bson.BsonDocument; import org.bson.BsonValue; import org.restheart.handlers.RequestContext; import org.restheart.utils.JsonUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * * @author Andrea Di Cesare {@literal <andrea@softinstigate.com>} * * JsonPathConditionsChecker allows to check request content by using conditions * based on json path expression * * JsonPathConditionsChecker does not support dot notation and update operators * on bulk requests. For instance PATCH /db/coll/* { $currentDate: { "a.b": true * }} * * the args arguments is an array of condition. a condition is json object as * follows: { "path": "PATHEXPR", [ "type": "APPLY]"] ["count": COUNT ] * ["regex": "REGEX"] ["nullable": BOOLEAN]} * * where * * <br>PATHEXPR the path expression. use the . notation to identify the property * <br>COUNT is the number of expected values * <br>APPLY can be any BSON type: null, object, array, string, number, boolean * * objectid, date,timestamp, maxkey, minkey, symbol, code, objectid * <br>REGEX regular expression. note that string values to match come enclosed * in quotation marks, i.e. the regex will need to match "the value", included * the quotation marks * * <br>examples for path expressions: * * <br>root = {a: {b:1, c: {d:2, e:3}}, f:4} * <br> $.a -> {b:1, c: {d:2, e:3}}, f:4} * <br> $.* -> {a: {b:1, c: {d:2, e:3}}}, {f:4} * <br> $.a.b -> 1 * <br> $.a.c -> {d:2,e:3} * <br> $.a.c.d -> 2 * * <br>root = {a: [{b:1}, {c:2,d:3}}, true]} * * <br> $.a -> [{b:1}, {c:2,d:3}, true] * <br> $.a.[*] -> {b:1}, {c:2,d:3}, true * <br> $.a.[*].c -> null, 2, null * * * <br>root = {a: [{b:1}, {b:2}, {b:3}]}" * * <br> $.*.a.[*].b -> [1,2,3] * * <br>example regex condition that matches email addresses: * * <br>{"path":"$._id", "regex": * "^\"[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}\"$"} * <br>with unicode escapes (used by httpie): {"path":"$._id", "regex": * "^\\u0022[A-Z0-9._%+-]+@[A-Z0-9.-]+\\u005C\\u005C.[A-Z]{2,6}\\u0022$"} * */ public class JsonPathConditionsChecker implements Checker { static final Logger LOGGER = LoggerFactory.getLogger(JsonPathConditionsChecker.class); protected static String avoidEscapedChars(String s) { return s.replaceAll("\"", "'").replaceAll("\t", " "); } @Override public boolean check( HttpServerExchange exchange, RequestContext context, BsonDocument contentToCheck, BsonValue args) { if (args.isArray()) { BsonArray conditions = filterMissingOptionalAndNullNullableConditions( args.asArray(), contentToCheck); return applyConditions(conditions, contentToCheck, context); } else { context.addWarning( "checker wrong definition: args property must be " + "an arrary of string property names."); return true; } } @Override public PHASE getPhase(RequestContext context) { if (context.getMethod() == RequestContext.METHOD.PATCH || CheckersUtils .doesRequestUsesDotNotation(context.getContent()) || CheckersUtils .doesRequestUsesUpdateOperators(context.getContent())) { return PHASE.AFTER_WRITE; } else { return PHASE.BEFORE_WRITE; } } @Override public boolean doesSupportRequests(RequestContext context) { return !(CheckersUtils.isBulkRequest(context) && getPhase(context) == PHASE.AFTER_WRITE); } protected boolean applyConditions(BsonArray conditions, BsonDocument json, final RequestContext context) { return conditions.stream().allMatch(_condition -> { if (_condition.isDocument()) { BsonDocument condition = _condition.asDocument(); String path = null; BsonValue _path = condition.get("path"); if (_path != null && _path.isString()) { path = _path.asString().getValue(); } String type = null; BsonValue _type = condition.get("type"); if (_type != null && _type.isString()) { type = _type.asString().getValue(); } Set<Integer> counts = new HashSet<>(); BsonValue _count = condition.get("count"); if (_count != null) { if (_count.isInt32()) { counts.add(_count.asInt32().getValue()); } else if (_count.isArray()) { BsonArray countsArray = _count.asArray(); countsArray.forEach(countElement -> { if (countElement.isInt32()) { counts.add(countElement.asInt32().getValue()); } }); } } Set<String> mandatoryFields; BsonValue _mandatoryFields = condition.get("mandatoryFields"); if (_mandatoryFields != null) { mandatoryFields = new HashSet<>(); if (_mandatoryFields.isArray()) { BsonArray mandatoryFieldsArray = _mandatoryFields .asArray(); mandatoryFieldsArray.forEach(element -> { if (element.isString()) { mandatoryFields.add(element .asString().getValue()); } }); } } else { mandatoryFields = null; } Set<String> optionalFields; BsonValue _optionalFields = condition.get("optionalFields"); if (_optionalFields != null) { optionalFields = new HashSet<>(); if (_optionalFields.isArray()) { BsonArray optionalFieldsArray = _optionalFields .asArray(); optionalFieldsArray.forEach(element -> { if (element.isString()) { optionalFields.add( element.asString().getValue()); } }); } } else { optionalFields = null; } String regex = null; BsonValue _regex = condition.get("regex"); if (_regex != null && _regex.isString()) { regex = _regex.asString().getValue(); } Boolean optional = false; BsonValue _optional = condition.get("optional"); if (_optional != null && _optional.isBoolean()) { optional = _optional.asBoolean().getValue(); } Boolean nullable = false; BsonValue _nullable = condition.get("nullable"); if (_nullable != null && _nullable.isBoolean()) { nullable = _nullable.asBoolean().getValue(); } if (counts.isEmpty() && type == null && regex == null) { context.addWarning("condition does not have any of " + "'count', 'type' and 'regex' properties, " + "specify at least one: " + _condition); return true; } if (path == null) { context.addWarning( "condition in the args list does " + "not have the 'path' property: " + _condition); return true; } if (type != null && !counts.isEmpty() && regex != null) { return checkCount( json, path, counts, context) && checkType( json, path, type, mandatoryFields, optionalFields, optional, nullable, context) && checkRegex( json, path, regex, optional, nullable, context); } else if (type != null && !counts.isEmpty()) { return checkCount( json, path, counts, context) && checkType( json, path, type, mandatoryFields, optionalFields, optional, nullable, context); } else if (type != null && regex != null) { return checkType( json, path, type, mandatoryFields, optionalFields, optional, nullable, context) && checkRegex( json, path, regex, optional, nullable, context); } else if (!counts.isEmpty() && regex != null) { return checkCount( json, path, counts, context) && checkRegex( json, path, regex, optional, nullable, context); } else if (type != null) { return checkType( json, path, type, mandatoryFields, optionalFields, optional, nullable, context); } else if (!counts.isEmpty()) { return checkCount( json, path, counts, context); } else if (regex != null) { return checkRegex( json, path, regex, optional, nullable, context); } return true; } else { context.addWarning( "property in the args list is not an object: " + _condition); return true; } }); } /** * this filters out the nullable and optional conditions where the path * resolves to null * * @param conditions * @param content * @return */ protected BsonArray filterMissingOptionalAndNullNullableConditions(BsonArray conditions, BsonValue content) { Set<String> nullPaths = new HashSet<>(); BsonArray ret = new BsonArray(); conditions.stream().forEach(_condition -> { if (_condition.isDocument()) { BsonDocument condition = _condition.asDocument(); Boolean nullable = false; BsonValue _nullable = condition.get("nullable"); if (_nullable != null && _nullable.isBoolean()) { nullable = _nullable.asBoolean().getValue(); } Boolean optional = false; BsonValue _optional = condition.get("optional"); if (_optional != null && _optional.isBoolean()) { optional = _optional.asBoolean().getValue(); } if (nullable) { BsonValue _path = condition.get("path"); if (_path != null && _path.isString()) { String path = _path.asString().getValue(); List<Optional<BsonValue>> props; try { props = JsonUtils.getPropsFromPath(content, path); if (props != null && props.stream().allMatch( (Optional<BsonValue> prop) -> { return prop != null && !prop.isPresent(); })) { LOGGER.debug("ignoring null path {}", path); nullPaths.add(path); } } catch (IllegalArgumentException ex) { nullPaths.add(path); } } } if (optional) { BsonValue _path = condition.get("path"); if (_path != null && _path.isString()) { String path = _path.asString().getValue(); List<Optional<BsonValue>> props; try { props = JsonUtils.getPropsFromPath(content, path); if (props == null || props.stream().allMatch( (Optional<BsonValue> prop) -> { return prop == null; })) { nullPaths.add(path); } } catch (IllegalArgumentException ex) { nullPaths.add(path); } } } } }); conditions.stream().forEach(_condition -> { if (_condition.isDocument()) { BsonDocument condition = _condition.asDocument(); BsonValue _path = condition.get("path"); if (_path != null && _path.isString()) { String path = _path.asString().getValue(); boolean hasNullParent = nullPaths.stream().anyMatch( (String nullPath) -> { return JsonUtils.isAncestorPath(nullPath, path); }); if (!hasNullParent) { ret.add(condition); } } } }); return ret; } protected boolean checkCount(BsonValue json, String path, Set<Integer> expectedCounts, RequestContext context) { Integer count; try { count = JsonUtils.countPropsFromPath(json, path); } catch (IllegalArgumentException ex) { return false; } // props is null when path does not exist. count is false if (count == null) { return false; } boolean ret = expectedCounts.contains(count); LOGGER.debug("checkCount({}, {}) -> {}", path, expectedCounts, ret); if (ret == false) { context.addWarning("checkCount condition failed: path: " + path + ", expected: " + expectedCounts + ", got: " + count); } return ret; } protected boolean checkType(BsonDocument json, String path, String type, Set<String> mandatoryFields, Set<String> optionalFields, boolean optional, boolean nullable, RequestContext context) { List<Optional<BsonValue>> props; boolean ret; boolean failedFieldsCheck = false; try { props = JsonUtils.getPropsFromPath(json, path); } catch (IllegalArgumentException ex) { LOGGER.debug("checkType({}, {}, {}, {}) -> {} -> false", path, type, mandatoryFields, optionalFields, ex.getMessage()); context.addWarning( "checkType condition failed: path: " + path + ", expected type: " + type + ", error: " + ex.getMessage()); return false; } // props is null when path does not exist. if (props == null) { ret = optional; } else { ret = props.stream().allMatch((Optional<BsonValue> prop) -> { if (prop == null) { return optional; } if (prop.isPresent()) { if ("array".equals(type) && prop.get().isDocument()) { // this might be the case of PATCHING an element array using the dot notation // e.g. object.array.2 // if so, the array comes as an BsonDocument with all numberic keys // in any case, it might also be the object { "object": { "array": {"2": xxx }}} return (prop .get()) .asDocument() .keySet() .stream() .allMatch((String k) -> { try { Integer.parseInt(k); return true; } catch (NumberFormatException nfe) { return false; } }) || JsonUtils.checkType(prop, type); } else { return JsonUtils.checkType(prop, type); } } else { return nullable; } }); // check object fields if (ret && "object".equals(type) && (mandatoryFields != null || optionalFields != null)) { Set<String> allFields = new HashSet<>(); if (mandatoryFields != null) { allFields.addAll(mandatoryFields); } if (optionalFields != null) { allFields.addAll(optionalFields); } ret = props.stream().allMatch((Optional<BsonValue> prop) -> { if (prop == null) { return optional; } if (prop.isPresent()) { BsonDocument obj = prop.get().asDocument(); if (mandatoryFields != null) { return obj.keySet() .containsAll(mandatoryFields) && allFields.containsAll(obj.keySet()); } else { return allFields.containsAll(obj.keySet()); } } else { return nullable; } }); if (ret == false) { failedFieldsCheck = true; } } } if (ret) { LOGGER.trace( "checkType({}, {}, {}, {}) -> {} -> {}", path, type, mandatoryFields, optionalFields, getRootPropsString(props), ret); } else { LOGGER.debug( "checkType({}, {}, {}, {}) -> {} -> {}", path, type, mandatoryFields, optionalFields, getRootPropsString(props), ret); String errorMessage; if (!failedFieldsCheck) { errorMessage = "checkType condition failed: path: " + path + ", expected type: " + type + ", got: " + (props == null ? "null" : avoidEscapedChars(getRootPropsString(props))); } else { errorMessage = "checkType condition failed: path: " + path + ", mandatory fields: " + mandatoryFields + ", optional fields: " + optionalFields + ", got: " + (props == null ? "null" : avoidEscapedChars(getRootPropsString(props))); } context.addWarning(errorMessage); } return ret; } protected boolean checkRegex(BsonDocument json, String path, String regex, boolean optional, boolean nullable, RequestContext context) { List<Optional<BsonValue>> props; try { props = JsonUtils.getPropsFromPath(json, path); } catch (IllegalArgumentException ex) { LOGGER.debug( "checkRegex({}, {}) -> {}", path, regex, ex.getMessage()); context.addWarning( "checkRegex condition failed: path: " + path + ", regex: " + regex + ", got: " + ex.getMessage()); return false; } boolean ret; // props is null when path does not exist. if (props == null) { ret = optional; } else { Pattern p = Pattern.compile(regex, Pattern.CASE_INSENSITIVE); ret = props.stream().allMatch((Optional<BsonValue> prop) -> { if (prop == null) { return optional; } if (prop.isPresent()) { if (prop.get().isString()) { return p.matcher(prop.get().asString().getValue()) .find(); } else { return p.matcher(JsonUtils.toJson(prop.get())).find(); } } else { return nullable; } }); } if (ret) { LOGGER.trace( "checkRegex({}, {}) -> {} -> {}", path, regex, getRootPropsString(props), ret); } else { LOGGER.debug( "checkRegex({}, {}) -> {} -> {}", path, regex, getRootPropsString(props), ret); String errorMessage = "checkRegex condition failed: path: " + path + ", regex: " + regex + ", got: " + (props == null ? "null" : avoidEscapedChars(getRootPropsString(props))); context.addWarning(errorMessage); } return ret; } private String getRootPropsString(List<Optional<BsonValue>> props) { if (props == null) { return null; } StringBuilder sb = new StringBuilder(); props.stream().forEach((_prop) -> { if (_prop == null) { sb.append("<property not existing>"); } else if (_prop.isPresent()) { BsonValue prop = _prop.get(); if (prop.isArray()) { BsonArray array = prop.asArray(); sb.append("["); array.stream().forEach((item) -> { if (item.isDocument()) { sb.append("{obj}"); } else if (item.isArray()) { sb.append("[array]"); } else if (item.isString()) { sb.append("'"); sb.append(item.asString().getValue()); sb.append("'"); } else { sb.append(JsonUtils.toJson(prop)); } sb.append(", "); }); // remove last comma if (sb.length() > 1) { sb.deleteCharAt(sb.length() - 1); sb.deleteCharAt(sb.length() - 1); } sb.append("]"); } else if (prop.isDocument()) { BsonDocument obj = prop.asDocument(); sb.append(obj.keySet().toString()); } else if (prop.isString()) { sb.append("'"); sb.append(prop.asString().getValue()); sb.append("'"); } else { sb.append(prop.toString()); } } else { sb.append("null"); } sb.append(", "); }); // remove last comma if (sb.length() > 1) { sb.deleteCharAt(sb.length() - 1); sb.deleteCharAt(sb.length() - 1); } return sb.toString(); } }