Make WordPress Core

Opened 4 weeks ago

Closed 9 days ago

#65271 closed defect (bug) (fixed)

REST API Integer Type Check Fails on Large Ints

Reported by: kevinfodness's profile kevinfodness Owned by: westonruter's profile westonruter
Milestone: 7.1 Priority: normal
Severity: minor Version: 5.5
Component: REST API Keywords: has-patch has-unit-tests php84
Focuses: Cc:

Description

The rest_is_integer function in wp-includes/rest-api.php (https://github.com/WordPress/WordPress/blob/ae418d5505790435d008f2f8a5b24baba732fd98/wp-includes/rest-api.php#L1572) checks whether a provided value is an integer by running round( (float) $maybe_integer ) === (float) $maybe_integer which fails on large ints. round() starts to break down on floats on values greater than 2^52, but the post ID field is a BIGINT UNSIGNED which has a maximum value of 2^64 - 1, so in cases where there are very large post IDs in the database the REST API check fails (every other number for post IDs 2^52 < x < 2^53, every fourth number for 2^53 < x < 2^54, and so on).

I understand that under normal usage conditions WordPress users should never have post IDs this high (it would take billions of years of furious publishing to hit those numbers naturally). However, I have seen an example in the wild of where the auto increment ID was bumped to a very high number that triggered this bug, where the IDs are still within acceptable limits in MySQL/MariaDB and pass normal checks in PHP for working with integers except when passing them to the REST API, which breaks the block editor entirely (you get an error message saying the post doesn't exist). The only fix in this situation is to renumber posts, which is a time-consuming and error-prone endeavor, and I believe a fix in this function would be straightforward and worth doing.

Change History (31)

#1 follow-up: @kevinfodness
4 weeks ago

Here is an example of the bug:

> var_dump( round( (float) 9004111231758204 ) );
float(9004111231758205)

I would expect the last digit to not change, but it does, because of the lack of precision in floats of this length (16 digits) combined with the behavior of round.

#2 @kevinfodness
4 weeks ago

Correction on the math in the original description - in the 2^53 < x < 2^54 bucket only one out of every four numbers will work and the remaining 3 will fail, so it gets worse the higher the numbers go.

#3 @westonruter
4 weeks ago

  • Keywords needs-patch needs-unit-tests added
  • Milestone changed from Awaiting Review to Future Release

Good catch. It also seems unsafe in general to compare for exact equality with floats like round( (float) $maybe_integer ) === (float) $maybe_integer given the impreciseness of floating point arithmetic (although no arithmetic is being done here).

#4 @westonruter
4 weeks ago

I think it would perhaps be better to look at the string representation of the number, and check to see if it has a decimal point present: ! str_contains( (string) $maybe_integer, '.' ). I haven't done any research on alternatives, however.

#5 @audrasjb
4 weeks ago

Removing trunk version as this is not going to be shipped with WP 7.0 but in the next releases.

#6 @gautam23
4 weeks ago

I was taking a look here and I think maybe we can use regex matching here. That way we can eliminate the float precision bug entirely.

Investigating a bit here(https://www.php.net/manual/en/function.is-numeric.php#37717), I found is_numeric() also isn't the best way to deal with this.

Last edited 4 weeks ago by gautam23 (previous) (diff)

#7 @gautam23
4 weeks ago

I was tinkering with the regex for this and had a couple of questions.

Do we consider leading and trailing spaces in the input? Like " 123 ".

And do we consider positive/negative signs in the input? I think we should not support this ideally but the current implementation does support the + sign.

This ticket was mentioned in PR #11883 on WordPress/wordpress-develop by @gautam23.


4 weeks ago
#8

  • Keywords has-patch added; needs-patch removed

This changeset updates rest_is_integer() to avoid using floating-point conversion when determining whether a value is integer-like.

Previously, the function relied on:

{{{php id="1p9csy"
round( (float) $maybe_integer ) === (float) $maybe_integer
}}}

This approach breaks for large integers because PHP floating-point values cannot precisely represent all integers above IEEE-754 precision limits (~253). As a result, valid large integer values — including values still valid for MySQL/MariaDB BIGINT UNSIGNED columns — could incorrectly fail REST API validation.

The updated implementation:

  • avoids float conversion entirely
  • validates integer-like values using string pattern matching
  • preserves support for signed numeric strings
  • rejects decimal and scientific-notation values
  • avoids platform integer size limitations (PHP_INT_MAX)

This fixes cases where very large post IDs can cause REST API requests to fail, which may prevent the block editor from loading posts correctly when auto-increment values have been manually increased or otherwise become unusually large.

Trac ticket: https://core.trac.wordpress.org/ticket/65271

## Use of AI Tools

AI assistance: Yes
Tool(s): ChatGPT
Model(s): GPT-5.5
Used for: Generating regex pattern

#9 @gautam23
4 weeks ago

Okay so I put together a small patch for this. I handles the leading and trailing spaces and also kept support for signs to keep it as close to the current implementation as possible.

While I think I covered all edge cases, let me know if I missed something!

#10 @desrosj
4 weeks ago

  • Version changed from trunk to 5.5

Introduced in [48306]/#50300.

#11 in reply to: ↑ 1 @siliconforks
4 weeks ago

Replying to kevinfodness:

Here is an example of the bug:

> var_dump( round( (float) 9004111231758204 ) );
float(9004111231758205)

I would expect the last digit to not change, but it does, because of the lack of precision in floats of this length (16 digits) combined with the behavior of round.

I think what you're seeing here is actually a bug in PHP, introduced in PHP 8.4:

https://github.com/php/php-src/issues/18266

A quick fix might be to change round to floor, because floor does not have the same weird behavior of round.

#12 @kevinfodness
4 weeks ago

@siliconforks interesting! That also explains why the bug doesn't manifest in our current production environment (PHP 8.2) but does in the redesign environment (PHP 8.4). Thanks, everyone!

This ticket was mentioned in PR #11893 on WordPress/wordpress-develop by @siliconforks.


4 weeks ago
#13

  • Keywords has-unit-tests added; needs-unit-tests removed

To run the test suite:

vendor/bin/phpunit --filter Tests_REST_API

Trac ticket: https://core.trac.wordpress.org/ticket/65271

## Use of AI Tools

AI assistance: No

#14 follow-up: @mboynes
4 weeks ago

@siliconforks it's true that round() impacts the issue starting in PHP 8.4, but only in that it moves the starting point from 253 to 252; the heart of the issue remains.

Example:

php > var_dump(floor((float) '9007199254740992'));
float(9007199254740992)
php > var_dump(floor((float) '9007199254740993'));
float(9007199254740992)
php > var_dump(floor((float) '9007199254740994'));
float(9007199254740994)

The real problem is that 253 is the line after which double-precision floats can no longer represent every integer exactly, as @gautam23 mentioned above. Better illustrated without the use of round() or floor():

php > var_dump((float) 9007199254740993);
float(9007199254740992)

I agree with the idea to use regex here, or we could also cast to an int then back to a string and compare against the original string...

php > $maybe_integer = '9223372036854775807';
php > var_dump((string)(int) $maybe_integer === (string) $maybe_integer);
bool(true)

There is still a problem lurking around here, such that the database can store larger integers than PHP can handle.

php > $maybe_integer = '9223372036854775808'; // PHP_INT_MAX + 1
php > var_dump((string)(int) $maybe_integer === (string) $num);
bool(false)
php > echo PHP_INT_MAX + 1;
9.2233720368548E+18

#15 in reply to: ↑ 14 @siliconforks
4 weeks ago

Replying to mboynes:

The real problem is that 253 is the line after which double-precision floats can no longer represent every integer exactly, as @gautam23 mentioned above.

I don't think that really has anything to do with rest_is_integer() though.

In general, rest_is_integer() should be able to handle values above 253 without problems:

$ wp eval 'var_dump(rest_is_integer(2**53));'
bool(true)
$ wp eval 'var_dump(rest_is_integer(2**54));'
bool(true)
$ wp eval 'var_dump(rest_is_integer(2**55));'
bool(true)

I think the issue with rest_is_integer() is just that PHP's round() function mangles certain values.

#16 follow-up: @mboynes
4 weeks ago

@siliconforks good question, and sorry I wasn't clear on that. Here's an example of why it matters, why floor() doesn't solve the root issue, and why this needs a different validation mechanism (probably something string-based):

php > $maybe_integer = '9007199254740993.1';
php > var_dump(floor( (float) $maybe_integer ) === (float) $maybe_integer);
bool(true)

In fact, this problem surfaces at 250 if we're including decimals, since the double needs bits to represent the decimal...

php > $maybe_integer = 2**49 + 0.1;
php > var_dump(floor( (float) $maybe_integer ) === (float) $maybe_integer);
bool(false)
php > $maybe_integer = 2**50 + 0.1;
php > var_dump(floor( (float) $maybe_integer ) === (float) $maybe_integer);
bool(true)

#17 in reply to: ↑ 16 @siliconforks
4 weeks ago

Replying to mboynes:

@siliconforks good question, and sorry I wasn't clear on that. Here's an example of why it matters, why floor() doesn't solve the root issue, and why this needs a different validation mechanism (probably something string-based):

php > $maybe_integer = '9007199254740993.1';
php > var_dump(floor( (float) $maybe_integer ) === (float) $maybe_integer);
bool(true)

In fact, this problem surfaces at 250 if we're including decimals, since the double needs bits to represent the decimal...

php > $maybe_integer = 2**49 + 0.1;
php > var_dump(floor( (float) $maybe_integer ) === (float) $maybe_integer);
bool(false)
php > $maybe_integer = 2**50 + 0.1;
php > var_dump(floor( (float) $maybe_integer ) === (float) $maybe_integer);
bool(true)

That's kind of an edge case, since it isn't actually possible to represent numbers like 9007199254740993.1 or 2**50 + 0.1 as floats. If you try to do it, what you get is actually an integer, so rest_is_integer returning true is arguably correct. (In any case, I wouldn't recommend changing that behavior now - this function has been around for 6 years and there may be someone, somewhere relying on the current behavior.)

#18 @westonruter
4 weeks ago

While I originally suggested something like is_numeric( $x ) && ! str_contains( (string) $maybe_integer, '.' ), I just learned that is_numeric() returns true for numbers in scientific notation. This would fail when '15e-1' is passed as this is 1.5.

I also just learned that scientific notation is supported by rest_is_integer(): rest_is_integer( '15e-0' ) returns true. I wonder if this is expected, as there aren't any unit tests for it in \Tests_REST_API::test_rest_is_integer(). It would be a change in behavior from what @gautam23 suggests in the regex PR.

This is what Claude Opus 4.7 suggests taking into account all the above:

<?php
function rest_is_integer( $maybe_integer ) {
        if ( is_int( $maybe_integer ) ) {
                return true;
        }

        // A canonical integer string of any magnitude — verified without float conversion.
        if ( is_string( $maybe_integer ) && preg_match( '/^\s*[+-]?[0-9]+\s*$/', $maybe_integer ) ) {
                return true;
        }

        // Decimal and scientific-notation strings (and floats) keep their historical behavior.
        return is_numeric( $maybe_integer ) && floor( (float) $maybe_integer ) === (float) $maybe_integer;
}

Regardless, I think we need to update the data provider to include more examples, including scientific notation strings, both floats (e.g. 15e-1) and ints (e.g. 15e-0).

#19 @westonruter
4 weeks ago

  • Milestone changed from Future Release to 7.1
  • Owner set to westonruter
  • Status changed from new to reviewing

@kevinfodness @mboynes @gautam23 how does this PR look to you?

#20 @gautam23
4 weeks ago

@westonruter I think this is a good improvement over the current implementation and it correctly fixes the large integer issue for canonical integer strings. My only concern is that the fallback path still relies on float conversion, so the function ends up using two different validation models depending on input shape.

From an engineering perspective, I’d lean toward making the logic fully syntax-based (as in my PR) and avoiding float arithmetic entirely. That keeps the behavior deterministic across all magnitudes/platforms, avoids any remaining precision edge cases, and makes the function easier to reason about and test. Since REST params are commonly received as strings anyway, validating against an integer string format seems like the cleaner long-term approach to me.

#21 @westonruter
4 weeks ago

@gautam23 the missing piece with your PR is that it doesn't account for integers using scientific notation (which are currently supported but probably not used frequently).

#22 @gautam23
4 weeks ago

Makes sense. While I don't think we should technically support scientific notation since this is a REST API integer validator and not a general purpose integer validator, but yeah...I get the point about preserving existing behavior.

So, I’ve updated my PR to also support scientific notation values that represent integers (for example 1e3) while still avoiding float conversion entirely. I think that gets the best of both approaches: preserving existing behavior where it makes sense, while keeping the validation logic deterministic and free from float precision issues.

@siliconforks commented on PR #11883:


4 weeks ago
#23

The problem with this PR is that there's a bunch of cases where it doesn't return the same result as the old implementation.

Here is what the old implementation does:

$ wp eval 'var_dump( rest_is_integer( true ) );'
bool(false)
$ wp eval 'var_dump( rest_is_integer( "123.0" ) );'
bool(true)
$ wp eval 'var_dump( rest_is_integer( "10e-1" ) );'
bool(true)

Here is what the implementation in this PR does:

$ wp eval 'var_dump( rest_is_integer( true ) );'
bool(true)
$ wp eval 'var_dump( rest_is_integer( "123.0" ) );'
bool(false)
$ wp eval 'var_dump( rest_is_integer( "10e-1" ) );'
bool(false)

I don't think we should be trying to rewrite the entire rest_is_integer implementation from scratch - even for a simple function like rest_is_integer, there are a lot of different cases to consider, and there's going to be some risk that the new implemention behaves differently from the original.

My PR (#11893) aims to fix the bug with large integers but otherwise attempts to preserve the existing behavior of the function. I think this is the safest approach.

@gautam23 commented on PR #11883:


4 weeks ago
#24

@siliconforks I fixed the boolean case. I am still not sure why would someone need 123.0 or 10e-1 as an integer here, considering its purpose. I'd even go as far as to say that this is an unnoticed bug that slipped under eyeballs for years and must be corrected now.

But even if it is needed, I would still prefer a regex based approach over a float approach. But that could be just me.

#25 @westonruter
11 days ago

But even if it is needed, I would still prefer a regex based approach over a float approach. But that could be just me.

I agree that using a float is not ideal given that float equality comparisons are notoriously problematic. Even if they aren't a problem in this case, they look like they could be a problem, and other devs could flag it.

#26 @westonruter
11 days ago

Well, after more investigation, I think PR #11893 is going to be better off. The concern I had about comparing floats seems to not be valid when comparing a value with the floor of itself, as no arithmetic is being done. I've amended the PR with more tests, and they all still pass with the original implementation of rest_is_integer() in PHP 8.3.

In contrast, checking PR #11883, the following values are not identified as integers as they previously have been:

  • '15e-0' (which is sanitized as 15)
  • '1.0' (which is sanitized as 1)
  • '1.5e3' (which is sanitized as 1500)

To cross-reference, I've also updated the tests to include the expected integer value when passing the rest_is_integer()-verified value through rest_sanitize_value_from_schema() and it's checking that the expected value is returned to match.

TIL: In PHP, casting scientific notation strings to integer works: 1500 === (int) '1.5e3'.

Something which this PR does not currently handle is integer values which are larger than PHP_INT_MAX. This is now documented in the rest_is_integer() function description.

@siliconforks @gautam23 If you're satisfied, I'll go ahead with the commiting PR #11893.

@westonruter commented on PR #11883:


11 days ago
#27

I'm closing this in favor of https://github.com/WordPress/wordpress-develop/pull/11893. See comment on Trac.

Thanks very much for helping to shape what gets committed!

@gautam23 commented on PR #11893:


11 days ago
#28

@westonruter I beleive this PR logically solves the problem in hand. Although my personal preference would still be a more simpler and straightforward regex approach like in #11883. That is predictable and dependency free (core php changes don't affect the behaviour of the function). It's also more maintainable and easy to extend/change the ruleset (if ever needed).

I'd also propose to not let values pass which should not be passed like unrealistic scientific notations, since I think it was an unintended side effect of the original implementation.

That being said, I'm open to any approach the community decides upon. I'm just sharing my opinion.

@westonruter commented on PR #11893:


11 days ago
#29

@coderGtm values may be unexpected, but at the scale of WordPress, they'll surely happen! Backwards-compatibility is key.

#30 @westonruter
9 days ago

  • Keywords php84 added

#31 @westonruter
9 days ago

  • Resolution set to fixed
  • Status changed from reviewing to closed

In 62474:

REST API: Fix rest_is_integer() returning false for large integers.

The previous round( (float) $maybe_integer ) === (float) $maybe_integer check rejected large integers on PHP 8.4. The check itself was fragile: a PHP float (a 64-bit IEEE-754 double) can represent every integer exactly only up to 253, so casting larger values is lossy. Nevertheless, that lossiness alone did not reject anything, since both sides of the comparison were munged identically. What actually broke it was a round() regression in PHP 8.4, where round( (float) $x ) can return a value different from (float) $x for certain numbers. That inequality caused canonical integers still valid for a BIGINT UNSIGNED column (such as unusually high post IDs) to be incorrectly rejected by REST validation, only on PHP 8.4+.

The function now short-circuits returning true for native integers and canonical integer strings so that integer-like values of any magnitude are detected correctly. Decimal and scientific-notation strings (and floats) retain their historical behavior, including the existing float comparison, now rewritten as a floor() check whose strict equality compares a float to its own floor and is therefore exact. The limitations around PHP_INT_MAX and fractional magnitudes beyond 2 ** 53 are documented on the function, and the data provider gains coverage for large integers, negative floats, and scientific notation.

Developed in https://github.com/WordPress/wordpress-develop/pull/11893.
Follow-up to r48306.

Props siliconforks, gautam23, westonruter, kevinfodness, mboynes, desrosj.
Fixes #65271.

Note: See TracTickets for help on using tickets.