Skip to content

fix(#4326): replace JS source concatenation with Value.execute() in JavascriptFunctionDefinition#4339

Merged
robfrank merged 3 commits into
mainfrom
fix/4326-polyglot-js-injection
May 26, 2026
Merged

fix(#4326): replace JS source concatenation with Value.execute() in JavascriptFunctionDefinition#4339
robfrank merged 3 commits into
mainfrom
fix/4326-polyglot-js-injection

Conversation

@robfrank

Copy link
Copy Markdown
Collaborator

Closes #4326

Summary

  • Removes the looksLikeJson / alreadyQuoted heuristics in JavascriptFunctionDefinition.execute() that allowed arbitrary strings to pass through unescaped into eval'd JavaScript source code.
  • Replaces the string-concatenation call-site (functionName + "(" + toJavaScriptValue(arg) + ")") with a direct Value.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.
  • Map and List arguments are converted to ProxyObject / ProxyArray via the existing toDeepProxyObject / toDeepProxyList helpers, so JS code can still access map properties via dot notation.
  • Removes the now-unused toJavaScriptValue, mapToJavaScript, and listToJavaScript methods.

Test plan

  • PolyglotFunctionTest.jsInjectionPrevented (new): verifies that a string matching the old looksLikeJson pattern ({a:1,b:2}) arrives in JS as a plain string, not as a JS object literal.
  • PolyglotFunctionTest.jsonObjectAsInput (updated): now passes a Map<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.
  • All 17 polyglot function / query tests pass (PolyglotFunctionTest, SQLDefinedJavascriptFunctionTest, JsValueToJavaTest, PolyglotQueryTest, PolyglotEngineManagerTest).

…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.
@codacy-production

codacy-production Bot commented May 25, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Coverage 60.00% diff coverage · -7.44% coverage variation

Metric Results
Coverage variation -7.44% coverage variation
Diff coverage 60.00% diff coverage

View coverage diff in Codacy

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 104 to 111
@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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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;
  }

Comment on lines +91 to +95
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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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));

Comment on lines +98 to 100
} catch (final Exception e) {
throw new FunctionExecutionException("Error on execution of function '" + functionName + "'");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
} 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);
}

@claude

claude Bot commented May 25, 2026

Copy link
Copy Markdown

Code Review - fix(#4326): JS injection via source concatenation

Overall: This is an important security fix with the right approach - replacing string-concatenation eval with direct Value.execute(args...). The core change is sound. A few issues worth addressing before merge.


Bugs / Issues

1. Exception cause is swallowed (medium severity)

In execute() the generic catch discards the original exception:

} catch (final Exception e) {
  throw new FunctionExecutionException("Error on execution of function '" + functionName + "'");
}

FunctionExecutionException already has a two-argument constructor accepting a cause. Without it, any JS runtime error (type error, reference error, etc.) becomes an opaque message with no indication of what actually went wrong. Should be:

throw new FunctionExecutionException("Error on execution of function '" + functionName + "'", e);

2. Null fn will produce a misleading NPE (low-medium severity)

polyglotEngine.context.getBindings("js").getMember(functionName) returns null when the function is not registered in the context. fn.execute(args) then throws NullPointerException, which is caught by the generic handler and becomes the same opaque message. A null check with a descriptive message would help:

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 toJsArg (low severity)

@SuppressWarnings("unchecked")
private static Object toJsArg(final Object value) {
  if (value instanceof Map<?, ?> map)
    return toDeepProxyObject((Map<String, Object>) map);

The cast (Map<String, Object>) map will throw ClassCastException at runtime if the map has non-String keys (a Map<Integer, Object> for example). The old mapToJavaScript handled this explicitly with entry.getKey().toString(). toDeepProxyObject should accept Map<?, ?> and coerce keys to String itself.


Breaking Change - needs documentation

The removed SQL-path test in jsonObjectAsInput was not accidental. Passing a JSON string like '{"foo":"bar"}' via a SQL query to a JS function that expects an object now arrives as a plain JS string rather than an object literal. Callers who previously did:

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 docs/ markdown file.


Minor Observations

  • Direct field access: polyglotEngine.context.getBindings("js") reaches into a public final field. GraalPolyglotEngine already has an eval() method and a putMember() method - a getBindings(language) delegation method would be slightly cleaner, though this is a style note not a blocker.

  • docs/ file: The project CLAUDE.md says not to create documentation files unless explicitly requested. docs/4326-polyglot-js-injection.md is a useful explanation of the fix but may conflict with that guideline - worth a quick check with the maintainer.

  • Test coverage gaps: The injection regression test covers {a:1,b:2} (the looksLikeJson path). The alreadyQuoted path ('value') is not tested. A test that passes "'hello'" and verifies it arrives as a literal string (not as hello) would close the gap.

  • The security fix itself is correct. Using Value.execute(args...) with GraalVM handling type conversion is the right solution. The proxy wrapping of Map and List via toDeepProxyObject / toDeepProxyList correctly preserves dot-notation access in JS.

@claude

claude Bot commented May 25, 2026

Copy link
Copy Markdown

Follow-up Review

All 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)

  • Exception cause now preserved: catch (Exception e) { throw new FunctionExecutionException(..., e); } - correct.
  • Null guard added: fn == null check with a descriptive message - correct.
  • ClassCastException risk resolved: normalizeMapKeys / normalizeListValues / normalizeValue helpers ensure all map keys are coerced to String before reaching toDeepProxyObject - correct.

Remaining minor observations (not blockers)

1. init() still swallows exception cause (pre-existing, out of scope)

} catch (final Exception e) {
  throw new FunctionExecutionException("Error on definition of function '" + functionName + "'");
}

The two-arg constructor FunctionExecutionException(message, cause) exists - worth a small follow-up to pass e here too, but this is pre-existing and out of scope for this PR.

2. SQL-via-injection test removed from jsonObjectAsInput

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 alreadyQuoted injection regression test

jsInjectionPrevented covers the looksLikeJson path ({a:1,b:2}). The alreadyQuoted path ('some-value') is not covered. A test that passes "'hello'" and asserts the result equals "'hello'" (not "hello") would close this gap.

4. docs/ file convention

docs/4326-polyglot-js-injection.md is a useful internal explanation of the fix. Per project conventions, markdown documentation files should not be added to the repo unless explicitly requested - the maintainer may prefer to keep this explanation in the PR description instead.


Verdict

The 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 Value.execute(args...) is the right solution and eliminates the injection surface entirely.

@claude

claude Bot commented May 25, 2026

Copy link
Copy Markdown

test comment

@codecov

codecov Bot commented May 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 43.33333% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.15%. Comparing base (c4cf514) to head (0da878f).

Files with missing lines Patch % Lines
...unction/polyglot/JavascriptFunctionDefinition.java 43.33% 12 Missing and 5 partials ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@robfrank robfrank merged commit 128872a into main May 26, 2026
23 of 30 checks passed
@robfrank robfrank deleted the fix/4326-polyglot-js-injection branch May 26, 2026 09:39
@robfrank robfrank added this to the 26.6.1 milestone May 27, 2026
@robfrank robfrank self-assigned this May 27, 2026
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.

JavascriptFunctionDefinition` "looks-like-JSON" heuristic enables JS injection in the polyglot engine

1 participant