Skip to content

fix: encode non-ASCII characters before redirecting#16065

Draft
teemingc wants to merge 15 commits into
mainfrom
fix-non-ascii-redirects
Draft

fix: encode non-ASCII characters before redirecting#16065
teemingc wants to merge 15 commits into
mainfrom
fix-non-ascii-redirects

Conversation

@teemingc

@teemingc teemingc commented Jun 17, 2026

Copy link
Copy Markdown
Member

closes #16054

This PR calls encodeURIComponent for non-ASCII characters before redirecting. Not sure if this is the best solution as I used Opus for this. Tests seem useful though.


Please don't delete this checklist! Before submitting the PR, please make sure you do the following:

  • It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.

Tests

  • Run the tests with pnpm test and lint the project with pnpm lint and pnpm check

Changesets

  • If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running pnpm changeset and following the prompts. Changesets that add features should be minor and those that fix bugs should be patch. Please prefix changeset messages with feat:, fix:, or chore:.

Edits

  • Please ensure that 'Allow edits from maintainers' is checked. PRs without this option may be closed.

@changeset-bot

changeset-bot Bot commented Jun 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 74c6947

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@sveltejs/kit Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@svelte-docs-bot

Copy link
Copy Markdown

Comment thread packages/kit/src/exports/internal/index.js Outdated
Comment thread packages/kit/src/exports/internal/index.js Outdated
vercel Bot and others added 2 commits June 17, 2026 06:38
…the try/catch, so a lone-surrogate location throws an unhelpful `URIError` instead of the intended descriptive error.

This commit fixes the issue reported at packages/kit/src/exports/internal/index.js:31

## Bug

In `Redirect`'s constructor, the location encoding step ran *before* the `try` block:

```js
location = location.replace(/[^\u0020-\u007E\t\n\r]+/g, (match) => encodeURIComponent(match));

try {
    new Headers({ location });
} catch {
    throw new Error(`Invalid redirect location ...`);
}
```

`encodeURIComponent` throws `URIError: URI malformed` when given a string containing a lone/unpaired surrogate. This was verified:

```
$ node -e "'/\uD800'.replace(/[^\u0020-\u007E\t\n\r]+/g, m=>encodeURIComponent(m))"
URIError: URI malformed
```

**Trigger:** `redirect(303, '/\uD800')` (any redirect location containing an unpaired surrogate).

Because the `.replace()` call happens *outside* the `try`, the raw `URIError` escapes to the caller. Previously, such input flowed into `new Headers({ location })`, which threw a `TypeError` that the `catch` converted into the descriptive `Invalid redirect location ...: this string contains characters that cannot be used in HTTP headers` error. So this is a regression in error quality for malformed/unpaired-surrogate inputs.

## Fix

Moved the `.replace()`/`encodeURIComponent` call *inside* the existing `try` block. Now any `URIError` from malformed surrogates is caught and converted into the intended descriptive error, restoring the previous contract.


Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: teemingc <chewteeming01@gmail.com>
@hyunbinseo

Copy link
Copy Markdown
Contributor

Doesn't redirect allow any URL or string?

import { redirect } from '@sveltejs/kit';

export const load = () => {
  redirect(303, 'https://github.com/hyunbinseo/'); // works
  redirect(303, 'https://내도메인.한국/'); // fails
  redirect(303, new URL('https://내도메인.한국/')); // works
};

Wouldn't it be safer to use new URL() constructor every time to support puny codes as well?

@teemingc

Copy link
Copy Markdown
Member Author

Yeah, good idea. Let's try that

@teemingc teemingc marked this pull request as draft June 17, 2026 12:25
@teemingc

teemingc commented Jun 17, 2026

Copy link
Copy Markdown
Member Author

I'm trying to do something like:

	// encode non-ASCII characters
	if (typeof location === 'string') {
		const event = getRequestEvent();
		// location could be a pathname so we need to add a base URL
		location = new URL(location, event.request.url);
	}

But having getRequestEvent makes it a pain to unit test since we need to mock it

constructor(status, location) {
try {
// Encode non-ASCII characters so the location is valid in HTTP headers
location = location.replace(/[^\u0020-\u007E\t\n\r]+/g, (match) => encodeURIComponent(match));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is there a good reason not to just use encodeURI on the whole location?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

const str = 'https://내도메인.한국/';
new URL(str).href; // "https://xn--220b31d95hq8o.xn--3e0b707e/"
encodeURI(str); // "https://%EB%82%B4%EB%8F%84%EB%A9%94%EC%9D%B8.%ED%95%9C%EA%B5%AD/"

The resulting string doesn't use puny code, but I can access it in desktop Firefox and Chrome.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yeah I don't think we have to use punycode, it should work fine... it also should work okay for locations that are just pathnames and not full URLs as well.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Notably new URL on the output string of encodeURI parses it down to the punycode version

@teemingc teemingc Jun 18, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Is there a good reason not to just use encodeURI on the whole location?

There's the double encoding of % but we can probably workaround it with a similar solution to our decoding utility.

/**
 * Decode pathname excluding %25 to prevent further double decoding of params
 * @param {string} pathname
 */
export function decode_pathname(pathname) {
	return pathname.split('%25').map(decodeURI).join('%25');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actually I did some work on this today and I think your current solution is close to right -- will push up a couple of commits with tests tomorrow

constructor(status, location) {
try {
// Encode non-ASCII characters so that the location is valid in HTTP headers
if (!location.match(/[\r\n]/)) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@elliott-with-the-longest-name-on-github is this necessary? Otherwise, I can't seem to get make redirect throw an "invalid character" error because they always get URL encoded

@elliott-with-the-longest-name-on-github elliott-with-the-longest-name-on-github Jun 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

So I'm pretty sure we can do away with this error. We originally put it in place because we wanted "no characters that are invalid in headers", but if we encode them, they're valid in headers (and don't cause the security issue we had before, where newlines could inject set-cookie headers)

Comment thread packages/kit/src/exports/internal/index.js
@teemingc teemingc marked this pull request as ready for review June 18, 2026 02:40
Comment thread packages/kit/src/exports/internal/index.js Outdated
if (!location.match(/[\r\n]/)) {
location = location.startsWith('/')
? encode_pathname(location)
: new URL(location).toString();

@vercel vercel Bot Jun 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

new URL(location).toString() re-normalizes/re-encodes absolute redirect locations, breaking the existing escapes characters in redirect prerender test and silently altering redirect targets.

Fix on Vercel

@teemingc teemingc marked this pull request as draft June 18, 2026 02:57
Comment thread packages/kit/src/exports/internal/index.js Outdated
@teemingc

Copy link
Copy Markdown
Member Author

Looks like we purposely removed this in the past #3404 wonder if that's a case against this. The docs from that PR were also lost in time and should maybe be added back https://github.com/sveltejs/kit/pull/3404/changes#diff-820e98511cb87c8891f398a00ff2f70793de22e53396d0a3ac6bc2cd6cc3e56eR146

@elliott-with-the-longest-name-on-github

Copy link
Copy Markdown
Contributor

Re: above, the reason for this was double encoding, so if we can avoid that somehow... 🤔

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.

redirect should handle non-ASCII characters

3 participants