Skip to content

Canvas: reject foreign objects instead of unwrapping them as a Path2D or gradient - #1844

Open
bkaradzic-microsoft wants to merge 5 commits into
BabylonJS:masterfrom
bkaradzic-microsoft:canvas-path2d-isinstance
Open

Canvas: reject foreign objects instead of unwrapping them as a Path2D or gradient#1844
bkaradzic-microsoft wants to merge 5 commits into
BabylonJS:masterfrom
bkaradzic-microsoft:canvas-path2d-isinstance

Conversation

@bkaradzic-microsoft

@bkaradzic-microsoft bkaradzic-microsoft commented Aug 20, 2026

Copy link
Copy Markdown
Member

Follow-up to #1824, as agreed in review.

ObjectWrap::Unwrap does no type checking -- it reinterprets the object's internal pointer as the target type. Four call sites reached it without establishing that the object was actually a NativeCanvasPath2D:

call gate before hole
ctx.stroke(x) none ctx.stroke("x") cast a string straight into Unwrap
ctx.fill(x) IsObject() ctx.fill({}) still got through
new Path2D(x) IsObject() same hole
path.addPath(x) none, and no arity check addPath() unwrapped a missing argument

stroke was the worst, as noted in review:

// before
const NativeCanvasPath2D* path = info.Length() == 1
    ? NativeCanvasPath2D::Unwrap(info[0].As<Napi::Object>()) : nullptr;

Change

The check has to be one script cannot forge. instanceof is not, because a prototype is assignable:

Object.setPrototypeOf(gradient, Path2D.prototype);
ctx.fill(gradient);            // unwrapped a CanvasGradient as a path -> 0xC0000005
ctx.fillStyle = spoofedPath;   // stored, then Unwrap(...)->Paint() -> 0xC0000005

Both directions are an access violation, reproduced on Win32 D3D11. Object.create(Path2D.prototype) gets through the same way while having no native wrap behind it at all. CanvasGradient had the identical gate, so it is fixed here too.

napi_type_tag_object would be the idiomatic answer, but only the V8 port implements it in this tree -- Chakra, JavaScriptCore and QuickJS do not. A brand property is forgeable even under a symbol, since Object.getOwnPropertySymbols hands the symbol to script.

So the authority lives in C++, where script cannot reach it. NativeInstanceRegistry<T> records the address of every live instance, and a candidate is accepted only when its unwrapped pointer is one of them. The pointer is compared and never dereferenced before being accepted, so a foreign wrapped object is rejected rather than misread, and a reused address cannot alias because entries are removed in the destructor. It calls napi_unwrap directly rather than ObjectWrap::Unwrap, which throws for an object that was never wrapped -- that is what closes the Object.create case.

fill, stroke and addPath now throw TypeError for an argument that is not a Path2D, as browsers do, and addPath() reports a missing argument separately from a wrong-typed one. An unusable fillStyle/strokeStyle assignment leaves the previous value in place, as the spec requires.

The legal forms are unchanged and covered by a test: fill()/stroke() with no argument, an explicit undefined (which selects the no-argument overload rather than being a bad Path2D), a fill rule string, fill(path), fill(path, rule) and stroke(path).

new Path2D(x) does not throw. Per the (Path2D or DOMString) union a non-Path2D is converted to a string and parsed as path data, so new Path2D({ toString() { return "M0 0 L10 10"; } }) works and new Path2D({}) yields an empty path.

Validation

  • Unit tests: 21/21 suites, 49 JS assertions (was 43).
  • Visual sweep: ran=305 passed=305 failed=0; "Native Canvas" unchanged at 1.850%.
  • A/B on the original four holes: reverting only the C++ change fails exactly those tests and nothing else, without faulting -- unwrapping the wrong object reads plausible garbage rather than crashing, which is why it never showed up as a test failure.
  • A/B on the two prototype-spoof holes: both exit 0xC0000005 before the fix.

Throw assertions deliberately do not name the error type: a C++ Napi::TypeError surfaces as a JS TypeError on some engines and as an InternalError on the QuickJS Node-API port, so only the fact that it throws is portable. Every other throw test in this suite does the same.

Copilot AI lite review requested due to automatic review settings August 20, 2026 00:41

Copilot AI 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.

Pull request overview

This PR hardens the Canvas2D polyfill’s Path2D interop by preventing unsafe ObjectWrap::Unwrap calls on non-Path2D values, aligning fill, stroke, and addPath argument handling with browser behavior and adding regression coverage.

Changes:

  • Added NativeCanvasPath2D::IsInstance (mirroring CanvasGradient::IsInstance) and used it to gate all NativeCanvasPath2D::Unwrap call sites.
  • Updated Context2D.fill, Context2D.stroke, and Path2D.addPath to throw TypeError for non-Path2D arguments (while preserving the intended Path2D constructor behavior for non-Path2D inputs by stringifying them as path data).
  • Added unit tests covering the previously-unsafe argument forms and the still-valid overload forms.

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
Polyfills/Canvas/Source/Path2D.h Declares NativeCanvasPath2D::IsInstance for safe instance checking prior to Unwrap.
Polyfills/Canvas/Source/Path2D.cpp Implements IsInstance, fixes Path2D constructor routing, and adds addPath argument validation before unwrapping.
Polyfills/Canvas/Source/Context.cpp Adds Path2D instance checks (and TypeErrors) to fill/stroke prior to unwrapping.
Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts Adds regression tests for invalid/valid fill/stroke/addPath argument forms and Path2D ctor behavior.
Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js Updates built test bundle corresponding to the new/updated TS tests.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Polyfills/Canvas/Source/Path2D.cpp Outdated

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated 1 comment.

Comment thread Polyfills/Canvas/Source/Path2D.cpp Outdated
// the global is writable, so script can replace it, and this has to answer whether the
// object is safe to Unwrap, not whether it matches whatever Path2D currently names.
const auto constructor = JsRuntime::NativeObject::GetFromJavaScript(env).Get(JS_PATH2D_CONSTRUCTOR_NAME);
return constructor.IsFunction() && value.As<Napi::Object>().InstanceOf(constructor.As<Napi::Function>());

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

You are right, and it is worse than a type-confusion risk -- both directions are a hard crash. Fixed in 4b37ddf.

I reproduced it before changing anything. Object.setPrototypeOf(gradient, Path2D.prototype) makes gradient instanceof Path2D true, and ctx.fill(gradient) then exits with 0xC0000005 on Win32 D3D11. Object.create(Path2D.prototype) passes the same check while having no native wrap behind it at all.

Checking the neighbouring type on your hint that a gradient was the vehicle: CanvasGradient::IsInstance had the identical gate, and it is reachable the other way round -- ctx.fillStyle = spoofedPath2D stores the impostor, and the next fill does CanvasGradient::Unwrap(...)->Paint(). Also 0xC0000005. Fixed both.

On the mechanism, I went through the options you implied:

  • napi_type_tag_object is the idiomatic answer, but only the V8 port implements it in this tree; the Chakra, JavaScriptCore and QuickJS ports do not, so it is not usable here.
  • A brand property is forgeable even under a symbol, since Object.getOwnPropertySymbols hands the symbol to script, which can then define it on any object.

So the authority has to live somewhere script cannot reach, which means C++. NativeInstanceRegistry<T> records the address of every live instance; a candidate is accepted only when its unwrapped pointer is one of them. The pointer is compared and never dereferenced before it is accepted, so a foreign wrapped object is rejected rather than misread, and a reused address cannot alias because entries are removed in the destructor. It also calls napi_unwrap directly instead of ObjectWrap::Unwrap, which throws for an object that was never wrapped -- that is what closes the Object.create case.

Both crashes now have regression tests, and they are the prototype-spoofed-native-object tests you asked for. Full run is 21/21 gtest suites and 49 JS assertions, up from 47. I also ran the 305-test visual sweep because the gradient draw path changed: 305/305, unchanged.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Follow-up: the first version of this fix reached the instance pointer through ObjectWrap::Unwrap, and that turned out not to be safe to call on an object that might never have been wrapped. Neither JsRuntimeHost Node-API port honours that part of the contract:

  • the V8 port dereferences internal field 0 unconditionally ([BABYLON-NATIVE-ADDITION] in js_native_api_v8.cc), so a plain object access-violates;
  • the QuickJS port falls back to walking the prototype chain, so it returns some other object's native pointer.

Object.create(Path2D.prototype) hit exactly this and crashed the V8 and QuickJS unit test runs.

So the check no longer calls Unwrap at all: each instance brands its own JS object with an External holding its address, and a candidate is accepted only if that address is still registered natively. Externals are opaque to script, and the address is compared, never dereferenced, before it is accepted.

An identity check against the instance's own object would also close brand-copying, but ObjectWrap::Value() throws on the QuickJS port, so it is not portable. It is not a memory-safety gap: copying a brand off a real instance yields that live instance, which the script already held, and a brand left over from a collected instance is rejected because the destructor unregisters the address.

Verified on Chakra, V8 and QuickJS: 21/21 gtest, 49 assertions each. Reverting only the C++ change reproduces the access violation. Visual sweep 305/305.

I'll file the napi_unwrap contract violations against JsRuntimeHost separately, since every ObjectWrap::Unwrap call site in BabylonNative has the same exposure.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Filed the promised follow-up: BabylonJS/JsRuntimeHost#226 covers the napi_unwrap contract violations (V8 port dereferences internal field 0 unchecked; QuickJS port falls back to walking the prototype chain).

Chasing the remaining CI failure on this PR turned up a second, separate port bug in the same area — BabylonJS/JsRuntimeHost#225, where the napi_throw family returns napi_pending_exception after a successful throw. That one makes every native throw on QuickJS escape WrapCallback and get rebuilt from e.what() after its handle scope has closed, which is the segfault in Ubuntu_Clang_QuickJS.

@bkaradzic-microsoft bkaradzic-microsoft changed the title Canvas: reject non-Path2D arguments instead of unwrapping them Canvas: reject foreign objects instead of unwrapping them as a Path2D or gradient Aug 20, 2026
@bkaradzic-microsoft

Copy link
Copy Markdown
Member Author

The remaining Ubuntu_Clang_QuickJS failure is not a Canvas bug — it is a Node-API port bug that this PR is the first thing to trip.

What the core dump says. The job uploaded a core this time. gdb puts the fault in the error reporting path, not in the type check:

#0  js_dup                       quickjs.c:1628          <-- SIGSEGV
#4  napi_get_value_string_utf8   js_native_api_quickjs.cc:696
#7  Napi::Error::Message         napi-inl.h:3087
#8  Napi::Error::what            napi-inl.h:3157
#9  ExternalCallback::Callback   js_native_api_quickjs.cc:164

The failing callback is InstanceWrap<NativeCanvasPath2D>::InstanceVoidMethodCallbackWrapper with argc=0, i.e. the path.addPath() assertion in the new rejects a non-Path2D argument to Path2D.addPath test. The JSValue being stringified has JS_TAG_STRING and an unaligned, freed pointer.

Root cause. The QuickJS port's napi_throw returns napi_pending_exception after a successful throw. node-addon-api reads that as "the throw failed" and re-throws Error::New(env), which consumes the pending exception, so the C++ exception escapes WrapCallback with no JS exception set. ExternalCallback::Callback then rebuilds the error from e.what() after the relevant handle scope has closed — a use-after-free.

This affects every native throw on QuickJS, not just Canvas. I instrumented that catch block locally: all ~50 native throws in the unit-test run escape WrapCallback. Linux faults; Windows happens to survive reading the freed string, which is why my local QuickJS build stayed green.

It also explains the InternalError: Uncaught C++ exception: ... messages I hit earlier in this PR — the real error is being replaced.

Fix: BabylonJS/JsRuntimeHost#225. Verified there with an A/B on Linux QuickJS: without the change expected 'InternalError' to equal 'Error' (1 failing), with it 213 passing.

So this PR is blocked on JsRuntimeHost#225 landing plus a pin bump, the same way #1835 waited on JsRuntimeHost#223. Everything else here is green (31/32), and the Canvas work itself is verified on Chakra, V8 and QuickJS locally (21/21 gtest, 49 assertions each) with a 305/305 visual sweep.

bkaradzic-microsoft pushed a commit to bkaradzic-microsoft/BabylonNative that referenced this pull request Aug 20, 2026
… them as Path2D/gradient

# Conflicts:
#	Polyfills/Canvas/Source/Context.cpp
bkaradzic and others added 5 commits August 20, 2026 15:42
ObjectWrap::Unwrap does no type checking, so handing it an object that is
not a NativeCanvasPath2D reinterprets unrelated memory as one. Four call
sites reached it without establishing that:

  ctx.stroke(x)      no check at all -- ctx.stroke("x") cast a string
  ctx.fill(x)        gated on IsObject(), so ctx.fill({}) still got through
  new Path2D(x)      gated on IsObject(), same hole
  path.addPath(x)    no type check and no arity check, so addPath() also
                     unwrapped a missing argument

Add NativeCanvasPath2D::IsInstance, mirroring CanvasGradient::IsInstance,
and check it before every Unwrap. It tests against the constructor kept on
the native object rather than the global one, because the global is
writable and the question being asked is whether the object is safe to
unwrap, not whether it matches whatever Path2D currently names.

fill/stroke/addPath now throw TypeError for an argument that is not a
Path2D, as browsers do. The legal forms are unchanged: fill() and stroke()
with no argument, an explicit undefined (which selects the no-argument
overload), a fill rule string, and a Path2D. Per the (Path2D or DOMString)
union, new Path2D(x) converts a non-Path2D to a string and parses it as
path data rather than throwing.
A C++ Napi::TypeError surfaces as a JS TypeError on Chakra and V8 but as
"InternalError: Uncaught C++ exception" on the QuickJS Node-API port, so
asserting the constructor fails the Ubuntu_Clang_QuickJS job. Assert only
that the call throws, which is what every other throw test in this suite
already does.

Also split addPath's arity check from its type check so a missing argument
no longer reports that the first argument has the wrong type.
The IsInstance gates added earlier tested `instanceof` against the stored
constructor. That only walks the prototype chain, and a prototype is
assignable from script, so the check they were meant to make was still
bypassable:

    Object.setPrototypeOf(gradient, Path2D.prototype);
    ctx.fill(gradient);   // unwraps a CanvasGradient as a NativeCanvasPath2D

Both directions crash with an access violation, confirmed on Win32 D3D11:
the Path2D case through fill/stroke/addPath, and the gradient case through
`ctx.fillStyle = spoofedObject` followed by any fill. Object.create with
the right prototype gets through the same way while having no native wrap
behind it at all.

napi_type_tag_object would be the idiomatic fix, but only the V8 port
implements it -- Chakra, JavaScriptCore and QuickJS do not -- and a brand
property is reachable through Object.getOwnPropertySymbols and copyable
onto any object. So the authority moves into C++, where script cannot
reach it: NativeInstanceRegistry records the address of every live
instance, and a candidate is accepted only when its unwrapped pointer is
one of them. The pointer is compared, never dereferenced, before being
accepted, so a foreign wrapped object is rejected instead of misread.

napi_unwrap is called directly rather than through ObjectWrap::Unwrap,
which throws for an object that was never wrapped.
The registry added in the previous commit still reached the instance pointer
through ObjectWrap::Unwrap, which is not safe to call on an object that may
never have been wrapped. Neither JsRuntimeHost port honours that contract: the
V8 port dereferences internal field 0 unconditionally and access-violates, and
the QuickJS port falls back to walking the prototype chain and returns some
other object's pointer. Object.create(Path2D.prototype) crashed the V8 and
QuickJS unit test runs for exactly this reason.

Each instance now brands its own JS object with an External holding its address,
and a candidate is accepted only if that address is still registered. Externals
are opaque to script and the address is compared, never dereferenced, before it
is accepted.

Verified on Chakra, V8 and QuickJS: 21/21 gtest, 49 assertions. Reverting only
the C++ change reproduces the access violation. Visual sweep 305/305.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
BabylonJS/JsRuntimeHost#225 makes the QuickJS napi_throw family report success,
so a native throw no longer escapes the callback wrapper and get rebuilt from a
freed error. Without it, the addPath() arity check added here segfaults the
QuickJS unit tests on Linux. Also picks up BabylonJS#223.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 88569c10-a7ff-4373-9a58-afa9c68b8c09
@bkaradzic-microsoft

Copy link
Copy Markdown
Member Author

Confirming the loop is closed: BabylonJS/JsRuntimeHost#225 is merged, the pin here now points at 2390c63c34138a05c06c967d22687bb8e199a340, and CI is 32/32 green — including Ubuntu_Clang_QuickJS, which is the job that was segfaulting.

No change was needed to the Canvas code for that failure; it was the QuickJS Node-API port reporting failure from a successful napi_throw, so the new Path2D.addPath() arity check unwound into a use-after-free instead of surfacing a TypeError.

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.

3 participants