001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.commons.utils; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.lang.reflect.*; 022import java.util.*; 023import java.util.concurrent.*; 024 025/** 026 * Bounded, iterative walker that validates already-materialized JSON values against structural safety limits. 027 * 028 * <p> 029 * This helper defends against pathological inputs (excessive nesting, unbounded node counts, cyclic references, and 030 * runaway traversal times) before a value is serialized or otherwise consumed. It walks only in-memory maps, 031 * collections, arrays, and scalar leaves; it never resolves <js>"$ref"</js> or performs any I/O. 032 * 033 * <p> 034 * All violations are reported as neutral {@link IllegalArgumentException}s. Typed or adaptor callers are responsible 035 * for mapping these to the appropriate JSON-RPC error code, which keeps this core class free of any revision-specific 036 * error policy. 037 */ 038public final class JsonValueSafety { 039 040 /** Maximum allowed nesting depth of the walked value. */ 041 public static final int MAX_DEPTH = 64; 042 043 /** Maximum allowed number of distinct nodes (containers and leaves) in the walked value. */ 044 public static final int MAX_NODES = 10_000; 045 046 /** Maximum allowed wall-clock time, in milliseconds, for a single traversal. */ 047 public static final long MAX_TRAVERSAL_MILLIS = 100; 048 049 private JsonValueSafety() {} 050 051 /** 052 * Computes a fresh traversal deadline from the current time. 053 * 054 * @return An absolute deadline in {@link System#nanoTime()} units, {@link #MAX_TRAVERSAL_MILLIS} milliseconds in the future. 055 */ 056 public static long deadlineNanos() { 057 return System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(MAX_TRAVERSAL_MILLIS); 058 } 059 060 /** 061 * Validates the specified value against the structural safety limits using a fresh {@link #deadlineNanos() deadline}. 062 * 063 * @param value The value to walk. Can be <jk>null</jk>, in which case this method returns immediately. 064 * @param label A human-readable label describing the value; used as a prefix in violation messages. 065 * @throws IllegalArgumentException If the value violates any of the depth, node-count, traversal-time, or JSON-shape constraints. 066 */ 067 public static void check(Object value, String label) { 068 check(value, label, deadlineNanos()); 069 } 070 071 /** 072 * Validates the specified value against the structural safety limits using the specified traversal deadline. 073 * 074 * @param root The value to walk. Can be <jk>null</jk>, in which case this method returns immediately. 075 * @param label A human-readable label describing the value; used as a prefix in violation messages. 076 * @param deadlineNanos An absolute deadline in {@link System#nanoTime()} units after which the traversal is aborted. 077 * @throws IllegalArgumentException 078 * If the value exceeds {@link #MAX_DEPTH}, exceeds {@link #MAX_NODES}, exceeds the traversal deadline, contains a 079 * non-string JSON object key, or contains a leaf that is not a JSON value type ({@link String}, {@link Number}, or 080 * {@link Boolean}). 081 */ 082 public static void check(Object root, String label, long deadlineNanos) { 083 if (root == null) 084 return; 085 var seen = Collections.newSetFromMap(new IdentityHashMap<Object,Boolean>()); 086 var stack = new ArrayDeque<Framed>(); 087 stack.push(new Framed(root, 1)); 088 var nodes = 0; 089 while (! stack.isEmpty()) { 090 // Bounded per-iteration work runs before the deadline check so a structurally-oversized input 091 // deterministically reports its depth/node-count violation instead of racing the wall clock. 092 var frame = stack.pop(); 093 if (frame.depth() > MAX_DEPTH) 094 throw iaex("%s exceeds maximum nesting depth of %s", label, MAX_DEPTH); 095 nodes = visitFrame(frame, label, seen, stack, nodes); 096 if (System.nanoTime() >= deadlineNanos) 097 throw iaex("%s traversal exceeded %s ms", label, MAX_TRAVERSAL_MILLIS); 098 } 099 } 100 101 /** 102 * Processes one popped stack frame: counts it toward the node budget, and - if it's an unseen container - 103 * pushes its children onto {@code stack} for further traversal. 104 * 105 * @return The updated node count. 106 */ 107 private static int visitFrame(Framed frame, String label, Set<Object> seen, ArrayDeque<Framed> stack, int nodes) { 108 var value = frame.value(); 109 if (value == null) 110 return checkNodeBudget(nodes, label); 111 var container = value instanceof Map<?,?> || value instanceof Collection<?> || value.getClass().isArray(); 112 if (container && ! seen.add(value)) 113 return nodes; 114 nodes = checkNodeBudget(nodes, label); 115 pushChildren(value, frame.depth(), label, stack); 116 return nodes; 117 } 118 119 private static int checkNodeBudget(int nodes, String label) { 120 if (++nodes > MAX_NODES) 121 throw iaex("%s exceeds maximum node count of %s", label, MAX_NODES); 122 return nodes; 123 } 124 125 private static void pushChildren(Object value, int depth, String label, ArrayDeque<Framed> stack) { 126 if (value instanceof Map<?,?> value2) { 127 for (var entry : value2.entrySet()) { 128 if (! (entry.getKey() instanceof String)) 129 throw iaex("%s contains non-string JSON object key %s", label, entry.getKey()); 130 stack.push(new Framed(entry.getValue(), depth + 1)); 131 } 132 } else if (value instanceof Collection<?> value2) { 133 for (var child : value2) 134 stack.push(new Framed(child, depth + 1)); 135 } else if (value.getClass().isArray()) { 136 for (var i = 0; i < Array.getLength(value); i++) 137 stack.push(new Framed(Array.get(value, i), depth + 1)); 138 } else if (! (value instanceof String || value instanceof Number || value instanceof Boolean)) 139 throw iaex("%s contains non-JSON value type %s", label, value.getClass().getName()); 140 } 141 142 /** 143 * Computes the number of nanoseconds remaining before the specified deadline. 144 * 145 * @param deadlineNanos An absolute deadline in {@link System#nanoTime()} units. 146 * @return The remaining nanoseconds, or <c>0</c> if the deadline has already passed. 147 */ 148 public static long remainingNanos(long deadlineNanos) { 149 return Math.max(0, deadlineNanos - System.nanoTime()); 150 } 151 152 private record Framed(Object value, int depth) {} 153}