Skip to content

fix: encode path parameters in request URLs - #1113

Open
max-programming wants to merge 7 commits into
resend:canaryfrom
max-programming:fix/encode-path-parameters
Open

max-programming wants to merge 7 commits into
resend:canaryfrom
max-programming:fix/encode-path-parameters

Conversation

@max-programming

@max-programming max-programming commented Sep 27, 2026 •

Copy link
Copy Markdown

what's happening

most resource methods drop ids and emails straight into the request path:

`/contacts/${options?.email ? options?.email : options?.id}`   // src/contacts/contacts.ts
`/emails/${id}`                                                 // src/emails/emails.ts

only events.ts and suppressions.ts run the value through encodeURIComponent.

contacts can be looked up by email, and #, /, ? and % are all valid in the local part of an email. for john#doe@example.com, everything after # is treated as a url fragment and never sent, so resend.contacts.get({ email: 'john#doe@example.com' }) ends up requesting /contacts/john. same thing happens for update and remove, so an update or delete can hit the wrong contact. a/b@example.com splits into an extra path segment, and a?b@example.com turns into a query string.

there's a hardening side too. url paths work like folders, and .. means "go up one level", so fetch resolves /domains/../api-keys to /api-keys before sending. if an app passes user input as an id, that lets a call escape its own endpoint: domains.remove('../api-keys/KEY_ID') sends DELETE /api-keys/KEY_ID, which is the delete api key endpoint. x?foo=bar also sneaks in extra query params.

the fix

added a small path tagged template in src/common/utils/path.ts that encodes every interpolated value:

path`/contacts/${email}` // '/contacts/john%23doe%40example.com'

switched every interpolated request path over to it, including events.ts and suppressions.ts, so there's one pattern and new code can't forget. for paths with a query string only the path part goes through path, the query string is left as is.

encoding the / is enough for ../api-keys (it becomes one harmless segment, ..%2Fapi-keys), but not for an id that is exactly . or ... there's no / to encode, and url parsing treats %2E%2E as .. too, so there's no way to send it that stays on the right endpoint. fetchRequest now checks for that and returns an invalid_parameter error without sending anything. it returns the error instead of throwing, same as the other input checks in the sdk. ids that just contain dots (..., john.doe@...) go through as normal.

normal ids come out unchanged, so this is non-breaking. the one visible difference is that @ is now sent as %40, which the api already handles (suppressions and events send it that way today, and the re-recorded fixtures below confirm it for contacts).

tests

  • path.spec.ts covers the helper: plain ids unchanged, reserved characters encoded, ../ kept inside one segment, and dot segment detection including the encoded forms
  • resend.spec.ts: . / .. ids return invalid_parameter and no request is made, ... still goes through
  • contacts.spec.ts: lookups by john#doe@example.com and a/b@example.com hit the encoded path, a normal id is unchanged. the existing string-email test now expects team%40resend.com
  • re-recorded the contacts integration fixtures against the live api, since the request urls changed. every by-email get/update/remove passes against the real api with the encoded path

one heads up: lists contacts without pagination fails when recording on an account that isn't empty, because creates a contact leaves test@example.com behind and the list test expects exactly 6. it's not related to this change, so i kept the existing fixture for that test.

verified

built the sdk from the current release and from this pr, pointed both at a local http server via baseUrl, and logged the request that actually arrived for each call:

call request sent today request sent with this fix what it means
contacts.get({ email: 'john#doe@example.com' }) GET /contacts/john GET /contacts/john%23doe%40example.com wrong contact today
contacts.update({ email: 'john#doe@example.com', ... }) PATCH /contacts/john PATCH /contacts/john%23doe%40example.com updates the wrong contact today
contacts.remove({ email: 'john#doe@example.com' }) DELETE /contacts/john DELETE /contacts/john%23doe%40example.com deletes the wrong contact today
contacts.get({ email: 'a/b@example.com' }) GET /contacts/a/b@example.com GET /contacts/a%2Fb%40example.com email split in two today
emails.get('x?foo=bar') GET /emails/x?foo=bar GET /emails/x%3Ffoo%3Dbar extra query param today
domains.remove('../api-keys/KEY_ID') DELETE /api-keys/KEY_ID DELETE /domains/..%2Fapi-keys%2FKEY_ID hits the delete api key endpoint today
emails.cancel('..') POST /cancel not sent, returns invalid_parameter leaves /emails today
emails.get('..') GET / not sent, returns invalid_parameter leaves /emails today
emails.get('...') GET /emails/... GET /emails/... unchanged
broadcasts.get('<uuid>') GET /broadcasts/<uuid> GET /broadcasts/<uuid> unchanged
templates.list({ limit: 10, after: 'abc' }) GET /templates?after=abc&limit=10 GET /templates?after=abc&limit=10 unchanged

Summary by cubic

Fixes path construction in request URLs so IDs and emails with reserved characters like #, /, ?, and % are correctly encoded, preventing lookups and updates from hitting the wrong contact or endpoint.

Refactors

  • Adds a path tagged template that encodes every interpolated value, and switches all resource methods to it; plain IDs come out unchanged, so this is non-breaking.
  • events.ts and suppressions.ts now use the same pattern instead of the previous encodeURIComponent calls.
  • Re-recorded the contacts integration fixtures since request URLs changed.

Bug Fixes

  • Escapes # in emails so they aren't treated as URL fragments, and / so it stays one path segment.
  • Rejects . and .. path parameters in fetchRequest with an invalid_parameter error without sending a request, closing the path traversal hole where domains.remove('../api-keys/KEY_ID') would hit the delete API key endpoint.

Written for commit 2cea0cb. Summary will update on new commits.

Review in cubic

@max-programming
max-programming requested a review from a team as a code owner September 27, 2026 21:36
@github-actions github-actions Bot added the linear-synced PR has been synced to Linear label Sep 28, 2026
max-programming and others added 3 commits September 29, 2026 13:39
…arameters

# Conflicts:
#	src/api-keys/api-keys.ts
#	src/automation-runs/automation-runs.ts
#	src/automations/automations.ts
#	src/broadcasts/broadcasts.ts
#	src/contact-properties/contact-properties.ts
#	src/contacts/contacts.ts
#	src/contacts/imports/contact-imports.ts
#	src/contacts/segments/contact-segments.ts
#	src/domains/claims/domain-claims.ts
#	src/domains/domains.ts
#	src/emails/attachments/attachments.ts
#	src/emails/emails.ts
#	src/emails/receiving/attachments/attachments.ts
#	src/emails/receiving/receiving.ts
#	src/events/events.ts
#	src/logs/logs.ts
#	src/oauth-grants/oauth-grants.ts
#	src/resend.spec.ts
#	src/resend.ts
#	src/segments/segments.ts
#	src/suppressions/suppressions.ts
#	src/templates/templates.ts
#	src/topics/topics.ts
#	src/webhooks/events/events.ts
#	src/webhooks/webhooks.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

linear-synced PR has been synced to Linear

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant