diff --git a/api/src/plugins/cookie-update.test.ts b/api/src/plugins/cookie-update.test.ts new file mode 100644 index 00000000000..dac6ee88ff4 --- /dev/null +++ b/api/src/plugins/cookie-update.test.ts @@ -0,0 +1,113 @@ +import Fastify, { FastifyInstance } from 'fastify'; +import cookies, { type CookieSerializeOptions, sign } from './cookies'; +import { cookieUpdate } from './cookie-update'; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-return +jest.mock('../utils/env', () => ({ + ...jest.requireActual('../utils/env'), + COOKIE_DOMAIN: 'www.example.com', + FREECODECAMP_NODE_ENV: 'not-development' +})); + +describe('Cookie updates', () => { + let fastify: FastifyInstance; + + const setup = async (attributes: CookieSerializeOptions) => { + // Since register creates a new scope, we need to create a route inside the + // scope for the plugin to be applied. + await fastify.register(cookieUpdate, fastify => { + // eslint-disable-next-line @typescript-eslint/require-await + fastify.get('/', async () => { + return { hello: 'world' }; + }); + return { + cookies: ['cookie_name'], + attributes + }; + }); + }; + + beforeEach(async () => { + fastify = Fastify(); + await fastify.register(cookies); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it('should not set cookies that are not in the request', async () => { + await setup({}); + + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + cookie: 'cookie_name_two=cookie_value' + } + }); + + expect(res.headers['set-cookie']).toBeUndefined(); + }); + + it("should update the cookie's attributes without changing the value", async () => { + await setup({ sameSite: 'strict' }); + const signedCookie = sign('cookie_value'); + const encodedCookie = encodeURIComponent(signedCookie); + + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + cookie: `cookie_name=${signedCookie}` + } + }); + + const updatedCookie = res.headers['set-cookie'] as string; + expect(updatedCookie).toEqual( + expect.stringContaining(`cookie_name=${encodedCookie}`) + ); + expect(updatedCookie).toEqual(expect.stringContaining('SameSite=Strict')); + }); + + it('should unsign the cookie if required', async () => { + await setup({ signed: false }); + const signedCookie = sign('cookie_value'); + + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + cookie: `cookie_name=${signedCookie}` + } + }); + + const updatedCookie = res.headers['set-cookie'] as string; + expect(updatedCookie).toEqual( + expect.stringContaining('cookie_name=cookie_value') + ); + }); + + it('should respect the default cookie config if not overriden', async () => { + await setup({}); + + const res = await fastify.inject({ + method: 'GET', + url: '/', + headers: { + cookie: 'cookie_name=anything' + } + }); + + expect(res.cookies[0]).toStrictEqual({ + domain: 'www.example.com', + httpOnly: true, + name: 'cookie_name', + path: '/', + sameSite: 'Lax', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + value: expect.any(String), + secure: true + }); + }); +}); diff --git a/api/src/plugins/cookie-update.ts b/api/src/plugins/cookie-update.ts new file mode 100644 index 00000000000..65de3f69085 --- /dev/null +++ b/api/src/plugins/cookie-update.ts @@ -0,0 +1,37 @@ +import { FastifyPluginCallback } from 'fastify'; + +import type { CookieSerializeOptions } from './cookies'; + +type Options = { cookies: string[]; attributes: CookieSerializeOptions }; + +/** + * Plugin that updates the attributes of cookies in the response, without + * changing the value. + * + * @param fastify The Fastify instance. + * @param options Options passed to the plugin via `fastify.register(plugin, + * options)`. + * @param options.cookies The names of the cookies to update. + * @param options.attributes The attributes to update the cookies with. NOTE: + * The attributes are merged with the default values given to \@fastify/cookie. + * @param done Callback to signal that the logic has completed. + */ +export const cookieUpdate: FastifyPluginCallback = ( + fastify, + options, + done +) => { + fastify.addHook('onSend', (request, reply, _payload, next) => { + for (const cookie of options.cookies) { + const oldCookie = request.cookies[cookie]; + if (!oldCookie) continue; + + const unsigned = reply.unsignCookie(oldCookie); + const raw = unsigned.valid ? unsigned.value : oldCookie; + void reply.setCookie(cookie, raw, options.attributes); + } + next(); + }); + + done(); +}; diff --git a/api/src/plugins/cookies.ts b/api/src/plugins/cookies.ts index 2161aec8515..5203614c9db 100644 --- a/api/src/plugins/cookies.ts +++ b/api/src/plugins/cookies.ts @@ -8,6 +8,8 @@ import { FREECODECAMP_NODE_ENV } from '../utils/env'; +export { type CookieSerializeOptions } from '@fastify/cookie'; + /** * Signs a cookie value by prefixing it with "s:" and using the COOKIE_SECRET. *