Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ReactJS: Another round of fixes and improvements #846

Merged
merged 16 commits into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions src/main/docs/guide/views/templates/react.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,30 @@ include::{includedir}configurationProperties/io.micronaut.views.react.ReactViews

Props can be supplied in the form of an introspectable bean or a `Map<String, Object>`. Both forms will be serialized to JSON and sent to the client for hydration, as well as used to render the root component. The URL of the current page will be taken from the request and added to the props under the `url` key, which is useful when working with libraries like https://github.com/preactjs/preact-router[`preact-router`]. If you use `Map<String, Object>` as your model type and use Micronaut Security, authenticated usernames and other security info will be added to your props automatically.

By default you will need React components that return the entire page, including the `<html>` tag. You'll also need to prepare your Javascript (see below). Then just name your required page component in the `@View` annotation on a controller, for example `@View("App")` will render the `<App/>` component with your page props.
On the server side prop objects are exposed to Javascript directly, without being serialized to JSON first. Micronaut's compile time reflection is used and this avoids some overhead as well as simplifying access to your props (see below). If your props bean returns a non-introspectable object from a property, it will be mapped in the normal way for GraalJS (meaning it will use runtime reflection and may require `@HostAccess.Export` on methods you wish to call).

By default, you will need React components that return the entire page, including the `<html>` tag. You'll also need to prepare your Javascript (see below). Then just name your required page component in the `@View` annotation on a controller, for example `@View("App")` will render the `<App/>` component with your page props.

If your page components don't render the whole page or you need better control over how the framework is invoked you can use _render scripts_ (see below).

== Accessing Java from Javascript

The https://www.graalvm.org/latest/reference-manual/embed-languages/#access-java-from-guest-languages[usual GraalJS rules for accessing Java apply] with a few differences:

1. Your root prop object and any introspectable object reachable from it can be accessed using normal Javascript property syntax, for instance if you have an `@Introspectable` bean with a `String getFoo()` method then you can just access that property by writing `props.foo` instead of `props.getFoo()`, as would normally be required when accessing Java objects.
2. Methods annotated with `@Executable` can be invoked from Javascript. Arguments and return values are mapped to/from Java in a https://www.graalvm.org/sdk/javadoc/org/graalvm/polyglot/Value.html#target-type-mapping-heading[natural manner].
3. Your code can use `Java.type("com.foo.bar.BazClass")` style calls to get access to Java classes and then instantiate them or call static methods on them.

Note that props are read only. Attempting to set the value of a Java property on a props object will fail.

== Sandbox

By default Javascript executing server side runs with the same privilege level as the server itself. This is similar to the Node security model, but exposes you to supply chain attacks. If a third party React component you depend on turns out to be malicious or simply buggy, it could allow an attacker to run code server side instead of only inside the browser sandbox.
By default, Javascript executing server side runs with the same privilege level as the server itself. This is similar to the Node security model but exposes you to supply chain attacks. If a third party React component you depend on turns out to be malicious or simply buggy, it could allow an attacker to run code server side instead of only inside the browser sandbox.

Normally with React SSR you can't do much about this, but with Micronaut Views React you can enable a server-side sandbox if you use GraalVM 24.1 or higher. This prevents Javascript from accessing any Java host objects that haven't been specifically exposed into the sandbox. To use this set `micronaut.views.react.sandbox` to true in your `application.properties`.

In this mode:

Normally with React SSR you can't do much about this, but with Micronaut Views React you can enable a server-side sandbox if you use GraalVM 24.1 or higher. This prevents Javascript from accessing any Java host objects that haven't been specifically marked as accessible to the sandbox. To use this set `micronaut.views.react.sandbox` to true in your `application.properties`, and then ensure that any objects you use as props have their property getters annotated with `@org.graalvm.polyglot.HostAccess.Export`. If there are properties that happen to be on your beans that should _not_ be exposed to Javascript, just don't annotate them. Any properties not annotated will simply be invisible from inside the sandbox.
- The `Java` top level object that lets code access any class will be gone.
- Methods in `@Introspectable` objects reachable from your prop objects that are marked as `@Executable` will be exposed into the sandbox _regardless_ of sandbox settings. So be careful what methods you add to your props.
- Any objects exposed via your root props that are *not* marked `@Introspectable` will be exposed via runtime reflection instead. In that case what's available inside the sandbox will depend on the https://www.graalvm.org/sdk/javadoc/org/graalvm/polyglot/HostAccess.html[`HostAccess`] policy, which can be customized by using the factory replacement mechanism (see the docs for Micronaut Core for details). By default anything not annotated with `@HostAccess.Exposed` will be invisible and uninvokable. Normally this is sufficient, but customizing the `HostAccess` can be useful if you want to expose third party code you don't control into the sandbox.
1 change: 0 additions & 1 deletion src/main/docs/guide/views/templates/react/reacttodo.adoc
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
Micronaut React SSR has the following known issues and limitations:

- There is currently no way to extend the Javascript execution environment with custom Java-side objects.
- There is no built-in support for server side fetching.
- The rendering isn't streamed to the user.
- `<Suspense>` is not supported.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright 2017-2024 original authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.micronaut.views.react;
sdelamo marked this conversation as resolved.
Show resolved Hide resolved

import io.micronaut.context.annotation.Bean;
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
import io.micronaut.context.annotation.Factory;
import io.micronaut.core.annotation.Internal;
import org.graalvm.polyglot.HostAccess;

/**
* Allows the default Javascript context and host access policy to be controlled.
*/
@Factory
@Internal
class JSBeanFactory {
/**
* This defaults to
* {@link HostAccess#ALL} if the sandbox is disabled, or {@link HostAccess#CONSTRAINED} if it's on.
* By replacing the {@link HostAccess} bean you can whitelist methods/properties by name or
* annotation, which can be useful for exposing third party libraries where you can't add the
* normal {@link HostAccess.Export} annotation, or allowing sandboxed JS to extend or implement
* Java types.
*/
@Bean
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
HostAccess hostAccess(ReactViewsRendererConfiguration configuration) {
if (configuration.getSandbox()) {
return HostAccess.CONSTRAINED;
} else {
return HostAccess.ALL;
}
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
package io.micronaut.views.react;

import io.micronaut.core.annotation.Internal;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import org.graalvm.polyglot.Context;
import org.graalvm.polyglot.Engine;
Expand All @@ -33,31 +32,28 @@
class JSSandboxing {
private static final Logger LOG = LoggerFactory.getLogger(JSSandboxing.class);
private final boolean sandbox;
private final HostAccess hostAccess;

@Inject
JSSandboxing(ReactViewsRendererConfiguration configuration) {
JSSandboxing(ReactViewsRendererConfiguration configuration, HostAccess hostAccess) {
sandbox = configuration.getSandbox();
if (sandbox) {
LOG.debug("ReactJS sandboxing enabled");
} else {
LOG.debug("ReactJS sandboxing disabled");
}
if (LOG.isDebugEnabled()) {
LOG.debug("ReactJS sandboxing {}", sandbox ? "enabled" : "disabled");
}
this.hostAccess = hostAccess;
}

Engine.Builder configure(Engine.Builder engineBuilder) {
return engineBuilder.sandbox(sandbox ? SandboxPolicy.CONSTRAINED : SandboxPolicy.TRUSTED);
}

Context.Builder configure(Context.Builder contextBuilder) {
Context.Builder configure(Context.Builder builder) {
if (sandbox) {
return contextBuilder
.sandbox(SandboxPolicy.CONSTRAINED)
.allowHostAccess(HostAccess.CONSTRAINED);
return builder.sandbox(SandboxPolicy.CONSTRAINED).allowHostAccess(hostAccess);
} else {
return contextBuilder
.sandbox(SandboxPolicy.TRUSTED)
.allowAllAccess(true)
.allowExperimentalOptions(true);
// allowExperimentalOptions is here because as of the time of writing (August 2024)
// the esm-eval-returns-exports option is experimental. That got fixed and this
// can be removed once the base version of GraalJS is bumped to 24.1 or higher.
return builder.sandbox(SandboxPolicy.TRUSTED).allowAllAccess(true).allowExperimentalOptions(true);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/*
* Copyright 2017-2020 original authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.micronaut.views.react;

import io.micronaut.core.annotation.Internal;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.core.beans.BeanIntrospection;
import io.micronaut.core.beans.BeanIntrospector;
import io.micronaut.core.beans.BeanMap;
import io.micronaut.core.beans.BeanMethod;
import org.graalvm.polyglot.Context;
import org.graalvm.polyglot.Value;
import org.graalvm.polyglot.proxy.ProxyArray;
import org.graalvm.polyglot.proxy.ProxyExecutable;
import org.graalvm.polyglot.proxy.ProxyObject;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;

/**
* A proxy object similar to that returned by {@link ProxyObject#fromMap(Map)}, but with support
* for Micronaut's bean introspection system (a form of compile-time reflection code generation).
* Reading a key whose value is an introspectable bean will use the {@link BeanMap} instead of
* the regular polyglot mapping.
*/
@Internal
class ProxyObjectWithIntrospectableSupport implements ProxyObject {
sdelamo marked this conversation as resolved.
Show resolved Hide resolved
private final Context context;
private final Object target;
private final boolean isStringMap;

@Nullable
private final BeanIntrospection<?> introspection;

ProxyObjectWithIntrospectableSupport(Context context, Object targetObject) {
this.context = context;

if (targetObject == null) {
throw new NullPointerException("Cannot proxy a null");
}

this.target = targetObject;
this.isStringMap = isStringMap(targetObject);
this.introspection = isStringMap ? null : BeanIntrospector.SHARED.findIntrospection(targetObject.getClass()).orElseThrow();
}

private ProxyObjectWithIntrospectableSupport(Context context, Object target, boolean isStringMap, BeanIntrospection<?> introspection) {
this.context = context;
this.target = target;
this.isStringMap = isStringMap;
this.introspection = introspection;
}

private static boolean isStringMap(Object obj) {
if (obj instanceof Map<?, ?> map) {
return map.keySet().stream().allMatch(it -> it instanceof String);
} else {
return false;
}
}

@Override
public Object getMember(String key) {
Map<String, Object> map = asMap();

// Is it a property?
Object result = map.get(key);
if (result != null) {
boolean resultIsMap = isStringMap(result);
var resultIntrospection = BeanIntrospector.SHARED.findIntrospection(result.getClass());
if (resultIsMap || resultIntrospection.isPresent()) {
return new ProxyObjectWithIntrospectableSupport(context, result, resultIsMap, resultIntrospection.orElseThrow());
} else {
return context.asValue(result);
}
}

// Can it be an @Executable method?
if (introspection != null) {
Collection<? extends BeanMethod<?, Object>> beanMethods = introspection.getBeanMethods();
for (BeanMethod<?, Object> method : beanMethods) {
if (method.getName().equals(key)) {
return new PolyglotBeanMethod(beanMethods);
}
}
}

return context.asValue(null);
}

@SuppressWarnings("unchecked")
private Map<String, Object> asMap() {
return isStringMap ? (Map<String, Object>) target : BeanMap.of(target);
}

@SuppressWarnings("rawtypes")
private class PolyglotBeanMethod implements ProxyExecutable {
private final Collection<? extends BeanMethod<?, Object>> candidates;

private PolyglotBeanMethod(Collection<? extends BeanMethod<?, Object>> candidates) {
assert !candidates.isEmpty();
this.candidates = candidates;
}

@SuppressWarnings("unchecked")
@Override
public Object execute(Value... arguments) {
BeanMethod candidate = findCandidateByNumberOfArguments(arguments);
Object[] convertedArgs = new Object[arguments.length];
for (int i = 0; i < arguments.length; i++) {
convertedArgs[i] = arguments[i].as(Object.class);
}
return context.asValue(candidate.invoke(target, convertedArgs));
}

private BeanMethod findCandidateByNumberOfArguments(Value[] arguments) {
int minNeeded = Integer.MAX_VALUE;
for (BeanMethod candidate : candidates) {
int numArgs = candidate.getArguments().length;
minNeeded = Math.min(minNeeded, numArgs);
if (numArgs == arguments.length) {
return candidate;
}
}
throw new UnsupportedOperationException(String.format("No candidates found with the right number of arguments for method %s, needed at least %d but got %d", candidates.iterator().next().getName(), minNeeded, arguments.length));
}
}

@Override
public Object getMemberKeys() {
return ProxyArray.fromList(getInvokableNames());
}

@Override
public boolean hasMember(String key) {
return getInvokableNames().contains(key);
}

private ArrayList<Object> getInvokableNames() {
ArrayList<Object> propNames = new ArrayList<>(asMap().keySet());
if (introspection != null) {
introspection.getBeanMethods().forEach(it -> propNames.add(it.getName()));
}
return propNames;
}

@Override
public void putMember(String key, Value value) {
throw new UnsupportedOperationException();
}
}
Loading
Loading