diff --git a/api/src/routes/settings.test.ts b/api/src/routes/settings.test.ts index 12603ed0bd7..111e989392e 100644 --- a/api/src/routes/settings.test.ts +++ b/api/src/routes/settings.test.ts @@ -11,7 +11,11 @@ import { import { formatMessage } from '../plugins/redirect-with-message'; import { createUserInput } from '../utils/create-user'; import { API_LOCATION, HOME_LOCATION } from '../utils/env'; -import { isPictureWithProtocol, getWaitMessage } from './settings'; +import { + isPictureWithProtocol, + getWaitMessage, + validateSocialUrl +} from './settings'; const baseProfileUI = { isLocked: false, @@ -827,12 +831,24 @@ Happy coding! expect(response.statusCode).toEqual(200); }); - test('PUT returns 400 status code with invalid socials setting', async () => { + test('PUT rejects non-url values', async () => { const response = await superPut('/update-my-socials').send({ website: 'invalid', twitter: '', linkedin: '', - githubProfile: 'invalid' + githubProfile: '' + }); + + expect(response.body).toEqual(updateErrorResponse); + expect(response.statusCode).toEqual(400); + }); + + test('PUT only accepts urls to certain domains', async () => { + const response = await superPut('/update-my-socials').send({ + website: '', + twitter: '', + linkedin: '', + githubProfile: 'https://x.com/should-be-github' }); expect(response.body).toEqual(updateErrorResponse); @@ -1122,3 +1138,29 @@ describe('getWaitMessage', () => { ); }); }); + +describe('validateSocialUrl', () => { + it.each(['githubProfile', 'linkedin', 'twitter'] as const)( + 'accepts empty strings for %s', + social => { + expect(validateSocialUrl('', social)).toBe(true); + } + ); + + it.each([ + ['githubProfile', 'https://something.com/user'], + ['linkedin', 'https://www.x.com/in/username'], + ['twitter', 'https://www.toomanyexes.com/username'] + ] as const)('rejects invalid urls for %s', (social, url) => { + expect(validateSocialUrl(url, social)).toBe(false); + }); + + it.each([ + ['githubProfile', 'https://something.github.com/user'], + ['linkedin', 'https://www.linkedin.com/in/username'], + ['twitter', 'https://twitter.com/username'], + ['twitter', 'https://x.com/username'] + ] as const)('accepts valid urls for %s', (social, url) => { + expect(validateSocialUrl(url, social)).toBe(true); + }); +}); diff --git a/api/src/routes/settings.ts b/api/src/routes/settings.ts index fd663e434ee..0b575d9e128 100644 --- a/api/src/routes/settings.ts +++ b/api/src/routes/settings.ts @@ -69,6 +69,35 @@ export const isPictureWithProtocol = (picture?: string): boolean => { } }; +const ALLOWED_DOMAINS_MAP = { + githubProfile: ['github.com'], + linkedin: ['linkedin.com'], + twitter: ['twitter.com', 'x.com'] +}; + +/** + * Validate a social URL. + * + * @param socialUrl The URL to check. + * @param key The key of the allowed socials and domains. + * @returns Whether the URL is valid. + */ +export const validateSocialUrl = ( + socialUrl: string, + key: keyof typeof ALLOWED_DOMAINS_MAP +): boolean => { + if (!socialUrl) return true; + + try { + const url = new URL(socialUrl); + const domains = ALLOWED_DOMAINS_MAP[key]; + const domainAndTld = url.hostname.split('.').slice(-2).join('.'); + return domains.includes(domainAndTld); + } catch { + return false; + } +}; + /** * Plugin for all endpoints related to user settings. * @@ -328,6 +357,18 @@ ${isLinkSentWithinLimitTTL}` errorHandler: updateErrorHandler }, async (req, reply) => { + const valid = (['twitter', 'githubProfile', 'linkedin'] as const).every( + key => validateSocialUrl(req.body[key], key) + ); + + if (!valid) { + void reply.code(400); + return reply.send({ + message: 'flash.wrong-updating', + type: 'danger' + }); + } + try { await fastify.prisma.user.update({ where: { id: req.user?.id },