Repository navigation
Fixed Relay global ID and cursor helpers throwing on malformed client input - #638
xperiandri wants to merge 4 commits into
Conversation
… input Relay global IDs and connection cursors come from the client, but the helpers decoding them threw on malformed input: - `fromGlobalId` and the `GlobalId` active pattern used `Convert.FromBase64String`, which throws `FormatException` on anything that is not valid base64. A `node` query with a malformed `id` produced a GraphQL error carrying the exception's message instead of a `null` node. - `Cursor.toOffset` decoded through the same pattern, so it threw too, and parsed the offset with the current culture and `NumberStyles.Integer`, accepting negative offsets, signs and surrounding whitespace. Changes: - `fromGlobalId` decodes with `Convert.TryFromBase64String`, rejects bytes that are not valid UTF-8 instead of turning them into U+FFFD, and splits at the first `:` byte; malformed input yields `ValueNone`. Its signature is unchanged. - Added `Cursor.tryToOffset`, which accepts only ASCII decimal digits parsed with `NumberStyles.None` and the invariant culture and returns `ValueNone` otherwise; `Cursor.toOffset` is built on it and keeps its signature. - `Cursor.ofOffset` formats the offset with the invariant culture. - Documented on `NodeField`/`NodeAsyncField` how a resolver turns a malformed ID into a `null` node. - Tests cover empty, whitespace, non-base64, non-ASCII, non-UTF-8, missing separator, negative, signed, overflowing and non-ASCII-digit IDs and cursors, through the helpers and through executed `node` and connection queries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Int32.TryParse` ignores trailing NUL characters even with `NumberStyles.None`, so `Cursor.tryToOffset` read "1" followed by NULs as 1, although it is documented to accept ASCII decimal digits only. The local ID of a cursor is now checked to hold only ASCII digits before it is parsed. The release note of the stricter `Cursor.toOffset` is marked as a breaking change: a cursor that `Cursor.ofOffset` wrote for a negative offset is no longer read back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| letters | ||
| |> Array.skip start | ||
| |> Array.truncate first | ||
| |> Array.mapi (fun index letter -> { Cursor = Cursor.ofOffset (start + index); Node = letter }) |
There was a problem hiding this comment.
| letters | |
| |> Array.skip start | |
| |> Array.truncate first | |
| |> Array.mapi (fun index letter -> { Cursor = Cursor.ofOffset (start + index); Node = letter }) | |
| letters | |
| |> Seq.skip start | |
| |> Seq.truncate first | |
| |> Seq.mapi (fun index letter -> { Cursor = Cursor.ofOffset (start + index); Node = letter }) | |
| |> Seq.toArray |
| box <| NameValueLookup.ofList [ "node", upcast "c" ] | ||
| upcast NameValueLookup.ofList [ "node", upcast "d" ] |
There was a problem hiding this comment.
why differently?
There was a problem hiding this comment.
No reason: the first item of the list was boxed with box <| and the second with upcast. Both go through box <| now (4ff2fdd).
There was a problem hiding this comment.
🟢 Approval recommended
The implementation and tests consistently cover the documented malformed-input behavior.
0 open findings
What changed in this PR
Improves Relay helpers so malformed client-provided global IDs and cursors are handled safely and consistently.
Changes:
- Adds non-throwing Base64/UTF-8 global ID decoding.
- Adds strict cursor offset validation via
Cursor.tryToOffset. - Expands documentation, release notes, and malformed-input tests.
| File | Description |
|---|---|
src/FSharp.Data.GraphQL.Server.Relay/Node.fs |
Safely decodes and validates global IDs. |
src/FSharp.Data.GraphQL.Server.Relay/Connections.fs |
Adds strict cursor parsing and invariant formatting. |
tests/FSharp.Data.GraphQL.Tests/Relay/NodeTests.fs |
Tests malformed IDs and node resolution. |
tests/FSharp.Data.GraphQL.Tests/Relay/CursorTests.fs |
Tests cursor validation and query behavior. |
RELEASE_NOTES.md |
Documents behavior and breaking changes. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The cursor decoder opens `System` instead of qualifying `Int32`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Test Results 9 files 9 suites 14m 25s ⏱️ Results for commit 4ff2fdd. ♻️ This comment has been updated with latest results. |
…alike Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
Relay global IDs and cursors come from the client.
fromGlobalId, theGlobalIdactive pattern built on it, andCursor.toOffsetdecoded them withConvert.FromBase64String, which throwsFormatExceptionon anything that is not valid base64. Anode(id: ...)query with a malformed ID therefore answered with a GraphQL error carrying the exception's message, instead of anullnode.Cursor.toOffsetparsed the offset with the current culture andNumberStyles.Integer. It accepted negative offsets, which index before the start of the data, as well as signs and surrounding whitespace.Changes
fromGlobalId:Convert.TryFromBase64String;:.It returns
ValueNonefor a null, empty, non-base64, non-UTF-8 or separator-less ID. Its signature is unchanged.Cursor.tryToOffset(new) returnsValueNonefor a malformed cursor, so that a resolver can report it as a GraphQL error.It accepts only a non-negative
Int32written in ASCII decimal digits, parsed with the invariant culture. The digits are checked before parsing, becauseInt32.TryParseignores trailing NUL characters even withNumberStyles.None.Cursor.toOffsetusestryToOffset, and returns the default value for a malformed cursor.Cursor.ofOffsetformats with the invariant culture.Docs. The docs of
NodeFieldandNodeAsyncFieldsay that the resolver gets the raw client ID and should returnNonewhenfromGlobalIdyields nothing.Breaking:
Cursor.toOffsetno longer reads back a cursor thatCursor.ofOffsetwrote for a negative offset. The library itself never writes negative offsets.Tests
The tests are xUnit, in
Relay/NodeTests.fsandRelay/CursorTests.fs:noderesolving a malformed ID tonullwithout errors;aftercursor as a GraphQL error;toGlobalId/fromGlobalIdandofOffset/tryToOffset.Verification:
🤖 Generated with Claude Code