Skip to content

Canvas: unforgeable native instance branding (Path2D/Gradient/Image) - #1844

Draft
bkaradzic-microsoft wants to merge 9 commits into
BabylonJS:masterfrom
bkaradzic-microsoft:canvas-path2d-isinstance
Draft

Canvas: unforgeable native instance branding (Path2D/Gradient/Image)#1844
bkaradzic-microsoft wants to merge 9 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

Summary

Unforgeable type checks for Canvas polyfill natives (Path2D, CanvasGradient, Image).

Status: Held pending JsRuntimeHost type tags (per @bghgary). Nothing in Babylon.js deliberately re-prototypes these objects, so this is not on the critical path for Canvas feature work.

Feature work that used to stack here now lives in #1855 (metrics / filters / drawImage(canvas) / toDataURL) and no longer depends on this PR.

Problem

ObjectWrap::Unwrap on an unchecked JS value is unsafe on JsRuntimeHost Node-API ports (V8 AV; QuickJS wrong pointer). instanceof is forgeable via Object.setPrototypeOf.

Approach (temporary registry)

NativeInstanceRegistry brands each instance and only accepts live registered addresses via TryUnwrap. Replace with CheckTypeTag once tags land on all ports (related: JsRuntimeHost#226 covers unwrap safety only — tags are still a separate port gap with no issue yet).

Scope

  • Path2D / Gradient / Image register + safe unwrap
  • fill / stroke / addPath / drawImage reject bad args with TypeError
  • Unit tests for spoofed prototypes

Not blocked

Canvas text metrics, filter lifetime, canvas→canvas blit, toDataURL → see #1855.

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
@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-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.

@bghgary bghgary 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.

[Reviewed by Copilot on behalf of @bghgary]

The type check belongs in JsRuntimeHost rather than here — comment inline. The call-site gating, the arity check and the tests all stand.

Not a complete review; holding the smaller points until the mechanism settles.

// field 0 unconditionally, which access-violates, and the QuickJS port falls back to
// walking the prototype chain, which returns some other object's pointer.
//
// So each instance brands its own JS object with an External holding its address, and a

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.

napi_type_tag_object is the Node-API answer to this, and it is unusable here only because three of the four ports do not implement it. V8 does (js_native_api_v8.cc:2556), storing the tag under a private key — invisible to script, absent from the prototype chain, not copyable onto another object. Implementing it in the QuickJS, Chakra and JavaScriptCore ports is the fix, and this file then deletes: Napi::Object::CheckTypeTag before Unwrap rejects both spoofs, with no brand property on the object and no process-wide set or mutex.

JsRuntimeHost#226 alone is not enough. It closes Object.create(Path2D.prototype), which was never wrapped, but a correct napi_unwrap still returns void* — so a CanvasGradient wearing Path2D.prototype is genuinely wrapped, unwraps successfully, and is used as a path. Only a tag distinguishes one wrapped type from another.

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.

[Responded by Copilot on behalf of @bghgary]

We're picking up the JsRuntimeHost side — type tags in the QuickJS, Chakra and JavaScriptCore ports, plus the napi_unwrap guards from JsRuntimeHost#226. Flagging it so you don't start the same work; happy to hand it over if you'd rather take it, since you're already in that code.

This PR then waits on that landing and a pin bump.

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.

Agreed — type tags are the right mechanism, and NativeInstanceRegistry.h deletes in favour of CheckTypeTag before Unwrap.

Your correction on #226 is right, and I'd overstated it: it only closes the never-wrapped Object.create case. A genuinely wrapped CanvasGradient wearing Path2D.prototype unwraps successfully no matter how correct napi_unwrap is. My registry separates the two types only because it is instantiated per T; the general form of that belongs in the port, not in Canvas.

One thing worth knowing before you start, because it makes this four ports rather than three: the V8 implementation is currently dead code.

js_native_api.h:4 carries a [BABYLON-NATIVE-ADDITION]:

#ifndef NAPI_VERSION
#define NAPI_VERSION 5
#endif

That shadows upstream's default of 8, so the #if NAPI_VERSION >= 8 guard beginning at js_native_api_v8.cc:2555 never compiles. Confirmed against the build rather than by reading: in napi.lib (V8, RelWithDebInfo) napi_type_tag_object and napi_check_object_type_tag are both absent, while napi_unwrap and napi_create_external are present.

Raising NAPI_VERSION alone will not build it. NAPI_PRIVATE_KEY is commented out at js_native_api_v8_internals.h:79:

// [BABYLON-NATIVE-ADDITION]: Increase perf by using internal field instead of private property
//#define NAPI_PRIVATE_KEY(context)                                      \
//  (v8::Private::New(context->GetIsolate()))

and no other definition exists in the tree, so the uses at 2565 and 2595 are unresolved. The internal-field route that comment points at needs a free slot and there isn't one: SetInternalFieldCount(1) at js_native_api_v8.cc:929, with field 0 already holding the Reference (363, 598). So it is restore the private key for tags, go to two internal fields, or carry the tag on the Reference.

Worth deciding deliberately either way: NAPI_VERSION 5 gates every other post-5 API as well, so bumping it is a wider change than type tags alone.

On the handoff — happy for you to take it, and I'll stay off it. If you'd rather split, I'll take the QuickJS port, since I was just in that file for #225.

One question on the interim. Both spoofs are a hard 0xC0000005 reachable from script on master today, and the tagging work is now larger than it looked. Would you like me to land the uncontroversial part of this PR first — the call-site gating, the addPath arity check and the tests — which closes the original four holes via InstanceOf, and leave the two prototype-spoof crashes to be closed properly by the tag? That keeps the spoof tests in the suite (failing-but-known, or skipped) rather than sitting on a branch. Or I'm equally happy to hold the whole PR until the tag lands, if you'd prefer one clean change.

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.

[Responded by Copilot on behalf of @bghgary]

Hold the whole PR — one clean change once the tag lands. Nothing is blocked on it, and reaching either spoof takes script that deliberately re-prototypes a canvas object; nothing in Babylon.js does that.

We'll take all four ports.

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.

Understood — holding it. Agreed on the risk call: both spoofs need script that deliberately re-prototypes a canvas object, so there's nothing to race here.

I'll leave the branch rebased and green, and when the tags land I'll swap NativeInstanceRegistry.h for CheckTypeTag, bump the pin, and re-run. The two spoof tests stay in the suite as the regression cover for the port work.

Marking this a draft in the meantime so it doesn't sit in your review queue. Ping me if you want the QuickJS port after all.

@bkaradzic-microsoft

Copy link
Copy Markdown
Member Author

Rebased onto master (85355d87), now 5 commits with no merge. Force-pushed as a1aab5e1.

The one conflict was against #1843: BindFillStyle/BindStrokeStyle moved their style members into m_state, and this PR changes the same two lines from Unwrap to the checked TryUnwrap. Resolved by keeping the check and taking the m_state members.

Re-verified after the rebase rather than assuming: 21/21 gtest suites, all the new spoof/gradient tests passing, and the visual sweep still ran=305 passed=305 failed=0 with "Native Canvas" unchanged at 1.850%.

Still blocked on the type-tag work as discussed above — the rebase is just to keep it mergeable.

@bkaradzic-microsoft
bkaradzic-microsoft marked this pull request as draft August 22, 2026 00:23
@bkaradzic-microsoft
bkaradzic-microsoft force-pushed the canvas-path2d-isinstance branch 3 times, most recently from 6e1444c to 6e7db04 Compare August 27, 2026 18:14
@bkaradzic-microsoft
bkaradzic-microsoft marked this pull request as ready for review August 27, 2026 23:15
@bkaradzic-microsoft bkaradzic-microsoft changed the title Canvas: reject foreign objects instead of unwrapping them as a Path2D or gradient Canvas: unforgeable native instance branding (Path2D/Gradient/Image) Aug 27, 2026
@bkaradzic-microsoft
bkaradzic-microsoft requested review from CedricGuillemet and a balanced review from Copilot August 27, 2026 23:16

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 10 out of 11 changed files in this pull request and generated 5 comments.

Comment on lines +35 to +41
static void Add(const Napi::CallbackInfo& info, T* instance)
{
info.This().As<Napi::Object>().Set(BRAND_NAME, Napi::External<T>::New(info.Env(), instance));

const std::scoped_lock lock{Mutex()};
Instances().insert(instance);
}
private:
// Shared by every T: a brand read as the wrong type is still rejected, because each T
// registers into its own set.
static constexpr const char* BRAND_NAME{"__nativeInstance"};
Comment on lines +346 to +348
if (path == nullptr && !info[0].IsString())
{
throw Napi::TypeError::New(info.Env(), "Context2D.fill: the first argument is neither a Path2D nor a fill rule.");
Comment on lines +186 to +190
CanvasGradient* gradient = CanvasGradient::TryUnwrap(info.Env(), std::get<GradientStyle>(m_state.fillStyle)->Value());
if (gradient == nullptr)
{
throw Napi::Error::New(info.Env(), "Fillstyle is not a color string or a gradient.");
}
Comment on lines +212 to +216
CanvasGradient* gradient = CanvasGradient::TryUnwrap(info.Env(), std::get<GradientStyle>(m_state.strokeStyle)->Value());
if (gradient == nullptr)
{
throw Napi::Error::New(info.Env(), "Strokestyle is not a color string or a gradient.");
}
bkaradzic-microsoft added a commit to bkaradzic-microsoft/BabylonNative that referenced this pull request Aug 27, 2026
…oDataURL

- fontstash: map CSS/canvas font sizes as em units (stbtt ScaleForMappingEmToPixels)
  and MeasureText width/actualBoundingBoxLeft from advance + ink bounds.
- MeasureText binds the same face FillText would use before measuring.
- nanovg_filterstack: refcount shared blur programs/uniforms so multiple ADTs
  neither leak a second set nor double-destroy on dispose (multi-GUI).
- Brand NativeCanvas; drawImage accepts another Canvas via CPU pixel mirror.
- Canvas.toDataURL("image/png") via bimg_encode + base64; link bimg_encode.

Depends on BabylonJS#1844 (instance branding).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
bkaradzic-microsoft added a commit to bkaradzic-microsoft/BabylonNative that referenced this pull request Aug 28, 2026
Independent of instance-branding (BabylonJS#1844 / type tags):

- fontstash: CSS/canvas font-size as em units; MeasureText advance + ink bounds
- MeasureText binds the same face FillText uses
- nanovg_filterstack: refcount shared blur programs (multi-ADT lifetime)
- drawImage(canvas) via Canvas InstanceOf + CPU pixel mirror (no NativeInstanceRegistry)
- toDataURL("image/png") via bimg_encode; link bimg_encode

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
@bkaradzic-microsoft
bkaradzic-microsoft marked this pull request as draft August 28, 2026 00:04
bkaradzic and others added 8 commits September 2, 2026 07:39
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
Extend the NativeInstanceRegistry brand to NativeCanvasImage so drawImage no
longer ObjectWrap::Unwraps an unchecked first argument (Canvas, Path2D, or
plain object), which access-violated on V8. TryUnwrap + TypeError instead.

Adds a unit test that drawImage throws for non-Image sources.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
- Define brand with napi_default (non-enumerable) via DefineProperty
- fillStyle/strokeStyle TypeError messages use JS property names
- Trim long explanatory comments; keep only non-obvious notes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
Header had the declaration commented out while Image.cpp/Context.cpp still
define and call it, which broke every Canvas build after the branding work.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d47cbab2-d751-4cf9-984f-4412dd9ec601
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d47cbab2-d751-4cf9-984f-4412dd9ec601
bkaradzic-microsoft added a commit to bkaradzic-microsoft/BabylonNative that referenced this pull request Sep 2, 2026
Independent of instance-branding (BabylonJS#1844 / type tags):

- fontstash: CSS/canvas font-size as em units; MeasureText advance + ink bounds
- MeasureText binds the same face FillText uses
- nanovg_filterstack: refcount shared blur programs (multi-ADT lifetime)
- drawImage(canvas) via Canvas InstanceOf + CPU pixel mirror (no NativeInstanceRegistry)
- toDataURL("image/png") via bimg_encode; link bimg_encode

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae
UWP arm64 JSI build fails with C2039: PropertyDescriptor is not a member of
Napi. Brand instances via napi_define_properties instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d47cbab2-d751-4cf9-984f-4412dd9ec601
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.

4 participants