Skip to content

Encode path parameters and keep Set-Cookie out of serialized responses - #81

Merged
owjs3901 merged 2 commits into
mainfrom
owjs3901/fetch-path-params
Oct 9, 2026
Merged

owjs3901 merged 2 commits into
mainfrom
owjs3901/fetch-path-params

Conversation

@owjs3901

@owjs3901 owjs3901 commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Two bugs in @devup-api/fetch, reproduced by calling createApi(...) against a local Bun.serve server (port 0, plain Bun runtime with native fetch).

Path parameters are inserted raw

getApiEndpoint did ret.replace(`{${key}}`, value), so the URL parser read the value as part of the path. api.delete('/admin/notices/{id}', { params: { id } }) reached the server as:

id server received (before) server received (after)
../members DELETE /admin/members (another endpoint) DELETE /admin/notices/..%2Fmembers
.. DELETE /admin/ throws, nothing sent
. DELETE /admin/notices/ throws, nothing sent
a/b DELETE /admin/notices/a/b DELETE /admin/notices/a%2Fb
x?y=1 DELETE /admin/notices/x?y=1 (became a query) DELETE /admin/notices/x%3Fy%3D1
x#y DELETE /admin/notices/x (rest cut as a fragment) DELETE /admin/notices/x%23y
$` DELETE /admin/notices/http://localhost:<port>/admin/notices/ DELETE /admin/notices/%24%60
$& DELETE /admin/notices/%7Bid%7D DELETE /admin/notices/%24%26

The last two rows are String.prototype.replace substitution patterns. A name used twice in one path was also replaced only once.

Serialized responses carry Set-Cookie

serializeResponse copied every response header. Generated Server Actions (df/server.ts) return serializeApiResponse(...) to Client Components, so the API's Set-Cookie, HttpOnly session included, became readable by browser JavaScript. Serialized headers from the same repro:

{"content-length":"11","content-type":"application/json","date":"...","set-cookie":"braillify_admin_session=SECRET-TOKEN; HttpOnly; Path=/; SameSite=Lax"}

Browser fetch never exposes the forbidden response-header names Set-Cookie and Set-Cookie2 to JavaScript.

Fix

  • getApiEndpoint (packages/fetch/src/utils.ts): every value whose {name} appears in the path is encoded with encodeURIComponent(String(value)) and inserted with replaceAll and a replacer function, so $ patterns are never interpreted and every occurrence is replaced. encodeURIComponent leaves . and .. unchanged, and the WHATWG URL parser resolves them (and %2e forms) as dot segments, so they cannot be sent as one segment; they now throw Path parameter "id" cannot be "..": it would change the request path before any request is made. The empty string is still inserted as-is because the parser keeps an empty segment. Params that are not in the path are still ignored. Query and body serialization are unchanged.
  • serializeResponse (packages/fetch/src/server-utils.ts): drops set-cookie and set-cookie2; all other headers are kept.
  • Docs in README.md, packages/fetch/README.md and SKILL.md, plus a @devup-api/fetch patch changepack.

Compatibility

  • Code that put / in a path parameter on purpose to build a multi-segment path now sends one encoded segment (a/b becomes a%2Fb). Such paths need one placeholder per segment.
  • Values the caller already percent-encoded are encoded again (%20 becomes %2520).
  • . and .. as path parameter values now reject instead of reaching a different endpoint. A string with a lone surrogate now throws URIError from encodeURIComponent.
  • Ordinary values (letters, digits, -, _, ~, dots inside a value, numbers, UUIDs) produce the same URL as before.
  • SerializedResponse.headers no longer contains set-cookie or set-cookie2; the type is unchanged. Only code that read cookies from the serialized object is affected.

Tests

  • utils.test.ts: getApiEndpoint cases for ../members, a/b, x?y=1, x#y, %, a space, Korean text, $&, $`, a number and a name used twice; the empty string and an unused .. param stay unchanged; . and .. throw. Existing cases are untouched.
  • api.test.ts: createApi(...).delete('/admin/notices/{id}', { params: { id } }) with the suite's mocked fetch asserts the URL of the Request that is actually sent, and that . and .. reject without calling fetch. The test preload (happy-dom) replaces the global fetch and Response, so the real-server repro above was run as a standalone Bun script.
  • server.test.ts: the isOk and isError cases now carry Set-Cookie and Set-Cookie2 and still serialize to exactly content-type (and x-test).

Verified TDD: 20 of the 23 new or updated tests fail without the fix and all pass with it; the 3 that pass in both states pin unchanged behavior (number, empty string, unused param).

In CI order, bun i, bun lint, bun run build, bun lint and bun test --coverage all exit 0: 1169 pass / 0 fail, 100% function and line coverage.

@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

Changepacks

@devup-api/fetch@0.1.24 → 0.1.25 - packages/fetch/package.json

Patch

  • Encode path parameters with encodeURIComponent. Code that put / in a path parameter to build a multi-segment path now sends one encoded segment (a/b becomes a%2Fb), and . or .. now throws instead of changing the request path. serializeApiResponse (used by generated Server Actions) no longer copies Set-Cookie or Set-Cookie2 headers.

@devup-api/hookform@0.1.8 → 0.1.9 - packages/hookform/package.json

Patch

  • Auto-update: depends on '@devup-api/fetch' via a local workspace dependency

@devup-api/react-query@0.1.18 → 0.1.19 - packages/react-query/package.json

Patch

  • Auto-update: depends on '@devup-api/fetch' via a local workspace dependency

@devup-api/ui@0.1.5 → 0.1.6 - packages/ui/package.json

Patch

  • Auto-update: depends on '@devup-api/hookform' via a local workspace dependency

@devup-api/zod@0.1.6 → 0.1.7 - packages/zod/package.json

Patch

  • Auto-update: depends on '@devup-api/fetch' via a local workspace dependency

@owjs3901
owjs3901 merged commit 106aef2 into main Oct 9, 2026
2 checks passed
@owjs3901
owjs3901 deleted the owjs3901/fetch-path-params branch October 9, 2026 15:08
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