[BUG] ArrayIndexOutOfBoundsException` on a field name followed by trailing whitespace
- Dominant language
- Java
- Stars
- 4.4k
- Forks
- 613
- Avg merge
- 1d 22h
- Merged PRs (30d)
- 6
Description
**Version:** 2.0.64 (latest release, reproduced against the published jar)
**Classes:** `com.alibaba.fastjson2.JSONReaderASCII`, `com.alibaba.fastjson2.JSONReaderUTF16`
A document that ends in whitespace just after a field name throws
`ArrayIndexOutOfBoundsException` instead of `JSONException`. Five bytes of plain ASCII:
```java
JSON.parse("{\"a\" ");
```
```
java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 5
at com.alibaba.fastjson2.JSONReaderASCII.readFieldName(JSONReaderASCII.java:966)
at com.alibaba.fastjson2.JSONReader.readObject(JSONReader.java:3739)
at com.alibaba.fastjson2.JSON.parse(JSON.java:142)
```
Without the trailing space, `JSON.parse("{\"a\"")` raises `JSONException` correctly — which
is the behaviour expected here.
## Cause
`readFieldName()` reads the character after the closing quote **with** a bounds check, then
skips whitespace **without** one (`JSONReaderASCII.java:956-970`):
```java
offset++;
if (offset < end) { // guarded
c = bytes[offset];
} else {
c = EOI;
}
while (c <= ' ' && ((1L << c) & SPACE) != 0) {
offset++;
c = bytes[offset]; // <-- line 966, no bounds check
}
if (c != ':') {
throw syntaxError(offset, ch);
}
```
The guarded read sets `c` correctly at end-of-input. The loop then advances and reads
again unchecked. One trailing whitespace character suffices: the guarded read returns it,
the loop is entered, `offset` becomes `end`, and `bytes[end]` is read.
The statement immediately after the loop is guarded again
(`c = ++offset == end ? EOI : chars[offset];`), so the loop is the only gap in an otherwise
careful sequence.
## Four sites, two per reader
The same loop appears twice in `readFieldName()` — before and after the `:` — in both
readers that implement the method:
| site | minimal input |
|---|---|
| `JSONReaderASCII.readFieldName:966` | `{"a" ` |
| `JSONReaderASCII.readFieldName:982` | `{"a": ` |
| `JSONReaderUTF16.readFieldName:2008` | `{"中" ` |
| `JSONReaderUTF16.readFieldName:2018` | `{"中": ` |
Any whitespace works — space, `\t`, `\r`, `\n`, or several of them.
**Only `JSON.parse(String)` is affected.** `JSON.parse(byte[])` goes through
`readFieldNameHashCode()`, whose equivalent loops are guarded, and returns a clean
`JSONException`.
## The idiom is right 199 times elsewhere
Sweeping the three readers for this loop shape and classifying by whether the body contains
a bounds check:
| file | guarded | unguarded |
|---|---|---|
| `JSONReaderASCII.java` | 11 | 2 |
| `JSONReaderUTF8.java` | 96 | 12 |
| `JSONReaderUTF16.java` | 92 | 20 |
The correct form is used overwhelmingly:
```java
while (ch <= ' ' && ((1L << ch) & SPACE) != 0) {
offset++;
if (offset >= this.end) { ch = EOI; break; }
ch = bytes[offset];
}
```
Four of the 34 unguarded ones are demonstrated reachable above. The remaining 30 were not
probed to a conclusion — they may well be unreachable, but the count seems worth a look on
your side.
## Fix
Guard the four demonstrated sites with the same idiom the other 199 use:
```diff
while (c <= ' ' && ((1L << c) & SPACE) != 0) {
offset++;
+ if (offset >= end) {
+ c = EOI;
+ break;
+ }
c = bytes[offset];
}
```
and identically over `chars[]` in `JSONReaderUTF16`. `c = EOI` then falls into the existing
`if (c != ':') throw syntaxError(...)`, so the truncated document is reported as such.
The attached patch covers only those four, not all 34 — guarding the rest is your call and
wants evidence per site.
## Verification
Both readers patched from the 2.0.64 sources and compiled ahead of the published jar.
| case | stock 2.0.64 | patched |
|---|---|---|
| `{"a" `, `{"a": `, `{"中" `, `{"中": ` | AIOOBE | `JSONException` |
| `{"a"\t`, `{"a"\r`, `{"a"\n`, `{"a" `, `{"中"\r` | AIOOBE | `JSONException` |
| the original 133-byte fuzzer input | AIOOBE | `JSONException` |
| `{"a"`, `{"a":`, `{"a":1`, `{"a" x`, `{"中"` | `JSONException` | `JSONException` |
| `{"a":1}` | `{"a":1}` | `{"a":1}` |
| `{"a" : 1}` | `{"a":1}` | `{"a":1}` |
| `{"a"\r\n:\t1}` | `{"a":1}` | `{"a":1}` |
| `{"中":1}`, `{"中" : 1}` | parse | parse |
| `[{"a":1}]`, `{"a":1,"b":2}`, `{}` | parse | parse |
No behaviour change for any valid input — in particular whitespace between a field name
and its colon keeps working, which is the regression that would matter.
## Not a security issue
The out-of-range access is a **read**, and the JVM's bounds check fires before it. Nothing
is read or disclosed, and the exception message carries only the input length.
The impact is the API-contract one: code following the documented pattern
```java
try { JSON.parse(untrusted); } catch (JSONException e) { /* reject */ }
```
does not catch this, so five bytes of ASCII propagate an unchecked `RuntimeException` past
the intended error handling.
---
Best regards,
The Fandango Cispa Team
Contributor guide
Research direction
Reproduce the failing JSON.parse(String) cases, then inspect JSONReaderASCII.readFieldName around lines 956-970 and 982, and the corresponding JSONReaderUTF16 sites around lines 2008 and 2018. Add the demonstrated end-of-input handling at those four whitespace loops and verify truncated inputs raise JSONException while valid whitespace and JSON documents continue to parse.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java
- Domain
- backend-api-design
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 85/100