From efb8cafb069a0401512bf3451c881bc9b61e9c8e Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Fri, 15 Mar 2024 14:21:05 +0100 Subject: [PATCH] chore: copy redirect + tests to new api (#53999) Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com> --- api/src/routes/deprecated-unsubscribe.test.ts | 4 +- api/src/routes/deprecated-unsubscribe.ts | 5 +- api/src/utils/allowed-origins.ts | 9 + api/src/utils/redirection.test.ts | 225 ++++++++++++++++++ api/src/utils/redirection.ts | 139 +++++++++++ 5 files changed, 377 insertions(+), 5 deletions(-) create mode 100644 api/src/utils/allowed-origins.ts create mode 100644 api/src/utils/redirection.test.ts create mode 100644 api/src/utils/redirection.ts diff --git a/api/src/routes/deprecated-unsubscribe.test.ts b/api/src/routes/deprecated-unsubscribe.test.ts index 85ab238e562..c37e2b6009a 100644 --- a/api/src/routes/deprecated-unsubscribe.test.ts +++ b/api/src/routes/deprecated-unsubscribe.test.ts @@ -9,7 +9,7 @@ describe('Deprecated unsubscribeEndpoints', () => { setupServer(); unsubscribeEndpoints.forEach(([endpoint, method]) => { - test(`${method} ${endpoint} redirects to referer with "info" message`, async () => { + test(`${method} ${endpoint} redirects to origin with "info" message`, async () => { const response = await superRequest(endpoint, { method }).set( 'Referer', 'https://www.freecodecamp.org/settings' @@ -17,7 +17,7 @@ describe('Deprecated unsubscribeEndpoints', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access expect(response.headers.location).toStrictEqual( - 'https://www.freecodecamp.org/settings' + urlEncodedMessage + 'https://www.freecodecamp.org' + urlEncodedMessage ); expect(response.status).toBe(302); }); diff --git a/api/src/routes/deprecated-unsubscribe.ts b/api/src/routes/deprecated-unsubscribe.ts index 35eb0b27b8e..e4a2693a8b2 100644 --- a/api/src/routes/deprecated-unsubscribe.ts +++ b/api/src/routes/deprecated-unsubscribe.ts @@ -1,6 +1,6 @@ import { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; -import { HOME_LOCATION } from '../utils/env'; +import { getRedirectParams } from '../utils/redirection'; type Endpoint = [string, 'GET' | 'POST']; @@ -28,8 +28,7 @@ export const unsubscribeDeprecated: FastifyPluginCallbackTypebox = ( method, url: endpoint, handler: async (req, reply) => { - // TODO: port over getRedirectParams from api-server anduser that - const origin = req.headers.referer ?? HOME_LOCATION; + const { origin } = getRedirectParams(req); void reply.redirectWithMessage(origin, { type: 'info', content: diff --git a/api/src/utils/allowed-origins.ts b/api/src/utils/allowed-origins.ts new file mode 100644 index 00000000000..cc5b86e1fc5 --- /dev/null +++ b/api/src/utils/allowed-origins.ts @@ -0,0 +1,9 @@ +export const allowedOrigins = [ + 'https://www.freecodecamp.dev', + 'https://www.freecodecamp.org', + // pretty sure the rest of these can go? + 'https://beta.freecodecamp.dev', + 'https://beta.freecodecamp.org', + 'https://chinese.freecodecamp.dev', + 'https://chinese.freecodecamp.org' +]; diff --git a/api/src/utils/redirection.test.ts b/api/src/utils/redirection.test.ts new file mode 100644 index 00000000000..0bab415f590 --- /dev/null +++ b/api/src/utils/redirection.test.ts @@ -0,0 +1,225 @@ +import { FastifyRequest } from 'fastify'; +import jwt from 'jsonwebtoken'; + +import { + getReturnTo, + normalizeParams, + getRedirectParams, + getPrefixedLandingPath +} from './redirection'; +import { HOME_LOCATION } from './env'; + +const validJWTSecret = 'this is a super secret string'; +const invalidJWTSecret = 'This is not correct secret'; +const validReturnTo = 'https://www.freecodecamp.org/settings'; +const invalidReturnTo = 'https://www.freecodecamp.org.fake/settings'; +const defaultReturnTo = 'https://www.freecodecamp.org/learn'; +const defaultOrigin = 'https://www.freecodecamp.org'; +const defaultPrefix = ''; + +const defaultObject = { + returnTo: defaultReturnTo, + origin: defaultOrigin, + pathPrefix: defaultPrefix +}; + +// TODO: tidy this up (the mocking is a bit of a mess) +describe('redirection', () => { + describe('getReturnTo', () => { + it('should extract returnTo from a jwt', () => { + expect.assertions(1); + + const encryptedReturnTo = jwt.sign( + { returnTo: validReturnTo, origin: defaultOrigin }, + validJWTSecret + ); + expect( + getReturnTo(encryptedReturnTo, validJWTSecret, defaultOrigin) + ).toStrictEqual({ + ...defaultObject, + returnTo: validReturnTo + }); + }); + + it('should return a default url if the secrets do not match', () => { + const oldLog = console.log; + expect.assertions(2); + console.log = jest.fn(); + const encryptedReturnTo = jwt.sign( + { returnTo: validReturnTo }, + invalidJWTSecret + ); + expect( + getReturnTo(encryptedReturnTo, validJWTSecret, defaultOrigin) + ).toStrictEqual(defaultObject); + expect(console.log).toHaveBeenCalled(); + console.log = oldLog; + }); + + it('should return a default url for unknown origins', () => { + expect.assertions(1); + const encryptedReturnTo = jwt.sign( + { returnTo: invalidReturnTo }, + validJWTSecret + ); + expect( + getReturnTo(encryptedReturnTo, validJWTSecret, defaultOrigin) + ).toStrictEqual(defaultObject); + }); + }); + describe('normalizeParams', () => { + it('should return a {returnTo, origin, pathPrefix} object', () => { + expect.assertions(2); + const keys = Object.keys(normalizeParams({})); + const expectedKeys = ['returnTo', 'origin', 'pathPrefix']; + expect(keys.length).toBe(3); + expect(keys).toEqual(expect.arrayContaining(expectedKeys)); + }); + it('should default to process.env.HOME_LOCATION', () => { + expect.assertions(1); + expect(normalizeParams({}, defaultOrigin)).toEqual(defaultObject); + }); + it('should convert an unknown pathPrefix to ""', () => { + expect.assertions(1); + const brokenPrefix = { + ...defaultObject, + pathPrefix: 'not-really-a-name' + }; + expect(normalizeParams(brokenPrefix, defaultOrigin)).toEqual( + defaultObject + ); + }); + it('should not change a known pathPrefix', () => { + expect.assertions(1); + const spanishPrefix = { + ...defaultObject, + pathPrefix: 'espanol' + }; + expect(normalizeParams(spanishPrefix, defaultOrigin)).toEqual( + spanishPrefix + ); + }); + // we *could*, in principle, grab the path and send them to + // process.env.HOME_LOCATION/path, but if the origin is wrong something unexpected is + // going on. In that case it's probably best to just send them to + // process.env.HOME_LOCATION/learn. + it('should return default parameters if the origin is unknown', () => { + expect.assertions(1); + const exampleOrigin = { + ...defaultObject, + origin: 'http://example.com', + pathPrefix: 'espanol' + }; + expect(normalizeParams(exampleOrigin, defaultOrigin)).toEqual( + defaultObject + ); + }); + it('should return default parameters if the returnTo is unknown', () => { + expect.assertions(1); + const exampleReturnTo = { + ...defaultObject, + returnTo: 'http://example.com/path', + pathPrefix: 'espanol' + }; + expect(normalizeParams(exampleReturnTo, defaultOrigin)).toEqual( + defaultObject + ); + }); + + it('should reject returnTo without trailing slashes', () => { + const exampleReturnTo = { + ...defaultObject, + returnTo: 'https://www.freecodecamp.dev' + }; + expect(normalizeParams(exampleReturnTo, defaultOrigin)).toEqual( + defaultObject + ); + }); + + it('should not modify the returnTo if it is valid', () => { + const exampleReturnTo = { + ...defaultObject, + returnTo: 'https://www.freecodecamp.dev/' + }; + expect(normalizeParams(exampleReturnTo, defaultOrigin)).toEqual( + exampleReturnTo + ); + }); + }); + + describe('getRedirectParams', () => { + it('should decorate the request object', () => { + const req: FastifyRequest = { + headers: { + referer: `https://www.freecodecamp.org/espanol/learn/rosetta-code/` + } + } as FastifyRequest; + + const expectedReturn = { + origin: 'https://www.freecodecamp.org', + pathPrefix: 'espanol', + returnTo: 'https://www.freecodecamp.org/espanol/learn/rosetta-code/' + }; + + const result = getRedirectParams(req); + expect(result).toEqual(expectedReturn); + }); + + it('should use HOME_LOCATION with missing referer', () => { + const req: FastifyRequest = { + headers: {} + } as FastifyRequest; + + const expectedReturn = { + returnTo: `${HOME_LOCATION}/learn`, + origin: HOME_LOCATION, + pathPrefix: '' + }; + + const result = getRedirectParams(req); + expect(result).toEqual(expectedReturn); + }); + + it('should use HOME_LOCATION with invalid referrer', () => { + const req: FastifyRequest = { + headers: { + referer: 'invalid-url' + } + } as FastifyRequest; + + const expectedReturn = { + returnTo: `${HOME_LOCATION}/learn`, + origin: HOME_LOCATION, + pathPrefix: '' + }; + + const result = getRedirectParams(req); + expect(result).toEqual(expectedReturn); + }); + }); + + describe('getPrefixedLandingPath', () => { + it('should return the origin when no pathPrefix is provided', () => { + const result = getPrefixedLandingPath(defaultOrigin); + expect(result).toEqual(defaultOrigin); + }); + + it('should append pathPrefix to origin when pathPrefix is provided', () => { + const expectedPath = `${defaultOrigin}/learn`; + const result = getPrefixedLandingPath(defaultOrigin, 'learn'); + expect(result).toEqual(expectedPath); + }); + + it('should handle empty origin', () => { + const pathPrefix = 'learn'; + const expectedPath = '/learn'; + const result = getPrefixedLandingPath('', pathPrefix); + expect(result).toEqual(expectedPath); + }); + + it('should handle empty pathPrefix', () => { + const result = getPrefixedLandingPath(defaultOrigin, ''); + expect(result).toEqual(defaultOrigin); + }); + }); +}); diff --git a/api/src/utils/redirection.ts b/api/src/utils/redirection.ts new file mode 100644 index 00000000000..5328a909596 --- /dev/null +++ b/api/src/utils/redirection.ts @@ -0,0 +1,139 @@ +import { FastifyRequest } from 'fastify'; +import jwt from 'jsonwebtoken'; + +// import { allowedOrigins } from '../../config/allowed-origins'; +import { availableLangs } from '../../../shared/config/i18n'; +import { allowedOrigins } from './allowed-origins'; + +// process.env.HOME_LOCATION is being used as a fallback here. If the one +// provided by the client is invalid we default to this. +import { HOME_LOCATION } from './env'; + +/** + * Get the returnTo value. + * + * @param encryptedParams - The encrypted parameters. + * @param secret - The secret key. + * @param _homeLocation - The home location. + * @returns The returnTo value. + */ +export function getReturnTo( + encryptedParams: string, + secret: jwt.Secret, + _homeLocation = process.env.HOME_LOCATION +) { + let params; + try { + params = jwt.verify(encryptedParams, secret); + } catch (e) { + // TODO: report to Sentry? Probably not. Remove entirely? + console.log(e); + // something went wrong, use default params + params = { + returnTo: `${_homeLocation}/learn`, + origin: _homeLocation, + pathPrefix: '' + }; + } + + // @ts-expect-error - I'm working on it... + return normalizeParams(params, _homeLocation); +} + +/** + * Normalize the parameters, making they're valid. + * + * @param arg - The parameters to normalize. + * @param arg.returnTo - The returnTo value. + * @param arg.origin - The origin value. + * @param arg.pathPrefix - The pathPrefix value. + * @param _homeLocation - The home location. + * @returns The normalized parameters. + */ +export function normalizeParams( + { + returnTo, + origin, + pathPrefix + }: { returnTo?: string; origin?: string; pathPrefix?: string }, + _homeLocation = HOME_LOCATION +) { + // coerce to strings, just in case something weird and nefarious is happening + // TODO: validate, don't coerce + returnTo = '' + returnTo; + origin = '' + origin; + pathPrefix = '' + pathPrefix; + // we add the '/' to prevent returns to + // www.freecodecamp.org.somewhere.else.com + if ( + !returnTo || + !allowedOrigins.some(allowed => returnTo?.startsWith(allowed + '/')) + ) { + returnTo = `${_homeLocation}/learn`; + origin = _homeLocation; + pathPrefix = ''; + } + if (!origin || !allowedOrigins.includes(origin)) { + returnTo = `${_homeLocation}/learn`; + origin = _homeLocation; + pathPrefix = ''; + } + pathPrefix = availableLangs.client.includes(pathPrefix) ? pathPrefix : ''; + return { returnTo, origin, pathPrefix }; +} + +/** + * Get the prefixed landing path. + * + * @param origin - The origin value. + * @param pathPrefix - The pathPrefix value. + * @returns The prefixed landing path. + */ +export function getPrefixedLandingPath(origin: string, pathPrefix?: string) { + const redirectPathSegment = pathPrefix ? `/${pathPrefix}` : ''; + return `${origin}${redirectPathSegment}`; +} + +/** + * Get the redirect parameters. + * + * @param req - A fastify Request. + * @param _normalizeParams - The function to normalize the parameters. + * @returns The redirect parameters. + */ +export function getRedirectParams( + req: FastifyRequest, + _normalizeParams = normalizeParams +) { + const url = req.headers['referer']; + // since we do not always redirect the user back to the page they were on + // we need client locale and origin to construct the redirect url. + let returnUrl; + try { + returnUrl = new URL(url ? url : HOME_LOCATION); + } catch (e) { + returnUrl = new URL(HOME_LOCATION); + } + + const origin = returnUrl.origin; + // if this is not one of the client languages, validation will convert + // this to '' before it is used. + const pathPrefix = returnUrl.pathname.split('/')[1] ?? ''; + return _normalizeParams({ returnTo: returnUrl.href, origin, pathPrefix }); +} + +/** + * Check if the redirect base and return URL have the same path. + * + * @param redirectBase - The redirect base URL. + * @param returnUrl - The return URL. + * @returns A boolean indicating whether the paths are the same. + */ +export function haveSamePath( + redirectBase: string | URL, + returnUrl: string | URL +) { + const base = new URL(redirectBase); + const url = new URL(returnUrl); + return base.pathname === url.pathname; +}