From 8591cbe2daf943691f9b1d5e1a95d4de5e9e417c Mon Sep 17 00:00:00 2001 From: Ole-Martin Bratteng <1681525+omBratteng@users.noreply.github.com> Date: Thu, 20 Nov 2025 13:09:52 +0100 Subject: [PATCH 1/3] fix: it should handle already encoded parts in the URL when redirecting --- __tests__/redirector.ts | 16 ++++++++++++++++ package.json | 2 ++ pnpm-lock.yaml | 17 +++++++++++++++++ src/routes/redirector.ts | 3 ++- 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/__tests__/redirector.ts b/__tests__/redirector.ts index e0d3701785..0ee1f5377e 100644 --- a/__tests__/redirector.ts +++ b/__tests__/redirector.ts @@ -105,6 +105,22 @@ describe('GET /r/:postId', () => { .expect(302) .expect('Location', 'http://localhost:5002/posts/p1-p1'); }); + + it('should not escape already encoded URL', async () => { + await con + .getRepository(ArticlePost) + .update( + { id: 'p1' }, + { url: 'http://p1.com/hello%world/%f0%9f%9a%80-to-the-🌔' }, + ); + return request(app.server) + .get('/r/p1') + .expect(302) + .expect( + 'Location', + 'http://p1.com/hello%world/%f0%9f%9a%80-to-the-%F0%9F%8C%94?ref=dailydev', + ); + }); }); describe('GET /:id/profile-image', () => { diff --git a/package.json b/package.json index 9f19f78807..5ebe8752a5 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "deepmerge": "^4.3.1", "dotenv": "17.2.2", "emoji-regex": "^10.5.0", + "encodeurl": "^2.0.0", "eventsource": "^2.0.2", "fast-json-stringify": "^6.0.1", "fastify": "^5.6.0", @@ -148,6 +149,7 @@ "@faker-js/faker": "^9.8.0", "@fastify/static": "^8.2.0", "@swc/core": "^1.13.5", + "@types/encodeurl": "^1.0.3", "@types/express": "^5.0.3", "@types/humanize-duration": "^3.27.4", "@types/jest": "^29.5.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bc518ae72..b78ff04485 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -214,6 +214,9 @@ importers: emoji-regex: specifier: ^10.5.0 version: 10.5.0 + encodeurl: + specifier: ^2.0.0 + version: 2.0.0 eventsource: specifier: ^2.0.2 version: 2.0.2 @@ -365,6 +368,9 @@ importers: '@swc/core': specifier: ^1.13.5 version: 1.13.5 + '@types/encodeurl': + specifier: ^1.0.3 + version: 1.0.3 '@types/express': specifier: ^5.0.3 version: 5.0.3 @@ -1556,6 +1562,9 @@ packages: '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/encodeurl@1.0.3': + resolution: {integrity: sha512-s10aRcePnVw+iUr5GtTQdzf1GC2jQqkLef2bBUFSX3E52RYnuQ4a7KFHifCFdi4QECiSbyFj7eO9CvZeCnvVkA==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -2468,6 +2477,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} @@ -6419,6 +6432,8 @@ snapshots: '@types/cookiejar@2.1.5': {} + '@types/encodeurl@1.0.3': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -7359,6 +7374,8 @@ snapshots: emoji-regex@9.2.2: {} + encodeurl@2.0.0: {} + end-of-stream@1.4.4: dependencies: once: 1.4.0 diff --git a/src/routes/redirector.ts b/src/routes/redirector.ts index c261792485..3f971316e1 100644 --- a/src/routes/redirector.ts +++ b/src/routes/redirector.ts @@ -1,5 +1,6 @@ import { URL } from 'url'; import { FastifyInstance } from 'fastify'; +import encodeurl from 'encodeurl'; import { ArticlePost, Post } from '../entity'; import { getDiscussionLink, notifyView } from '../common'; import createOrGetConnection from '../db'; @@ -35,7 +36,7 @@ export default async function (fastify: FastifyInstance): Promise { } const url = new URL(post.url); url.searchParams.append('ref', 'dailydev'); - const encodedUri = encodeURI(url.href); + const encodedUri = encodeurl(url.href); if (req.isBot) { return res.status(302).redirect(encodedUri); } From c596768d0f7815540ee5e45792a0035a8bb050ec Mon Sep 17 00:00:00 2001 From: Ole-Martin Bratteng <1681525+omBratteng@users.noreply.github.com> Date: Thu, 20 Nov 2025 13:12:28 +0100 Subject: [PATCH 2/3] fix: space should be `%20` --- __tests__/redirector.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/__tests__/redirector.ts b/__tests__/redirector.ts index 0ee1f5377e..4a668d0ff6 100644 --- a/__tests__/redirector.ts +++ b/__tests__/redirector.ts @@ -111,14 +111,14 @@ describe('GET /r/:postId', () => { .getRepository(ArticlePost) .update( { id: 'p1' }, - { url: 'http://p1.com/hello%world/%f0%9f%9a%80-to-the-🌔' }, + { url: 'http://p1.com/hello%20world/%f0%9f%9a%80-to-the-🌔' }, ); return request(app.server) .get('/r/p1') .expect(302) .expect( 'Location', - 'http://p1.com/hello%world/%f0%9f%9a%80-to-the-%F0%9F%8C%94?ref=dailydev', + 'http://p1.com/hello%20world/%f0%9f%9a%80-to-the-%F0%9F%8C%94?ref=dailydev', ); }); }); From ca7f97c865f9898ab555bbeb67c9ca5b9ba9b497 Mon Sep 17 00:00:00 2001 From: Ole-Martin Bratteng <1681525+omBratteng@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:04:34 +0100 Subject: [PATCH 3/3] fix: copy encodeurl into utils --- package.json | 2 -- pnpm-lock.yaml | 17 ------------- src/common/encodeurl.ts | 52 ++++++++++++++++++++++++++++++++++++++++ src/routes/redirector.ts | 4 ++-- 4 files changed, 54 insertions(+), 21 deletions(-) create mode 100644 src/common/encodeurl.ts diff --git a/package.json b/package.json index 5ebe8752a5..9f19f78807 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,6 @@ "deepmerge": "^4.3.1", "dotenv": "17.2.2", "emoji-regex": "^10.5.0", - "encodeurl": "^2.0.0", "eventsource": "^2.0.2", "fast-json-stringify": "^6.0.1", "fastify": "^5.6.0", @@ -149,7 +148,6 @@ "@faker-js/faker": "^9.8.0", "@fastify/static": "^8.2.0", "@swc/core": "^1.13.5", - "@types/encodeurl": "^1.0.3", "@types/express": "^5.0.3", "@types/humanize-duration": "^3.27.4", "@types/jest": "^29.5.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b78ff04485..2bc518ae72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -214,9 +214,6 @@ importers: emoji-regex: specifier: ^10.5.0 version: 10.5.0 - encodeurl: - specifier: ^2.0.0 - version: 2.0.0 eventsource: specifier: ^2.0.2 version: 2.0.2 @@ -368,9 +365,6 @@ importers: '@swc/core': specifier: ^1.13.5 version: 1.13.5 - '@types/encodeurl': - specifier: ^1.0.3 - version: 1.0.3 '@types/express': specifier: ^5.0.3 version: 5.0.3 @@ -1562,9 +1556,6 @@ packages: '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} - '@types/encodeurl@1.0.3': - resolution: {integrity: sha512-s10aRcePnVw+iUr5GtTQdzf1GC2jQqkLef2bBUFSX3E52RYnuQ4a7KFHifCFdi4QECiSbyFj7eO9CvZeCnvVkA==} - '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -2477,10 +2468,6 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} - end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} @@ -6432,8 +6419,6 @@ snapshots: '@types/cookiejar@2.1.5': {} - '@types/encodeurl@1.0.3': {} - '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -7374,8 +7359,6 @@ snapshots: emoji-regex@9.2.2: {} - encodeurl@2.0.0: {} - end-of-stream@1.4.4: dependencies: once: 1.4.0 diff --git a/src/common/encodeurl.ts b/src/common/encodeurl.ts new file mode 100644 index 0000000000..c8cb42811f --- /dev/null +++ b/src/common/encodeurl.ts @@ -0,0 +1,52 @@ +/*! + * encodeurl + * Copyright(c) 2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/** + * RegExp to match non-URL code points, *after* encoding (i.e. not including "%") + * and including invalid escape sequences. + * @private + */ + +const ENCODE_CHARS_REGEXP = + /(?:[^\x21\x23-\x3B\x3D\x3F-\x5F\x61-\x7A\x7C\x7E]|%(?:[^0-9A-Fa-f]|[0-9A-Fa-f][^0-9A-Fa-f]|$))+/g; + +/** + * RegExp to match unmatched surrogate pair. + * @private + */ + +const UNMATCHED_SURROGATE_PAIR_REGEXP = + /(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uD800-\uDBFF]([^\uDC00-\uDFFF]|$)/g; + +/** + * String to replace unmatched surrogate pair with. + * @private + */ + +const UNMATCHED_SURROGATE_PAIR_REPLACE = '$1\uFFFD$2'; + +/** + * Encode a URL to a percent-encoded form, excluding already-encoded sequences. + * + * This function will take an already-encoded URL and encode all the non-URL + * code points. This function will not encode the "%" character unless it is + * not part of a valid sequence (`%20` will be left as-is, but `%foo` will + * be encoded as `%25foo`). + * + * This encode is meant to be "safe" and does not throw errors. It will try as + * hard as it can to properly encode the given URL, including replacing any raw, + * unpaired surrogate pairs with the Unicode replacement character prior to + * encoding. + * + * @param {string} url + * @return {string} + * @public + */ + +export const encodeUrl = (url: string): string => + String(url) + .replace(UNMATCHED_SURROGATE_PAIR_REGEXP, UNMATCHED_SURROGATE_PAIR_REPLACE) + .replace(ENCODE_CHARS_REGEXP, encodeURI); diff --git a/src/routes/redirector.ts b/src/routes/redirector.ts index 3f971316e1..191b59c0e6 100644 --- a/src/routes/redirector.ts +++ b/src/routes/redirector.ts @@ -1,9 +1,9 @@ import { URL } from 'url'; import { FastifyInstance } from 'fastify'; -import encodeurl from 'encodeurl'; import { ArticlePost, Post } from '../entity'; import { getDiscussionLink, notifyView } from '../common'; import createOrGetConnection from '../db'; +import { encodeUrl } from '../common/encodeurl'; export default async function (fastify: FastifyInstance): Promise { fastify.get<{ Params: { postId: string }; Querystring: { a?: string } }>( @@ -36,7 +36,7 @@ export default async function (fastify: FastifyInstance): Promise { } const url = new URL(post.url); url.searchParams.append('ref', 'dailydev'); - const encodedUri = encodeurl(url.href); + const encodedUri = encodeUrl(url.href); if (req.isBot) { return res.status(302).redirect(encodedUri); }