fix(#4326): replace JS source concatenation with Value.execute() in JavascriptFunctionDefinition#4339
Conversation
…avascriptFunctionDefinition Drop the looksLikeJson/alreadyQuoted heuristics and the toJavaScriptValue/mapToJavaScript/listToJavaScript helpers that built function-call source by string-concatenation. Instead, retrieve the declared JS function from bindings by name and invoke it via Value.execute(args), passing Java values directly. GraalVM converts primitives automatically; Map/List arguments are converted to ProxyObject/ProxyArray via the existing toDeepProxyObject/toDeepProxyList helpers.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Coverage variation | ✅ -7.44% coverage variation |
| Diff coverage | ✅ 60.00% diff coverage |
Coverage variation details
Coverable lines Covered lines Coverage Common ancestor commit (c4cf514) 127540 93469 73.29% Head commit (0da878f) 159188 (+31648) 104819 (+11350) 65.85% (-7.44%) Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch:
<coverage of head commit> - <coverage of common ancestor commit>
Diff coverage details
Coverable lines Covered lines Diff coverage Pull request (#4339) 30 18 60.00% Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified:
<covered lines added or modified>/<coverable lines added or modified> * 100%
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Code Review
This pull request addresses a critical security vulnerability (JS injection) in JavascriptFunctionDefinition by replacing string-concatenation-based execution with direct GraalVM execution using Value.execute(). The review feedback identifies three key areas for improvement: handling potential ClassCastException when casting maps with non-string keys by normalizing them recursively, adding a defensive null check for the retrieved JS function to prevent a NullPointerException, and preserving the original exception cause when throwing FunctionExecutionException to facilitate debugging.
| @SuppressWarnings("unchecked") | ||
| private static Object toJsArg(final Object value) { | ||
| if (value instanceof Map<?, ?> map) | ||
| return toDeepProxyObject((Map<String, Object>) map); | ||
| if (value instanceof List<?> list) | ||
| return toDeepProxyList(list); | ||
| return value; | ||
| } |
There was a problem hiding this comment.
Casting Map<?, ?> directly to Map<String, Object> and passing it to toDeepProxyObject can cause a ClassCastException at runtime if the map contains non-string keys (e.g., Map.of(1, "one")). In the previous implementation, non-string keys were safely converted to strings using toString(). To prevent this regression, we should recursively normalize maps to ensure all keys are strings before proxying.
private static Object toJsArg(final Object value) {
if (value instanceof Map<?, ?> map) {
return toDeepProxyObject(normalizeMap(map));
}
if (value instanceof List<?> list) {
return toDeepProxyList(normalizeList(list));
}
return value;
}
private static Map<String, Object> normalizeMap(final Map<?, ?> map) {
final Map<String, Object> result = new java.util.LinkedHashMap<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
final String key = entry.getKey() instanceof String s ? s : String.valueOf(entry.getKey());
final Object val = entry.getValue();
if (val instanceof Map<?, ?> subMap) {
result.put(key, normalizeMap(subMap));
} else if (val instanceof List<?> subList) {
result.put(key, normalizeList(subList));
} else {
result.put(key, val);
}
}
return result;
}
private static List<?> normalizeList(final List<?> list) {
final List<Object> result = new java.util.ArrayList<>();
for (Object item : list) {
if (item instanceof Map<?, ?> subMap) {
result.add(normalizeMap(subMap));
} else if (item instanceof List<?> subList) {
result.add(normalizeList(subList));
} else {
result.add(item);
}
}
return result;
}| final Value fn = polyglotEngine.context.getBindings("js").getMember(functionName); | ||
| final Object[] args = new Object[parameters.length]; | ||
| for (int i = 0; i < parameters.length; i++) | ||
| args[i] = toJsArg(parameters[i]); | ||
| return jsValueToJava(fn.execute(args)); |
There was a problem hiding this comment.
If the function functionName is not defined or failed to initialize in the JS context, getMember(functionName) will return null. Calling fn.execute(args) directly will then result in a NullPointerException. It is safer to perform a defensive null check and throw a descriptive exception.
| final Value fn = polyglotEngine.context.getBindings("js").getMember(functionName); | |
| final Object[] args = new Object[parameters.length]; | |
| for (int i = 0; i < parameters.length; i++) | |
| args[i] = toJsArg(parameters[i]); | |
| return jsValueToJava(fn.execute(args)); | |
| final Value fn = polyglotEngine.context.getBindings("js").getMember(functionName); | |
| if (fn == null) | |
| throw new FunctionExecutionException("Function '" + functionName + "' is not defined"); | |
| final Object[] args = new Object[parameters.length]; | |
| for (int i = 0; i < parameters.length; i++) | |
| args[i] = toJsArg(parameters[i]); | |
| return jsValueToJava(fn.execute(args)); |
| } catch (final Exception e) { | ||
| throw new FunctionExecutionException("Error on execution of function '" + functionName + "'"); | ||
| } |
There was a problem hiding this comment.
When catching a general Exception, the original exception e is swallowed, and only a generic FunctionExecutionException is thrown without a cause. This makes debugging function execution failures extremely difficult. We should pass e as the cause to the FunctionExecutionException constructor.
| } catch (final Exception e) { | |
| throw new FunctionExecutionException("Error on execution of function '" + functionName + "'"); | |
| } | |
| } catch (final Exception e) { | |
| throw new FunctionExecutionException("Error on execution of function '" + functionName + "'", e); | |
| } |
Code Review - fix(#4326): JS injection via source concatenationOverall: This is an important security fix with the right approach - replacing string-concatenation eval with direct Bugs / Issues1. Exception cause is swallowed (medium severity) In } catch (final Exception e) {
throw new FunctionExecutionException("Error on execution of function '" + functionName + "'");
}
throw new FunctionExecutionException("Error on execution of function '" + functionName + "'", e);2. Null
final Value fn = polyglotEngine.context.getBindings("js").getMember(functionName);
if (fn == null)
throw new FunctionExecutionException("Function '" + functionName + "' not found in JS context");3. Unsafe unchecked cast in @SuppressWarnings("unchecked")
private static Object toJsArg(final Object value) {
if (value instanceof Map<?, ?> map)
return toDeepProxyObject((Map<String, Object>) map);The cast Breaking Change - needs documentationThe removed SQL-path test in SELECT `MyLib.myFunc`('{"foo":"bar"}') ...will now get different behavior. This should be explicitly called out in the release notes or changelog as a breaking change, not just in the Minor Observations
|
Follow-up ReviewAll three issues raised in the earlier review cycle have been addressed in the follow-up commit. The PR is in good shape. A few remaining observations: Fixed (confirmed)
Remaining minor observations (not blockers)1. } catch (final Exception e) {
throw new FunctionExecutionException("Error on definition of function '" + functionName + "'");
}The two-arg constructor 2. SQL-via-injection test removed from The original test included a SQL query path: ResultSet resultSet = database.query("sql", "SELECT `Test.objectComparison`('{\"foo\":\"bar\"}') as matchRating;");
assertThat(resultSet.next().<Boolean>getProperty("matchRating")).isTrue();That test is gone, and the behavior it relied on - a JSON string being interpreted as a JS object via the SQL path - is now intentionally different (a JSON string passed via SQL now arrives as a plain JS string). This is correct behavior after the fix, but there is no test verifying the SQL query path for object arguments after the change. Callers using the SQL API with JSON-encoded strings will need to migrate, and this behavioral difference deserves a mention in release notes. 3. Missing
4.
VerdictThe core security fix is correct and well-implemented. The three actionable issues from the first review cycle are all resolved. Items 2-4 above are minor; item 1 is pre-existing. The approach of replacing string-concatenation eval with |
|
test comment |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4339 +/- ##
============================================
- Coverage 64.21% 64.15% -0.07%
+ Complexity 448 432 -16
============================================
Files 1645 1645
Lines 127540 127510 -30
Branches 27350 27342 -8
============================================
- Hits 81899 81801 -98
- Misses 34102 34191 +89
+ Partials 11539 11518 -21 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Closes #4326
Summary
looksLikeJson/alreadyQuotedheuristics inJavascriptFunctionDefinition.execute()that allowed arbitrary strings to pass through unescaped into eval'd JavaScript source code.functionName + "(" + toJavaScriptValue(arg) + ")") with a directValue.execute(args...)call: the declared function is retrieved from the JS context bindings by name and invoked with Java objects passed as arguments. GraalVM handles type conversion internally, eliminating the injection surface entirely.MapandListarguments are converted toProxyObject/ProxyArrayvia the existingtoDeepProxyObject/toDeepProxyListhelpers, so JS code can still access map properties via dot notation.toJavaScriptValue,mapToJavaScript, andlistToJavaScriptmethods.Test plan
PolyglotFunctionTest.jsInjectionPrevented(new): verifies that a string matching the oldlooksLikeJsonpattern ({a:1,b:2}) arrives in JS as a plain string, not as a JS object literal.PolyglotFunctionTest.jsonObjectAsInput(updated): now passes aMap<String, String>directly instead of a JSON-encoded string; the function receives a proper JS object and property access works correctly.PolyglotFunctionTest.stringObjectAsInput(updated): now passes"UPPERCASE"directly instead of the single-quote-wrapped"'UPPERCASE'"workaround.PolyglotFunctionTest,SQLDefinedJavascriptFunctionTest,JsValueToJavaTest,PolyglotQueryTest,PolyglotEngineManagerTest).