Skip to content

Jinjava 3.0: Add method and return type validator framework#1297

Draft
jasmith-hs wants to merge 5 commits into3.0/test-objectsfrom
3.0/validators
Draft

Jinjava 3.0: Add method and return type validator framework#1297
jasmith-hs wants to merge 5 commits into3.0/test-objectsfrom
3.0/validators

Conversation

@jasmith-hs
Copy link
Contributor

@jasmith-hs jasmith-hs commented Mar 4, 2026

Part of #1288.

Introduces an allowlist-based validator framework that controls which Java methods can be invoked from Jinja templates and what return types are permitted. This replaces the old blocklist approach (getRestrictedMethods / getRestrictedProperties / isRestrictedClass) with an opt-in model: only explicitly-permitted methods and return types are allowed through.


Why this matters

The old security model in JinjavaBeanELResolver worked by blocking known-bad things: a hardcoded set of method names (class, clone, hashCode, getClass, etc.), an isRestrictedClass check for dangerous types (Class, ClassLoader, Thread, Method, java.lang.reflect.*, com.fasterxml.jackson.databind.*), and optional per-config restricted method/property sets.

Blocklists are fundamentally fragile — they require anticipating every dangerous thing. An allowlist inverts the model: unknown things are rejected by default, and only types explicitly categorised as safe are permitted.


New components

Validator interfaces and config

  • MethodValidator — interface with a single validateMethod(Method) that returns null to reject or the method to accept. Supports chaining additional validators.
  • ReturnTypeValidator — same pattern but for return values: validateReturnType(Object).
  • MethodValidatorConfig (@Value.Immutable) — specifies which methods are allowed, by exact Method instance, by declaring class canonical name, or by class name prefix. Has an onRejectedMethod callback for observability. Built via MethodValidatorConfig.builder().
  • ReturnTypeValidatorConfig (@Value.Immutable) — specifies which return types are allowed, by exact canonical class name or by prefix. Has an onRejectedClass callback and an allowArrays flag (only enabled for the Collections group). Built via ReturnTypeValidatorConfig.builder().

Both configs enforce hard safety guarantees via @Value.Check: attempting to add java.lang.Object, java.lang.Class, java.lang.reflect.*, com.fasterxml.jackson.databind.*, or any com.hubspot.jinjava.* type outside the explicit safe groups throws IllegalStateException at construction time. Misconfiguration is a hard error, not a silent runtime bypass. This logic lives in BannedAllowlistOptions.

AllowlistGroup enum

Defines named semantic groups used to populate configs:

Group Covers
JavaPrimitives Boxed/unboxed primitives, String, BigDecimal, BigInteger
JinjavaObjects PyList, PyMap, SizeLimitingPy*, PyishDate, FormattedDate, DummyObject, Namespace, SafeString, NullValue
Collections All of the above plus PySet, SizeLimitingPySet, ArrayList, Guava forwarding collections, LinkedHashMap; also enables array return types
JinjavaTagConstructs ForLoop, MacroFunction, EagerMacroFunction
JinjavaFilters All classes under com.hubspot.jinjava.lib.filter (by package prefix)
JinjavaFunctions ZonedDateTime (used by date functions)
JinjavaExpTests All classes under com.hubspot.jinjava.lib.exptest (by package prefix)

AllowlistMethodValidator.DEFAULT and AllowlistReturnTypeValidator.DEFAULT are pre-built from all groups and used as the defaults in JinjavaConfig.

AllowlistMethodValidator / AllowlistReturnTypeValidator

Both validators use ConcurrentHashMap caches (keyed on Method or canonical class name) so the allowlist check is only evaluated once per unique method/type during a JVM session. The onRejected* callback fires for observability before the method/value is suppressed.

ReturnTypeValidatingJinjavaInterpreterResolver

A thin ELResolver decorator that wraps JinjavaInterpreterResolver. All getValue and invoke calls are passed through AllowlistReturnTypeValidator.validateReturnType() after resolution. ExpressionResolver now builds this wrapper instead of using JinjavaInterpreterResolver directly.

HasInterpreter

A small interface (JinjavaInterpreter interpreter()) implemented by JinjavaELContext and NoInvokeELContext. Allows the EL resolver layer to obtain the current interpreter from the context without a static getCurrent() call when the context is available.


Changes to existing components

JinjavaBeanELResolver

The central change. The old DEFAULT_RESTRICTED_METHODS, DEFAULT_RESTRICTED_PROPERTIES, and isRestrictedClass are removed entirely. Method validation is now delegated:

  • findMethod — after resolving the candidate method, calls getJinjavaConfig().getMethodValidator().validateMethod(method). Returns null (method not found) if the validator rejects it.
  • getReadMethod / getWriteMethod — same pattern, so property access also goes through the allowlist.
  • coerceValue — new override replaces the old ____int3rpr3t3r____ hack. When a method parameter is typed JinjavaInterpreter, it injects JinjavaInterpreter.getCurrent() directly. Filters and expression tests that accept JinjavaInterpreter as a parameter continue to work without any special naming convention.
  • Method lookup now uses a BeanMethods cache (Introspector.getBeanInfoMethodDescriptor[] indexed by name) per class, stored in a ConcurrentHashMap<Class<?>, BeanMethods>. This avoids calling getClass().getMethods() on every invocation and removes the findAccessibleMethod scan from the hot path.

JinjavaConfig

  • Removes getRestrictedMethods() and getRestrictedProperties().
  • Adds getMethodValidator() (default: AllowlistMethodValidator.DEFAULT).
  • Adds getReturnTypeValidator() (default: AllowlistReturnTypeValidator.DEFAULT).

Both new methods have @Value.Default so existing JinjavaConfig builders continue to work without changes.

JinjavaInterpreterResolver

Raw Set values are now wrapped in SizeLimitingPySet (analogous to how List and Map are already wrapped in their SizeLimiting* equivalents). This also means Set is now in the Collections allowlist group and can be returned from expressions.

PySet / SizeLimitingPySet

  • PySet — a new ForwardingSet<Object> that implements PyWrapper. Includes a recursion-guarded hashCode() to handle sets that contain themselves.
  • SizeLimitingPySet — mirrors SizeLimitingPyList / SizeLimitingPyMap. Enforces maxSize on construction, add, and addAll; emits a WARNING-level TemplateError when the set reaches 90% capacity.

ArrayBacked

Marker interface (T[] backingArray()) for types that expose a backing array, used to support array return types through the validator.


Tests

  • ValidatorConfigBannedConstructsTest — verifies that banned types (Object, Class, java.lang.reflect, com.fasterxml.jackson.databind, JinjavaInterpreter) cannot be added to either MethodValidatorConfig or ReturnTypeValidatorConfig without throwing.
  • JinjavaBeanELResolverTest — restructured to test the new allowlist-delegation behavior rather than the removed hardcoded blocklist.
  • BaseJinjavaTest and most other tests updated to use explicit validator config where tests exercise method calls that go through the allowlist.

Update some tests

Implement allowlisting for methods and classes

Extract separate MethodValidator

Add result validating

Use string-based config. Make the allowlists easier to work with

Wrap classes before validating result

Split MethodValidator from ReturnTypeValidator

Don't create new JinjavaBeanELResolver instances per Jinjava or JinjavaConfig for bean cache performance

Validate ReturnTypes at the top-level so that accessing values of arrays, lists, maps have their return values validated

Wrap at a higher level and don't resolve `____int3rpr3t3r____`

Use BeanMethods and cache allowed methods and return types

Don't expect ____int3rpr3t3r____ and don't use arrays in ReverseFilter and add method and return type validator to test classes

Helper for constructing JinjavaConfig in tests

Extract test objects to separate classes. Allow arrays. Add AnnotationIntrospector

Don't need annotation introspector

All tests passing

Fix build

Describe a couple more changes

Don't allow jinjava constructs outside of the AllowlistGroup classes from being allowlisted

Add tests that certain classes and packages CANNOT be allowlisted

Allow BigInteger

Add annotation to MethodValidator

Test that certain constructs are fully banned

Remove unused method from ReturnTypeValidator

Use Map and Set instead of ImmutableMap and ImmutableSet for less churn

Apply spotless formatting

Fix test after merge conflicts

Remove 3.0-changes file
@jasmith-hs jasmith-hs self-assigned this Mar 4, 2026
jasmith-hs and others added 4 commits March 5, 2026 11:07
- Remove stale commented-out line in ExpressionResolver
- Remove @Value.Check from static utility method in BannedAllowlistOptions
- Make BeanMethod.method volatile for safe publication across threads
- Guard against getBeanMethods returning null for unknown method names
- Guard against getCanonicalName returning null for anonymous/local classes in AllowlistReturnTypeValidator and AllowlistMethodValidator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Use instanceof pattern matching in NoInvokeELContext to avoid bare ClassCastException when delegate doesn't implement HasInterpreter
- Guard against null JinjavaInterpreter.getCurrent() in SizeLimitingPySet.checkSize

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant