Skip to content

Fix UB in double-to-int conversions across JSC#244

Merged
dylan-conway merged 9 commits into
mainfrom
fix-parseint-result-int32-ub
Jun 2, 2026
Merged

Fix UB in double-to-int conversions across JSC#244
dylan-conway merged 9 commits into
mainfrom
fix-parseint-result-int32-ub

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Jun 1, 2026

Copy link
Copy Markdown
Member

Converting a double to a narrow integer with static_cast/C-cast is undefined behavior when the value is NaN, infinite, or outside the target range. Newer compilers exploit this — they assume the value is in range and delete a following round-trip/range check, yielding a wrong (often sign-wrapped) result. Each site below takes an unbounded, caller-controlled double straight to the cast.

  • DFGOperations parseIntResultparseInt("80000000", 16) boxed as a negative int32 in optimized builds
  • LiteralParser JSONP array index
  • JSValue::getUInt32 — property-subscript fast path
  • String.fromCodePoint code point
  • Number.prototype.toExponential/toPrecision digit count (range check ran after the cast)
  • Inspector weakMapEntries/iteratorEntries fetch count (+Infinity passed the >= 0 guard)
  • LLInt slow_path_switch_imm scrutinee — switch (2**31) could match a case -2147483648: label once the wrapped cast result passed the folded range check

Each is made defined via truncateDoubleToInt32(), clampTo<>(), or a range check on the double before narrowing. Same class as #179 and #232.

static_cast<int> of a double outside the int32 range is undefined
behavior. Newer compilers assume the cast is in-range and elide the
following round-trip check, so parseInt results >= 2^31 (e.g.
parseInt("80000000", 16)) are boxed as a negative int32 instead of the
correct double. Use truncateDoubleToInt32() for a defined conversion.
@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 499fec7b-1ad6-46db-a75c-2520782dcf64

📥 Commits

Reviewing files that changed from the base of the PR and between 04b3738 and d19a17e.

📒 Files selected for processing (1)
  • Source/JavaScriptCore/llint/LLIntSlowPaths.cpp

Walkthrough

Replace direct double-to-int/uint casts with truncateDoubleToInt32/truncateDoubleToUint32 and clamp inspector fetch counts with clampTo; keep existing equality and range checks and original behavior paths.

Changes

Numeric parsing and validation

Layer / File(s) Summary
parseIntResult truncation implementation
Source/JavaScriptCore/dfg/DFGOperations.cpp
parseIntResult converts double input to int32_t via truncateDoubleToInt32 instead of static_cast<int>, with subsequent correctness verification and value encoding unchanged.
LiteralParser JSONP index truncation
Source/JavaScriptCore/runtime/LiteralParser.cpp
JSONP bracket-index parsing uses truncateDoubleToInt32 for converting floating tokens before exactness and range checks.
JSValue getUInt32 truncation
Source/JavaScriptCore/runtime/JSCJSValue.h
getUInt32 truncates double values with truncateDoubleToUint32 before comparing to the original double.
NumberPrototype toExponential/toPrecision validation
Source/JavaScriptCore/runtime/NumberPrototype.cpp
Capture optional argument as double from toIntegerOrInfinity, validate it lies in 0..100 / 1..100 as a double, then cast to int for formatting.
stringFromCodePoint double-range and integer checks
Source/JavaScriptCore/runtime/StringConstructor.cpp
Convert code point argument with truncateDoubleToUint32, then validate integer-ness and range (<= UCHAR_MAX_VALUE) before appending UTF-16 or throwing RangeError.
LLInt switch scrutinee truncation
Source/JavaScriptCore/llint/LLIntSlowPaths.cpp
slow_path_switch_imm uses truncateDoubleToInt32 to derive the int32 case value from a double scrutinee before switch dispatch.

Inspector fetch clamping

Layer / File(s) Summary
weakMap and iterator fetch clamping
Source/JavaScriptCore/inspector/JSInjectedScriptHost.cpp
weakMapEntries and iteratorEntries use clampTo<unsigned>(...) when computing snapshot and iterator fetch counts instead of manual negative checks or direct casts.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and accurately describes the main change: fixing undefined behavior in double-to-int conversions across JSC, which aligns with all modified files.
Description check ✅ Passed The description explains the bug (UB in double narrowing), provides specific examples from each changed file, and describes the fix approach, but lacks a Bugzilla link and Reviewed-by line as required by the template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


Comment @coderabbitai help to get the list of available commands and usage tips.

LiteralParser JSONP index, JSValue::getUInt32, and String.fromCodePoint
each cast a double to a narrow int and validate with a round-trip check
the compiler may delete (the cast is UB out of range). Make the cast
defined / guard the range before narrowing.
The ToInteger argument was cast to int before the range check, so an
out-of-range or infinite value hit UB. Range-check the double first.
weakMapEntries/iteratorEntries cast a ToIntegerOrInfinity result to
unsigned; +Infinity passes the >= 0 guard and makes the cast UB. clampTo
defines it.
@dylan-conway dylan-conway changed the title Fix UB in parseIntResult double-to-int32 conversion Fix UB in double-to-int conversions across JSC Jun 2, 2026
truncateDoubleToInt32 makes the conversion defined for all doubles, so
the existing v == d round-trip check is enough on its own.
truncateDoubleToInt32 returns an architecture sentinel for d >= 2^31,
so the round-trip check would have rejected valid uint32 indices in
[2^31, 2^32). truncateDoubleToUint32 returns d exactly for d in
[0, 2^32) and is in scope via the existing MathExtras.h include.
JSValue(double) already calls tryConvertToStrictInt32 and boxes int32
iff exactly representable, else as double — this function was
duplicating that. The one-liner is bit-identical and removes the UB
by deletion.
Restore the original cast-then-round-trip structure; only the cast is
made defined via truncateDoubleToUint32 so the value is bit-identical
for every input the original cast was defined on.
@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
d19a17e7 autobuild-preview-pr-244-d19a17e7 2026-06-02 03:51:10 UTC
04b37384 autobuild-preview-pr-244-04b37384 2026-06-02 01:26:41 UTC

@dylan-conway dylan-conway force-pushed the fix-parseint-result-int32-ub branch from bfdb24c to 04b3738 Compare June 2, 2026 03:18
@dylan-conway dylan-conway merged commit 27f4e7a into main Jun 2, 2026
48 checks passed
Jarred-Sumner pushed a commit to oven-sh/bun that referenced this pull request Jun 2, 2026
Bumps `WEBKIT_VERSION` from `963f8758` to
[`6d586e29`](oven-sh/WebKit@6d586e2),
the current head of oven-sh/WebKit main. The prebuilt release
[`autobuild-6d586e293f008f0e74e5697611a379b1b24815c9`](https://github.com/oven-sh/WebKit/releases/tag/autobuild-6d586e293f008f0e74e5697611a379b1b24815c9)
is published with all platform tarballs.

This range is a fast-forward of 6 fork-local commits — no upstream
WebKit merge:

-
[`27f4e7a9`](oven-sh/WebKit@27f4e7a)
Fix UB in double-to-int conversions across JSC (oven-sh/WebKit#244)
-
[`f18cf9c2`](oven-sh/WebKit@f18cf9c)
FreeList::forEach: bound the interval assert by MarkedBlock::blockSize
(oven-sh/WebKit#247)
-
[`48460d87`](oven-sh/WebKit@48460d8)
Compile out the JIT disassembler in release builds (oven-sh/WebKit#241)
-
[`6d586e29`](oven-sh/WebKit@6d586e2)
windows cross: add the bun-webkit-windows-amd64-asan variant
(oven-sh/WebKit#240)
-
[`c787a5a7`](oven-sh/WebKit@c787a5a)
windows cross: add the amd64-baseline ThinLTO variant
-
[`c591a185`](oven-sh/WebKit@c591a18)
windows: update xwin to 0.9.0 (oven-sh/WebKit#242)

Checks:

- No `Source/JavaScriptCore/runtime/JSType.h` changes in the range, so
no `src/jsc/JSType.rs` update needed.
- The workflow change (#240) only adds a new
`bun-webkit-windows-amd64-asan.tar.gz` asset; all tarball names
`prebuiltSuffix()` produces are present in the release.
robobun added a commit to oven-sh/bun that referenced this pull request Jun 2, 2026
…bKit LTO flags

parseInt results, Map keys, and switch-immediate scrutinees at or above
2^31 came back as sign-wrapped int32s on official Linux x64 release
builds once the call site tiered up to the DFG: JSC had out-of-range
double->int casts (undefined behavior) in the round-trip checks that
decide int32 boxing/dispatch, and the LLVM 22 LTO backend (rust-lld,
via the cross-language LTO pipeline) folds those checks into bare
integrality tests. parseInt("80000000", 16) === -2147483648 after
~10k hot calls, persisting for the life of the process; Map keys
(issue #31080) and switch dispatch had the same failure mode. Fresh
processes replayed clean because lower tiers box through the hardened
paths.

The source fixes landed in oven-sh/WebKit#244 and reached main with the
WebKit upgrade in #31724. This adds the regression coverage: tests that
hammer all three paths past JIT tier-up (they fail in ~130 ms on the
last broken canary and run on CI's Linux x64 release lanes, which build
with LTO — debug builds cannot exhibit the fold).

Also forward the artifact builders' LTO flags (-flto=full
-fwhole-program-vtables -fforce-emit-vtables) to local Linux/FreeBSD
WebKit builds: with plain -flto=thin, --webkit=local --lto=on fails to
link with 'inconsistent LTO Unit splitting' because bun-profile links
with -fwhole-program-vtables. This is how the fix was validated
end-to-end before prebuilt artifacts existed.

Fixes #31080
robobun added a commit that referenced this pull request Jun 2, 2026
parseInt results, and switch-immediate scrutinees, at or above 2^31
must not come back as wrapped int32s once out-of-range double->int
casts are involved. #244 fixed the undefined behavior (newer LLVM LTO
backends folded the round-trip overflow guards into bare integrality
tests, so bun's linux-x64 release builds boxed parseInt("80000000",
16) as -2147483648 after DFG tier-up); these tests pin the behavior:

- dfg-parseIntResult-should-not-wrap-above-int32.js: hex/decimal/
  negative parses around the int32 boundary plus the Infinity-overflow
  case, with and without radix.
- switch-imm-scrutinee-above-int32.js: dense jump-table and sparse
  switches with out-of-int32 scrutinees (2^31, 2^32, Infinity), which
  land exactly on a case label if the scrutinee range check is folded.

Both passed on a jsc shell built with the equivalent fixes under
-flto=full -fwhole-program-vtables.
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.

1 participant