Skip to content

Commit d84c13d

Browse files
christophpurrermeta-codesync[bot]
authored andcommitted
Enforce the ArrayBuffer borrow contract for Java TurboModules (#57982)
Summary: Pull Request resolved: #57982 Changelog: [ANDROID][FIXED] Enforce the ArrayBuffer borrow contract for Java TurboModules Follow-up hardening for the Android `ArrayBuffer` TurboModule type. Three problems: **1. Borrowed JS-heap bytes outlived the call that lent them.** For a synchronous method, `convertJSIArgsToJNIArgs` hands the module a `ByteBuffer` aliasing the JS `ArrayBuffer`'s bytes without copying. Nothing stopped a module from stashing that `ArrayBuffer` in a field and reading it later, after the JS heap may have moved, freed, or reused the memory — a use-after-free that reads as intermittent data corruption rather than a crash. The borrow is now explicitly scoped to the call frame. `JNIArgs` records every borrowed `ArrayBuffer` and revokes it in its destructor — including when the call throws — via the new `JArrayBuffer::invalidate`, which drops the C++ side's reference to the bytes. `ArrayBuffer.bytes` and `ArrayBuffer.size` then throw, with a message pointing at `ArrayBuffer.arrayBufferWithCopiedBytes`, and `JArrayBuffer::toJSBuffer` throws rather than aliasing revoked memory. Modules that need the bytes past the call copy them; modules that don't keep the zero-copy fast path. Revocation lives entirely on the C++ side: the peer is the single source of truth, and Kotlin asks it through `isBytesValid`. The destructor runs while the stack unwinds, possibly with a Java exception pending, so it resolves each peer pointer at borrow time — the `global_ref` alongside it keeps the Java object, and therefore the peer, alive — and calls only the `noexcept` `JArrayBuffer::invalidate`. No JNI calls are made from the destructor, which is what lets it stay `noexcept` honestly. **2. Argument conversion aborted under runtimes that refuse `tryGetMutableBuffer`.** `jsi::Runtime::tryGetMutableBuffer` and `detached` are not universally implemented: tracing and replay runtimes throw from `tryGetMutableBuffer`, and `detached` throws a `JSINativeException` if the JS-side property isn't a bool. `ArrayBuffer` argument conversion is not wrapped in a try/catch, so either throw propagated out of a JNI frame. Both calls now go through exception-tolerant helpers in `react/bridging/ArrayBuffer.h`; a runtime that refuses to answer is treated as "no native buffer available", which selects the copy path. Routing `AsyncArrayBuffer::acquire` and `::borrow` through the same helper fixes the identical latent bug on the shared C++/ObjC path. **3. A wrong return type from a module crashed instead of raising a JS error.** The `ArrayBufferKind` return path cast the returned `jobject` to `JArrayBuffer` unconditionally. A module returning any other object type produced undefined behavior. The cast is now guarded by an `isInstanceOf` check that throws a `jsi::JSError` naming the offending module and method. Also in this change: - `JByteBufferMutableBuffer::data()` reports null for a zero-capacity direct buffer instead of calling `getDirectBytes()`, which throws for one. That made `createArrayBuffer` throw for an empty `ArrayBuffer`. - Dropped two dead zero-size branches in `JArrayBuffer`: `JByteBuffer::wrapBytes` already routes `size == 0` to an empty buffer. - `JArrayBuffer.cpp` reuses the shared `detail::OwnedBytesBuffer` from `react/bridging/ArrayBuffer.h` instead of a second local copy. - `ArrayBuffer.kt` KDoc corrected: the returned JS `ArrayBuffer` is a new object over the same bytes rather than the identical one, `size` is the capacity and not a view's remaining bytes, and `arrayBufferWithOwnedBytes` documents the caller's lifetime obligation. - `ArrayBuffer.kt` moves from the `bridge` target to `native-types`, alongside the other JNI-backed bridge types. Changelog: [Android][Breaking] - TurboModule methods taking or returning an `ArrayBuffer` now use `com.facebook.react.bridge.ArrayBuffer` instead of `java.nio.ByteBuffer`, and an `ArrayBuffer` argument must not be retained past the method that receives it unless its bytes are copied with `ArrayBuffer.arrayBufferWithCopiedBytes()`. Reviewed By: javache Differential Revision: D115794808 fbshipit-source-id: 26f5d863469cc14a3f1bffc2cbc3302f3e983ecb
1 parent 5bb9639 commit d84c13d

20 files changed

Lines changed: 629 additions & 68 deletions

File tree

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ArrayBuffer.kt

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,16 @@ import java.nio.ByteBuffer
1414
/**
1515
* A fixed-length byte buffer for TurboModule `ArrayBuffer` arguments and return values.
1616
*
17+
* Returning an owning [ArrayBuffer] to JS produces a *new* JS `ArrayBuffer` object over the same
18+
* bytes, so a module that mutates an argument in place and returns it gives JS a different object
19+
* that aliases the memory it passed in. Returning a non-owning [ArrayBuffer] gives JS a copy.
20+
*
1721
* @property isOwningBytes:
1822
* - `true` — safe to retain and return to JS. Synchronize externally if JS may touch the same
1923
* memory concurrently.
20-
* - `false` — bytes are borrowed from a JS `ArrayBuffer` for the current synchronous call only.
21-
* Copy with [arrayBufferWithCopiedBytes] to keep them.
24+
* - `false` — the bytes are borrowed from a JS `ArrayBuffer` and are only valid until the method
25+
* that received this [ArrayBuffer] returns. After that, [bytes] and [size] throw. Copy with
26+
* [arrayBufferWithCopiedBytes] to keep them.
2227
*/
2328
@DoNotStrip
2429
public class ArrayBuffer : HybridClassBase {
@@ -39,14 +44,50 @@ public class ArrayBuffer : HybridClassBase {
3944
initHybrid(buffer, isOwningBytes)
4045
}
4146

47+
/**
48+
* The bytes as a direct [ByteBuffer]. The buffer is shared, not a copy: its position and limit
49+
* belong to the caller, so duplicate it rather than relying on where a previous reader left it.
50+
*
51+
* Reading this property is the only access path that is checked against revocation, and the
52+
* returned [ByteBuffer] is not. Its address cannot be cleared once handed out, so a [ByteBuffer]
53+
* kept past the method that received a non-owning [ArrayBuffer] reads freed or relocated memory
54+
* with no exception to warn you. Do not store it, and do not let it outlive the [ArrayBuffer] it
55+
* came from: retain the [ArrayBuffer] and read [bytes] again, or copy the bytes with
56+
* [arrayBufferWithCopiedBytes].
57+
*
58+
* @throws IllegalStateException if the bytes were borrowed and the method that received this
59+
* [ArrayBuffer] has already returned. See [isOwningBytes].
60+
*/
4261
public val bytes: ByteBuffer
43-
get() = buffer
62+
get() {
63+
checkBytesValid()
64+
return buffer
65+
}
4466

67+
/**
68+
* The capacity of the buffer in bytes, independent of the position and limit of [bytes].
69+
*
70+
* @throws IllegalStateException if the bytes were borrowed and the method that received this
71+
* [ArrayBuffer] has already returned. See [isOwningBytes].
72+
*/
4573
public val size: Int
46-
get() = buffer.capacity()
74+
get() {
75+
checkBytesValid()
76+
return buffer.capacity()
77+
}
78+
79+
private fun checkBytesValid() {
80+
check(isBytesValid()) {
81+
"ArrayBuffer: the bytes of a non-owning ArrayBuffer were accessed after the method " +
82+
"that received it returned. Copy them with " +
83+
"ArrayBuffer.arrayBufferWithCopiedBytes() to use them later."
84+
}
85+
}
4786

4887
private external fun initHybrid(buffer: ByteBuffer, isOwningBytes: Boolean)
4988

89+
private external fun isBytesValid(): Boolean
90+
5091
public companion object {
5192
init {
5293
ReactNativeJniCommonSoLoader.staticInit()
@@ -69,7 +110,11 @@ public class ArrayBuffer : HybridClassBase {
69110
return buffer
70111
}
71112

72-
/** @param source remaining bytes are copied into a new owning buffer */
113+
/**
114+
* @param source the bytes between its position and limit are copied into a new owning buffer.
115+
* Pass a buffer positioned at 0 with the limit at its capacity to copy all of it. The
116+
* position and limit of `source` are left unchanged.
117+
*/
73118
@JvmStatic
74119
@DoNotStrip
75120
public fun arrayBufferWithCopiedBytes(source: ByteBuffer): ArrayBuffer {
@@ -83,8 +128,9 @@ public class ArrayBuffer : HybridClassBase {
83128
}
84129

85130
/**
86-
* @param source copied into a new owning buffer. Use to keep bytes from a non-owning argument
87-
* after the call returns.
131+
* @param source all [size] bytes are copied into a new owning buffer, regardless of the
132+
* position and limit of its [bytes]. Use to keep bytes from a non-owning argument after the
133+
* call returns.
88134
*/
89135
@JvmStatic
90136
@DoNotStrip
@@ -102,8 +148,12 @@ public class ArrayBuffer : HybridClassBase {
102148
}
103149

104150
/**
105-
* @param buffer direct [ByteBuffer] to alias without copying. The caller must keep it valid for
106-
* as long as this [ArrayBuffer] lives.
151+
* Aliases an existing direct [ByteBuffer] without copying. The resulting [ArrayBuffer] reports
152+
* [isOwningBytes] as `true`, so it may be retained and returned to JS — but it does not own the
153+
* memory: the caller must keep `buffer` and whatever backs it valid for as long as the
154+
* [ArrayBuffer], and any JS `ArrayBuffer` derived from it, is reachable.
155+
*
156+
* @param buffer direct [ByteBuffer] to alias without copying
107157
*/
108158
@JvmStatic
109159
@DoNotStrip

packages/react-native/ReactAndroid/src/main/jni/react/jni/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ add_library(
3939
target_merge_so(reactnativejni_common)
4040
target_include_directories(reactnativejni_common PUBLIC ../../)
4141

42-
target_link_libraries(reactnativejni_common fbjni folly_runtime react_cxxreact rrc_view)
42+
target_link_libraries(reactnativejni_common fbjni folly_runtime react_bridging react_cxxreact rrc_view)
4343
target_compile_reactnative_options(reactnativejni_common PRIVATE)
4444
target_compile_options(reactnativejni_common PRIVATE -Wno-unused-lambda-capture)
4545

packages/react-native/ReactAndroid/src/main/jni/react/jni/JArrayBuffer.cpp

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,38 +9,29 @@
99

1010
#include <cstring>
1111
#include <span>
12+
#include <stdexcept>
1213
#include <utility>
1314
#include <vector>
1415

16+
#include <react/bridging/ArrayBuffer.h>
17+
1518
#include "JByteBufferMutableBuffer.h"
1619

1720
namespace facebook::react {
1821

1922
namespace {
2023

21-
// Holds a copy of bytes borrowed from a JS ArrayBuffer.
22-
class OwnedBytesBuffer final : public jsi::MutableBuffer {
23-
public:
24-
explicit OwnedBytesBuffer(std::vector<uint8_t> bytes) noexcept
25-
: bytes_(std::move(bytes)) {}
26-
27-
size_t size() const override {
28-
return bytes_.size();
29-
}
30-
31-
uint8_t* data() override {
32-
return bytes_.data();
33-
}
34-
35-
private:
36-
std::vector<uint8_t> bytes_;
37-
};
24+
const char* const kRevokedBorrowMessage =
25+
"com.facebook.react.bridge.ArrayBuffer: the bytes of a non-owning ArrayBuffer "
26+
"were accessed after the method that received it returned. Copy them with "
27+
"ArrayBuffer.arrayBufferWithCopiedBytes() to use them later.";
3828

3929
} // namespace
4030

4131
void JArrayBuffer::registerNatives() {
4232
registerHybrid({
4333
makeNativeMethod("initHybrid", JArrayBuffer::initHybrid),
34+
makeNativeMethod("isBytesValid", JArrayBuffer::isBytesValid),
4435
});
4536
}
4637

@@ -54,6 +45,23 @@ void JArrayBuffer::initHybrid(
5445
owningBytes != JNI_FALSE);
5546
}
5647

48+
jboolean JArrayBuffer::isBytesValid() {
49+
return hasBytes() ? JNI_TRUE : JNI_FALSE;
50+
}
51+
52+
void JArrayBuffer::invalidate() noexcept {
53+
if (!owningBytes_) {
54+
buffer_.reset();
55+
}
56+
}
57+
58+
const std::shared_ptr<jsi::MutableBuffer>& JArrayBuffer::mutableBuffer() const {
59+
if (!hasBytes()) {
60+
throw std::runtime_error(kRevokedBorrowMessage);
61+
}
62+
return buffer_;
63+
}
64+
5765
jni::local_ref<JArrayBuffer::javaobject> JArrayBuffer::create(
5866
jni::local_ref<jni::JByteBuffer> byteBuffer,
5967
std::shared_ptr<jsi::MutableBuffer> buffer,
@@ -66,24 +74,15 @@ jni::local_ref<JArrayBuffer::javaobject> JArrayBuffer::create(
6674

6775
jni::local_ref<JArrayBuffer::javaobject> JArrayBuffer::createOwning(
6876
std::shared_ptr<jsi::MutableBuffer> buffer) {
69-
// NewDirectByteBuffer rejects a null address, which is what an empty
70-
// jsi::ArrayBuffer reports, so empty buffers get an allocation of their own.
71-
if (buffer->size() == 0) {
72-
return create(jni::JByteBuffer::allocateDirect(0), std::move(buffer), true);
73-
}
74-
7577
auto byteBuffer = jni::JByteBuffer::wrapBytes(buffer->data(), buffer->size());
7678
return create(std::move(byteBuffer), std::move(buffer), true);
7779
}
7880

7981
jni::local_ref<JArrayBuffer::javaobject> JArrayBuffer::createUnowned(
8082
void* bytes,
8183
size_t size) {
82-
// NewDirectByteBuffer rejects a null address, which is what an empty
83-
// jsi::ArrayBuffer reports, so empty buffers get an allocation of their own.
84-
auto byteBuffer = size == 0
85-
? jni::JByteBuffer::allocateDirect(0)
86-
: jni::JByteBuffer::wrapBytes(static_cast<uint8_t*>(bytes), size);
84+
auto byteBuffer =
85+
jni::JByteBuffer::wrapBytes(static_cast<uint8_t*>(bytes), size);
8786
auto buffer = std::make_shared<JByteBufferMutableBuffer>(byteBuffer);
8887
return create(std::move(byteBuffer), std::move(buffer), false);
8988
}
@@ -102,16 +101,28 @@ jni::local_ref<JArrayBuffer::javaobject> JArrayBuffer::createOwned(
102101
}
103102

104103
std::shared_ptr<jsi::MutableBuffer> JArrayBuffer::toJSBuffer(
104+
jsi::Runtime& runtime,
105105
jni::alias_ref<javaobject> arrayBuffer) {
106106
auto* self = arrayBuffer->cthis();
107+
// create() runs the Kotlin constructor before setNativePointer, so a Java
108+
// ArrayBuffer without a C++ peer is reachable if either step throws.
109+
if (self == nullptr) {
110+
throw jsi::JSError(
111+
runtime, "com.facebook.react.bridge.ArrayBuffer has no native peer.");
112+
}
113+
if (!self->hasBytes()) {
114+
throw jsi::JSError(runtime, kRevokedBorrowMessage);
115+
}
116+
117+
const auto& buffer = self->mutableBuffer();
107118
if (self->owningBytes_) {
108-
return self->buffer_;
119+
return buffer;
109120
}
110121

111122
// Borrowed bytes still belong to the inbound JS ArrayBuffer; copy them before
112123
// handing a new buffer back to JS.
113-
auto bytes = std::span<uint8_t>(self->buffer_->data(), self->buffer_->size());
114-
return std::make_shared<OwnedBytesBuffer>(
124+
auto bytes = std::span<uint8_t>(buffer->data(), buffer->size());
125+
return std::make_shared<detail::OwnedBytesBuffer>(
115126
std::vector<uint8_t>(bytes.begin(), bytes.end()));
116127
}
117128

packages/react-native/ReactAndroid/src/main/jni/react/jni/JArrayBuffer.h

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,31 @@ class JArrayBuffer : public jni::HybridClass<JArrayBuffer> {
4040

4141
// Convert a module return value for rt.createArrayBuffer. Owning buffers pass
4242
// through; borrowed ones are copied because createArrayBuffer needs its own
43-
// backing store.
44-
static std::shared_ptr<jsi::MutableBuffer> toJSBuffer(jni::alias_ref<javaobject> arrayBuffer);
43+
// backing store. Raises a jsi::JSError if the buffer has no native peer or
44+
// its borrow has been revoked.
45+
static std::shared_ptr<jsi::MutableBuffer> toJSBuffer(jsi::Runtime &runtime, jni::alias_ref<javaobject> arrayBuffer);
46+
47+
// Revokes access to borrowed bytes. Called when the call frame that lent the
48+
// bytes unwinds, so a module that retained a non-owning ArrayBuffer gets an
49+
// exception instead of reading memory the JS heap has moved or freed. Owning
50+
// buffers are unaffected.
51+
void invalidate() noexcept;
52+
53+
// The bytes this buffer was created over. Throws if a borrow has since been
54+
// revoked by invalidate().
55+
const std::shared_ptr<jsi::MutableBuffer> &mutableBuffer() const;
56+
57+
// Whether the bytes are still reachable, i.e. this is an owning buffer or a
58+
// borrow that invalidate() has not revoked.
59+
bool hasBytes() const noexcept
60+
{
61+
return buffer_ != nullptr;
62+
}
63+
64+
bool isOwningBytes() const noexcept
65+
{
66+
return owningBytes_;
67+
}
4568

4669
JArrayBuffer(std::shared_ptr<jsi::MutableBuffer> buffer, bool owningBytes) noexcept
4770
: buffer_(std::move(buffer)), owningBytes_(owningBytes)
@@ -54,6 +77,8 @@ class JArrayBuffer : public jni::HybridClass<JArrayBuffer> {
5477
static void
5578
initHybrid(jni::alias_ref<jhybridobject> jobj, jni::alias_ref<jni::JByteBuffer> buffer, jboolean owningBytes);
5679

80+
jboolean isBytesValid();
81+
5782
static jni::local_ref<javaobject>
5883
create(jni::local_ref<jni::JByteBuffer> byteBuffer, std::shared_ptr<jsi::MutableBuffer> buffer, bool owningBytes);
5984

packages/react-native/ReactAndroid/src/main/jni/react/jni/JByteBufferMutableBuffer.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ class JByteBufferMutableBuffer final : public jsi::MutableBuffer {
4545
}
4646
uint8_t *data() override
4747
{
48+
// GetDirectBufferAddress may report null for a zero-capacity direct buffer,
49+
// which getDirectBytes turns into an exception. An empty buffer has no bytes
50+
// to address, so report that directly instead.
51+
if (byteBuffer_->getDirectSize() == 0) {
52+
return nullptr;
53+
}
4854
return byteBuffer_->getDirectBytes();
4955
}
5056

0 commit comments

Comments
 (0)