Skip to content

fix(javascript): reject an http method token Node refuses at the call site - #9683

Open
XiaoBinGan wants to merge 6 commits into
morluto:mainfrom
XiaoBinGan:fix/http-method-token
Open

XiaoBinGan wants to merge 6 commits into
morluto:mainfrom
XiaoBinGan:fix/http-method-token

Conversation

@XiaoBinGan

Copy link
Copy Markdown
Contributor

Summary

http.request and https.request validate options.method with checkIsHttpToken before they build the request, so a literal outside the token set throws ERR_INVALID_HTTP_TOKEN from the call itself and no request exists. The analysis reported one anyway.

Measured on Node 24.22.1

options.method outcome
"GET", "get", "M-SEARCH", "X", "!#$%&'*+-.^_`|~" request constructed
"" request constructed — Node reads it as "not given" and falls back to GET
"BAD METHOD", "G/ET", "G:ET", "G,ET", "GET ", " ", "\t", "GéT" THREW ERR_INVALID_HTTP_TOKEN

Two consequences worth naming:

  • The accepted set is the RFC 9110 token character class, not a list of verbs. "GET " fails on the trailing space while "get" and "M-SEARCH" pass.
  • An empty string is accepted, because Node reads "" as "not given" rather than as an invalid token. Treating it as invalid would drop a real request.

fetch and WebSocket read a different member and validate no token, so they are untouched. A non-string method throws ERR_INVALID_ARG_TYPE, but that is left undecided because the reported fields do not describe the type check.

Contract and boundary impact

  • Semantic owner and earliest changed stage: request candidate collection in the JavaScript semantic domain.
  • CLI and MCP/tool-catalog contract: none.
  • Provider, bridge, target-format, or platform compatibility: none.
  • Evidence, artifact, provenance, or reconstruction contract: a rejected call no longer reports a request operation or its fields; an accepted call keeps both.
  • Generated metadata, package, or installation impact: none.

Expected effects:

  • Analysis facts only; no target interaction.

Validation performed

  • 16 cases, 8 failing before this commit.
  • src/domain/javascript — 5402 passed.
  • npm run format:check — exit 0 for the files this PR touches. The reported issue is in src/cli/adbCommands.ts.
  • npm run lint — the one error is the adbCommands.ts duplicate declaration below.
  • npm run build — fails only on that same adbCommands.ts error.

tsc, lint and build all fail on src/cli/adbCommands.ts(46,9): error TS2451: Cannot redeclare block-scoped variable 'packageName', which main at e9b6c87c1 already has and which this PR does not touch. #9677 and #9678 are open fixes for it. I did not include it here so this PR keeps one outcome.

Before merge

  • This PR addresses a concrete problem or an agreed enhancement.
  • Material scope and authority questions are resolved, and any required maintainer scope decision covers the final diff.
  • The PR has one focused outcome and the title follows type(scope): outcome.
  • Tests cover changed observable behavior and meaningful failure paths.
  • Owning docs, contracts, and generated metadata are updated where needed.
  • I checked the final diff for secrets, unrelated cleanup, and unsupported claims.

Author readiness

Human owner: @XiaoBinGan

  • I reviewed the final diff and relevant existing code, understand the change and its edge cases, and take responsibility for the complete PR, including AI-assisted work.
  • I checked the evidence above, confirmed it supports the current change, and recorded meaningful validation gaps.
  • I declare this PR ready for initial maintainer review.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-10T08:32:05.024043Z 4a08abb New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 632392c78f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@XiaoBinGan

Copy link
Copy Markdown
Contributor Author

Both fixed in 3db1facc0, each measured on Node 24.22.1 first.

An erased TypeScript assertion hides the options object. http.request(({host: "h", method: "BAD METHOD"}) as any) throws ERR_INVALID_HTTP_TOKEN at runtime, but node.arguments[0] is a TSAsExpression, so the raw isObjectExpression test chose the absent second argument and returned false before the unwrap. The first argument is now unwrapped before the slot is chosen.

A leading spread shifts the options slot. Confirmed:

call Node 24.22.1
http.request(...[{host, path}, () => {}], {method: "BAD METHOD"}) OK — builds a GET request
http.request({host, method: "BAD METHOD"}) THREW ERR_INVALID_HTTP_TOKEN

The spread fills both runtime slots and the trailing object is ignored, so this branch was inspecting an argument Node never reads and dropping a real request. A spread argument now leaves the position unresolved and keeps the request — the same rule as the TLS overload-slot reader on main.

You also suggested sharing the normalized selection with requestFields. I did not do that here: requestFields reads the fields of whichever object it selects, and changing what it selects would change the reported field set in cases this fix does not measure. If you want them unified, that is a separate change with its own evidence and I will take it as its own PR.

18 cases; the two new ones fail against the pre-fix file and pass here. src/domain/javascript: 5402 → 5404 passing.

… site

`http.request` and `https.request` validate `options.method` with `checkIsHttpToken`
before they build the request, so a literal outside the token set throws
`ERR_INVALID_HTTP_TOKEN` from the call itself and no request exists. The analysis reported
one anyway.

Measured on Node 24.22.1:

| `options.method` | outcome |
| --- | --- |
| `"GET"`, `"get"`, `"M-SEARCH"`, `"X"`, `"!#$%&'*+-.^_`\|~"` | request constructed |
| `""` | request constructed — Node reads it as "not given" and falls back to `GET` |
| `"BAD METHOD"`, `"G/ET"`, `"G:ET"`, `"G,ET"`, `"GET "`, `" "`, `"\\t"`, `"GéT"` | THREW `ERR_INVALID_HTTP_TOKEN` |

The accepted set is the RFC 9110 token character class, not a list of verbs, so `"GET "`
fails on the trailing space while `"get"` and `"M-SEARCH"` pass. `fetch` and `WebSocket` read
a different member and validate no token, so they are untouched. A non-string `method` throws
`ERR_INVALID_ARG_TYPE`, but that is left undecided because the reported fields do not describe
the type check.

16 cases, 8 failing before this commit. `src/domain/javascript`: 5370 to 5386 passing.
`tsc` reports only the pre-existing `adbCommands.ts` duplicate `packageName`, which `main`
already has; `build` 0; `npm run format:check` 0; `npm run lint` 0 errors.
…quest

Two review findings, each measured on Node 24.22.1. Both were real.

**An erased TypeScript assertion hides the options object.** `http.request(({host: "h",
method: "BAD METHOD"}) as any)` throws `ERR_INVALID_HTTP_TOKEN` at runtime because the
assertion is erased, but `node.arguments[0]` is a `TSAsExpression`, so the raw
`isObjectExpression` test chose the absent second argument and returned false before the
unwrap. The first argument is now unwrapped before the slot is chosen.

**A leading spread shifts the options slot.** `http.request(...[{host, path}, () => {}],
{method: "BAD METHOD"})` spreads the options and the callback into the two runtime slots and
ignores the trailing object, so Node builds a GET request — measured on Node 24.22.1, it
returns OK. This branch inspected that trailing object and dropped a real request. A spread
argument now leaves the position unresolved and keeps the request.

18 cases; the two new ones fail against the pre-fix file and pass here.
`src/domain/javascript`: 5402 to 5404 passing.
`tsc`, `lint` and `build` still fail only on the pre-existing `adbCommands.ts` duplicate
`packageName`, which `main` has and `morluto#9677`/`morluto#9678` fix.
@XiaoBinGan
XiaoBinGan force-pushed the fix/http-method-token branch from 3db1fac to 76926ce Compare November 10, 2026 02:55

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 76926ce8da

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Two review findings, each measured on Node 24.22.1.

**A callable first argument is the callback.** `ClientRequest` replaces the options with
defaults and never reads a later object. Measured on Node 24.22.1,
`http.request(() => {}, {method: "BAD METHOD"})` returns a GET `ClientRequest`. The slot
selection chose argument 1 and dropped a real request; a callable anywhere in the argument
list now leaves the position unresolved.

**`Object.assign` runs getters before it copies.** A getter can rewrite a later member.
Measured on Node 24.22.1,
`http.request({get x() { this.method = "GET"; }, method: "BAD METHOD"})` returns a GET
request. The reader now bails on an options literal carrying a getter.

`src/domain/javascript`: 5413 passing.
@XiaoBinGan

Copy link
Copy Markdown
Contributor Author

Both fixed in 2e36db24d, each measured on Node 24.22.1.

A callable first argument is the callback. http.request(() => {}, {method: "BAD METHOD"}) and the function spelling both return a GET ClientRequest — confirmed. A callable anywhere in the argument list now leaves the position unresolved.

Object.assign runs getters before it copies. http.request({get x() { this.method = "GET"; }, method: "BAD METHOD"}) returns a GET request — confirmed. The reader bails on an options literal carrying a getter, the same rule the TLS and socket readers use.

src/domain/javascript: 5404 → 5407 passing.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2e36db24d5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

… positions

Two review findings, each measured on Node 24.22.1.

**Only a callable in slot 0 is the callback.** A callback after the options does not move the
slot, and the method in slot 0 is validated before the callback is registered:

| call | outcome |
| --- | --- |
| `http.request(() => {}, {method: "BAD METHOD"})` | request constructed — slot 0 is the callback |
| `http.request({method: "BAD METHOD"}, () => {})` | THREW `ERR_INVALID_HTTP_TOKEN` |

The previous test looked at every argument, so the second call kept a request Node never
builds.

**A getter only rewrites a member that follows it in copy order.** `Object.assign` walks the
source in order, so a getter after `method` cannot change the value already copied:

| call | outcome |
| --- | --- |
| `{get x() { this.method = "GET"; }, method: "BAD METHOD"}` | request constructed — the getter runs first |
| `{method: "BAD METHOD", get x() { this.method = "GET"; }}` | THREW — the invalid value was copied first |

`methodGetterPrecedesMember` compares the two indices instead of treating any getter as able
to rewrite the member.

`src/domain/javascript`: 5407 passing.
@XiaoBinGan

Copy link
Copy Markdown
Contributor Author

Both fixed in 28e9b304e, each measured on Node 24.22.1.

Only a callable in slot 0 is the callback. http.request({method: "BAD METHOD"}, () => {}) throws ERR_INVALID_HTTP_TOKEN — confirmed. The bailout looked at every argument; it now looks at slot 0 only, so a callback after the options no longer suppresses the check.

A getter only rewrites a member that follows it. Object.assign walks in order:

call Node 24.22.1
{get x() {...}, method: "BAD METHOD"} request constructed — the getter runs first
{method: "BAD METHOD", get x() {...}} THREW — the invalid value was copied first

methodGetterPrecedesMember compares the getter index against the member index instead of treating every getter as able to rewrite it. That is the same ordering fact the TLS merge reader needed, so the rule is now consistent across both readers.

src/domain/javascript: 5407 → 5411 passing.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 28e9b304e3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

…ation

Two review findings, each measured on Node 24.22.1.

**An erased assertion leaves the same callback.** `http.request((() => {}) as any,
{method: "BAD METHOD"})` returns a default GET; the guard tested the raw `TSAsExpression`, so it
missed the function and the object-selection code then read argument 1. The slot-0 value is now
unwrapped before the callable test.

**`Object.assign` enumerates integer-index keys before string keys.** Source order is not the
copy order:

| call | outcome |
| --- | --- |
| `{method: "BAD METHOD", get 0() {...}}` | request constructed — getter `0` runs first |
| `{method: "BAD METHOD", get x() {...}}` | THREW — the invalid value was copied first |
| `{get 0() {...}, method: "BAD METHOD"}` | request constructed |
| `{get x() {...}, method: "BAD METHOD"}` | request constructed |

`methodGetterPrecedesMember` now compares `[group, order]` tuples — integer group ascending,
then the string group in insertion order — instead of a single fused index. My first attempt
folded both groups into one number and lost precision past 2^53, which made two of these cases
report the opposite answer.

`src/domain/javascript`: 5411 passing.
@XiaoBinGan

Copy link
Copy Markdown
Contributor Author

Both fixed in afe3cae7c, each measured on Node 24.22.1.

An erased assertion leaves the same callback. http.request((() => {}) as any, {method: "BAD METHOD"}) returns a default GET — confirmed. The guard tested the raw TSAsExpression, so it missed the function. The slot-0 value is unwrapped first now.

Object.assign enumerates integer-index keys before string keys. You were right that source order is not copy order:

call Node 24.22.1
{method: "BAD METHOD", get 0() {...}} request constructed — getter 0 runs first
{method: "BAD METHOD", get x() {...}} THREW — the invalid value was copied first
{get 0() {...}, method: "BAD METHOD"} request constructed
{get x() {...}, method: "BAD METHOD"} request constructed

methodGetterPrecedesMember compares [group, order] tuples now — integer group ascending, then the string group in insertion order.

Worth recording: my first attempt folded both groups into one number (key * 1e6 + index) and lost precision past 2^53, so two of these four cases reported the opposite answer while the tests still read plausibly. The tuple form is what makes the two groups independent.

src/domain/javascript: 5411 → 5414 passing.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: afe3cae7ce

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

…y-index bound

Three review findings, each measured on Node 24.22.1.

**A redefined key keeps its first insertion position.** JavaScript replaces the descriptor but
not the slot, so enumeration follows the first write:

| call | outcome |
| --- | --- |
| `{x: 0, method: "BAD METHOD", get x() { this.method = "GET"; }}` | request constructed — `x` enumerates first |
| `{y: 0, method: "BAD METHOD", get x() { this.method = "GET"; }}` | THREW — the getter is on a fresh key |

The helper now takes each key's position from its first insertion and its descriptor from the
last, instead of using the AST index of the last write.

**Only an ECMAScript array index enumerates in the integer group.** `4294967295` is the boundary
and is *not* one:

| key | outcome |
| --- | --- |
| `get 4294967294()` | request constructed — an array index, so it runs first |
| `get 4294967295()` | THREW — enumerates with the string keys, after `method` |

The predicate is now `canonical integer, >= 0, < 2 ** 32 - 1` rather than digits-only.

**A spread after the options slot cannot move it.** `http.request({method: "BAD METHOD"},
...[])` still throws `ERR_INVALID_HTTP_TOKEN`; only a spread in slot 0 leaves the position
unresolved.

`src/domain/javascript`: 5414 passing.
@XiaoBinGan

Copy link
Copy Markdown
Contributor Author

All three fixed in 4a08abb2e, each measured on Node 24.22.1.

A redefined key keeps its first insertion position. Confirmed:

call Node 24.22.1
{x: 0, method: "BAD METHOD", get x() {...}} request constructed — x enumerates first
{y: 0, method: "BAD METHOD", get x() {...}} THREW — the getter is on a fresh key

The helper now takes each key's position from its first insertion and its descriptor from the last. That is the distinction I had been collapsing: position and descriptor are two different facts about a redefined key, and using the AST index of the last write was reading the wrong one.

Only an ECMAScript array index enumerates in the integer group. Confirmed the boundary:

key outcome
get 4294967294() request constructed — array index, runs first
get 4294967295() THREW — enumerates with the string keys

The predicate is canonical integer, >= 0, < 2 ** 32 - 1 now instead of digits-only. String(key) === name also rules out "01" and "1e3", which are digits-only matches but not array indexes.

A spread after the options slot cannot move it. http.request({method: "BAD METHOD"}, ...[]) throws — confirmed. Only a spread in slot 0 leaves the position unresolved.

src/domain/javascript: 5414 → 5420 passing.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4a08abb2e4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

{ readonly node: t.ObjectProperty | t.ObjectMethod; readonly index: number }
>();
for (const [index, property] of options.properties.entries()) {
if (t.isSpreadElement(property)) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include spread-created keys in the getter-order model

A first insertion supplied by an object spread is fresh evidence beyond the handled direct-redefinition case: in http.request({ ...{ x: 0 }, method: "BAD METHOD", get x() { this.method = "GET"; } }), x keeps the position created by the spread, so Node invokes the final getter before copying method and constructs a GET request. This continue instead records x only at the later getter, causing rejectsHttpMethodLiteral to suppress the real request; expand statically known spread keys and overrides in the same object-property representation.

AGENTS.md reference: AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

for (const [index, property] of options.properties.entries()) {
if (t.isSpreadElement(property)) continue;
const name = semanticStaticPropertyKey(property.key, property.computed);
if (name === null) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep computed getter order unresolved

A computed accessor is fresh evidence beyond the handled static getter cases: const k = "x"; http.request({ get [k]() { this.method = "GET"; }, method: "BAD METHOD" }) constructs a GET request because the getter runs before method is copied. semanticStaticPropertyKey returns null here, so this continue erases the accessor and the later literal check suppresses the real request; retain unknown applicability/order when a computed descriptor may enumerate before method.

AGENTS.md reference: AGENTS.md:L21-L21

Useful? React with 👍 / 👎.

Comment on lines +710 to +713
const options =
firstValue !== undefined && t.isObjectExpression(firstValue)
? firstValue
: unwrapJavaScriptExpression(node.arguments[1] ?? first!).node;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain HTTPS requests when the leading input is falsy

A falsy HTTPS head is fresh evidence beyond the handled callback overload: for https.request(null, { method: "BAD METHOD" }) (likewise false, 0, or undefined), Node's HTTPS wrapper leaves its default options object in place and ignores argument 1, so it constructs a GET ClientRequest. This ternary instead selects argument 1 and removes that real request; preserve the call unless slot 1 is proven to be the HTTP/HTTPS options slot, or carry the module-specific overload identity into this decision.

AGENTS.md reference: AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

// The getter runs before the copy reaches `method`, so it rewrites the source value.
expect(
requestsFor(
`http.request({ get x() { this.method = "GET"; }, method: "BAD METHOD" })`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove duplicate getter-order regressions

This exact getter-before-method call and expected result already appear at lines 81-85, while the later getter-after-method standalone test similarly repeats lines 94-98. The duplicate assertions exercise no distinct behavior and create two places to update for each outcome, so keep one copy of each getter-order regression.

AGENTS.md reference: AGENTS.md:L27-L27

Useful? React with 👍 / 👎.

This branch has not been deployed

No deployments
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