From 4b3fb88849d9ceb4427c66ef2900cdd6099721d0 Mon Sep 17 00:00:00 2001 From: bjarneo Date: Sat, 9 Mar 2024 20:42:24 +0100 Subject: [PATCH] fix: leverage the fastify schema to validate input (#278) * fix: set valid values * fix: use shorthand * chore: refactor to use fastify validations * fix: set lower case restrict org email * chore: update the error message handling * fix: use fastify validation for the request body bonus: fixed how the deletion of files is handled, as currently it did not work as intended * chore: use fastify validation * fix: use fastify validation * fix: use fastify validation --- src/client/routes/account/account.jsx | 7 +- src/client/routes/account/settings.jsx | 2 +- src/client/routes/home/index.jsx | 6 +- src/client/routes/signup/index.jsx | 12 +- src/server/controllers/account.js | 38 ++-- src/server/controllers/admin/settings.js | 22 ++- src/server/controllers/authentication.js | 234 +++++++++++++---------- src/server/controllers/download.js | 53 +++-- src/server/controllers/secret.js | 49 +++-- src/server/helpers/validate-ttl.js | 4 +- 10 files changed, 229 insertions(+), 198 deletions(-) diff --git a/src/client/routes/account/account.jsx b/src/client/routes/account/account.jsx index 8f8153c8..7625ae51 100644 --- a/src/client/routes/account/account.jsx +++ b/src/client/routes/account/account.jsx @@ -73,11 +73,12 @@ const Account = () => { try { const updatedUserInfo = await updateUser(values); - if (updatedUserInfo.error || [401, 500].includes(updatedUserInfo.statusCode)) { + if (updatedUserInfo.error || [400, 401, 500].includes(updatedUserInfo.statusCode)) { setError( - updatedUserInfo.error - ? updatedUserInfo.error + updatedUserInfo.message + ? updatedUserInfo.message : t('account.account.can_not_update_profile') + ); return; diff --git a/src/client/routes/account/settings.jsx b/src/client/routes/account/settings.jsx index f9a2f552..3ac67968 100644 --- a/src/client/routes/account/settings.jsx +++ b/src/client/routes/account/settings.jsx @@ -22,7 +22,7 @@ const Settings = () => { disable_users: false, disable_user_account_creation: false, disable_file_upload: false, - Restrict_organization_email: '', + restrict_organization_email: '', }, }); diff --git a/src/client/routes/home/index.jsx b/src/client/routes/home/index.jsx index 3cd2494d..2fbf582d 100644 --- a/src/client/routes/home/index.jsx +++ b/src/client/routes/home/index.jsx @@ -173,14 +173,14 @@ const Home = () => { const json = await createSecret(body); if (json.statusCode !== 201) { - if (json.statusCode === 403) { - setError(json.error); + if (json.statusCode === 400) { + setError(json.message); } if (json.message === 'request file too large, please check multipart config') { form.setErrors({ files: 'The file size is too large' }); } else { - form.setErrors({ files: json.error }); + form.setErrors({ files: json.message }); } setCreatingSecret(false); diff --git a/src/client/routes/signup/index.jsx b/src/client/routes/signup/index.jsx index f0dd794a..62adffd6 100644 --- a/src/client/routes/signup/index.jsx +++ b/src/client/routes/signup/index.jsx @@ -30,19 +30,19 @@ const SignUp = () => { const onSignUp = async (values) => { const data = await signUp(values.email, values.username, values.password); - if (data.statusCode === 403) { - setError(data.error); + if ([400, 403].indexOf(data.statusCode) > -1) { + setError(data.message); setSuccess(false); return; } - if (data.error) { + if (data.type && data.message) { form.setErrors({ - username: data.type == 'username' ? data.error : '', - password: data.type == 'password' ? data.error : '', - email: data.type == 'email' ? data.error : '', + username: data.type == 'username' ? data.message : '', + password: data.type == 'password' ? data.message : '', + email: data.type == 'email' ? data.message : '', }); setSuccess(false); diff --git a/src/server/controllers/account.js b/src/server/controllers/account.js index b947dd03..35d34650 100644 --- a/src/server/controllers/account.js +++ b/src/server/controllers/account.js @@ -2,8 +2,6 @@ import emailValidator from 'email-validator'; import { compare, hash } from '../helpers/password.js'; import prisma from '../services/prisma.js'; -const PASSWORD_LENGTH = 5; - async function account(fastify) { fastify.get( '/', @@ -29,15 +27,23 @@ async function account(fastify) { '/update', { preValidation: [fastify.authenticate], + schema: { + body: { + type: 'object', + required: ['currentPassword', 'newPassword', 'confirmNewPassword', 'email'], + properties: { + currentPassword: { type: 'string', default: '' }, + newPassword: { type: 'string', maxLength: 50, minLength: 5, default: '' }, + confirmNewPassword: { type: 'string', default: '' }, + email: { type: 'string', default: '' }, + generated: { type: 'boolean', default: false }, + }, + }, + }, }, async (request, reply) => { - const { - currentPassword = '', - newPassword = '', - email = '', - confirmNewPassword = '', - generated = false, - } = request.body; + const { currentPassword, newPassword, email, confirmNewPassword, generated } = + request.body; const data = { generated, @@ -54,13 +60,6 @@ async function account(fastify) { } if (newPassword) { - if (newPassword.length < PASSWORD_LENGTH) { - return reply.code(403).send({ - type: 'newPassword', - error: `Password has to be longer than ${PASSWORD_LENGTH} characters`, - }); - } - data.password = await hash(newPassword); } @@ -75,13 +74,6 @@ async function account(fastify) { data.email = email; } - if (!email && !newPassword) { - return reply.code(412).send({ - type: 'no-data', - error: `Could not update your profile. Please set the fields you want to update.`, - }); - } - if (newPassword !== confirmNewPassword) { return reply.code(400).send({ type: 'confirmNewPassword', diff --git a/src/server/controllers/admin/settings.js b/src/server/controllers/admin/settings.js index 8b3f95fd..e66306d9 100644 --- a/src/server/controllers/admin/settings.js +++ b/src/server/controllers/admin/settings.js @@ -23,14 +23,26 @@ async function settings(fastify) { '/', { preValidation: [fastify.authenticate, fastify.admin], + schema: { + body: { + type: 'object', + properties: { + disable_users: { type: 'boolean', default: false }, + disable_user_account_creation: { type: 'boolean', default: false }, + read_only: { type: 'boolean', default: false }, + disable_file_upload: { type: 'boolean', default: false }, + restrict_organization_email: { type: 'string', default: '' }, + }, + }, + }, }, async (request) => { const { - disable_users = false, - disable_user_account_creation = false, - read_only = false, - disable_file_upload = false, - restrict_organization_email = '', + disable_users, + disable_user_account_creation, + read_only, + disable_file_upload, + restrict_organization_email, } = request.body; const settings = await prisma.settings.upsert({ diff --git a/src/server/controllers/authentication.js b/src/server/controllers/authentication.js index c58d0beb..1fac7358 100644 --- a/src/server/controllers/authentication.js +++ b/src/server/controllers/authentication.js @@ -6,9 +6,6 @@ import { compare, hash } from '../helpers/password.js'; export const validUsername = /^(?=.*[a-z])[a-z0-9]+$/is; -const PASSWORD_LENGTH = 5; -const USERNAME_LENGTH = 4; - const COOKIE_KEY = config.get('jwt.cookie'); const COOKIE_KEY_PUBLIC = COOKIE_KEY + '_PUBLIC'; const SACRED_COOKIE_SETTINGS = { @@ -27,127 +24,152 @@ const PUBLIC_COOKIE_SETTINGS = { }; async function authentication(fastify) { - fastify.post('/signup', async (request, reply) => { - const { email = '', username = '', password = '' } = request.body; - - if (!emailValidator.validate(email)) { - return reply.code(403).send({ - type: 'email', - error: `Your email: "${email}" is not valid.`, - }); - } + fastify.post( + '/signup', + { + schema: { + body: { + type: 'object', + required: ['email', 'username', 'password'], + properties: { + email: { type: 'string' }, + username: { type: 'string', minLength: 4, maxLength: 20 }, + password: { type: 'string', minLength: 5, maxLength: 50 }, + }, + }, + }, + }, + async (request, reply) => { + const { email, username, password } = request.body; - if (!validUsername.test(username) || username.length < USERNAME_LENGTH) { - return reply.code(403).send({ - type: 'username', - error: `Username has to be longer than ${USERNAME_LENGTH}, and can only contain these characters. [A-Za-z0-9_-]`, - }); - } + if (!emailValidator.validate(email)) { + return reply.code(400).send({ + type: 'email', + message: `Your email: "${email}" is not valid.`, + }); + } - if (password.length < PASSWORD_LENGTH) { - return reply.code(403).send({ - type: 'password', - error: `Password has to be longer than ${PASSWORD_LENGTH} characters`, + if (!validUsername.test(username)) { + return reply.code(400).send({ + type: 'username', + message: `Username can only contain these characters. [A-Za-z0-9_-]`, + }); + } + + const userExist = await prisma.user.findFirst({ where: { username } }); + if (userExist) { + return reply + .code(403) + .send({ type: 'username', message: `This username has already been taken.` }); + } + + const emailExist = await prisma.user.findFirst({ where: { email } }); + if (emailExist) { + return reply + .code(403) + .send({ type: 'email', message: `This email has already been registered.` }); + } + + const userPassword = await hash(password); + + const user = await prisma.user.create({ + data: { + username, + email, + password: userPassword, + role: 'user', + }, }); - } - const userExist = await prisma.user.findFirst({ where: { username } }); - if (userExist) { - return reply - .code(403) - .send({ type: 'username', error: `This username has already been taken.` }); - } + if (!user) { + return reply.code(400).send({ + message: + 'Something happened while creating a new user. Please try again later.', + }); + } - const emailExist = await prisma.user.findFirst({ where: { email } }); - if (emailExist) { - return reply - .code(403) - .send({ type: 'email', error: `This email has already been registered.` }); - } + const sacredToken = await reply.jwtSign( + { + username: user.username, + email: user.email, + user_id: user.id, + }, + { expiresIn: '7d' } // expires in seven days + ); - const userPassword = await hash(password); + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + 6); - const user = await prisma.user.create({ - data: { - username, - email, - password: userPassword, - role: 'user', - }, - }); + const publicToken = Buffer.from( + JSON.stringify({ + username: user.username, + expirationDate: expirationDate, + }) + ).toString('base64'); - if (!user) { - return reply.code(403).send({ - error: 'Something happened while creating a new user. Please try again later.', - }); + reply + .setCookie(COOKIE_KEY, sacredToken, SACRED_COOKIE_SETTINGS) + .setCookie(COOKIE_KEY_PUBLIC, publicToken, PUBLIC_COOKIE_SETTINGS) + .code(200) + .send({ + username: user.username, + }); } + ); - const sacredToken = await reply.jwtSign( - { - username: user.username, - email: user.email, - user_id: user.id, + fastify.post( + '/signin', + { + schema: { + body: { + type: 'object', + required: ['username', 'password'], + properties: { + username: { type: 'string' }, + password: { type: 'string' }, + }, + }, }, - { expiresIn: '7d' } // expires in seven days - ); - - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + 6); - - const publicToken = Buffer.from( - JSON.stringify({ - username: user.username, - expirationDate: expirationDate, - }) - ).toString('base64'); - - reply - .setCookie(COOKIE_KEY, sacredToken, SACRED_COOKIE_SETTINGS) - .setCookie(COOKIE_KEY_PUBLIC, publicToken, PUBLIC_COOKIE_SETTINGS) - .code(200) - .send({ - username: user.username, - }); - }); + }, + async (request, reply) => { + const { username = '', password = '' } = request.body; - fastify.post('/signin', async (request, reply) => { - const { username = '', password = '' } = request.body; + const user = await prisma.user.findFirst({ where: { username } }); - const user = await prisma.user.findFirst({ where: { username } }); + if (!user || !(await compare(password, user.password))) { + return reply.code(401).send({ error: 'Incorrect username or password.' }); + } - if (!user || !(await compare(password, user.password))) { - return reply.code(401).send({ error: 'Incorrect username or password.' }); - } + const sacredToken = await reply.jwtSign( + { + username: user.username, + email: user.email, + user_id: user.id, + }, + { expiresIn: '7d' } + ); - const sacredToken = await reply.jwtSign( - { - username: user.username, - email: user.email, - user_id: user.id, - }, - { expiresIn: '7d' } - ); + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + 6); - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + 6); + const publicToken = Buffer.from( + JSON.stringify({ + username: user.username, + expirationDate: expirationDate, + }) + ).toString('base64'); - const publicToken = Buffer.from( - JSON.stringify({ - username: user.username, - expirationDate: expirationDate, - }) - ).toString('base64'); - - reply - .setCookie(COOKIE_KEY, sacredToken, SACRED_COOKIE_SETTINGS) - .setCookie(COOKIE_KEY_PUBLIC, publicToken, PUBLIC_COOKIE_SETTINGS) - .code(200) - .send({ - username, - }); - }); + reply + .setCookie(COOKIE_KEY, sacredToken, SACRED_COOKIE_SETTINGS) + .setCookie(COOKIE_KEY_PUBLIC, publicToken, PUBLIC_COOKIE_SETTINGS) + .code(200) + .send({ + username, + }); + } + ); - fastify.post('/signout', async (request, reply) => { + fastify.post('/signout', async (_, reply) => { reply.clearCookie(COOKIE_KEY_PUBLIC, { path: '/' }).clearCookie(COOKIE_KEY, { path: '/' }); return { diff --git a/src/server/controllers/download.js b/src/server/controllers/download.js index 86d37c23..facf9b1d 100644 --- a/src/server/controllers/download.js +++ b/src/server/controllers/download.js @@ -5,33 +5,44 @@ import prisma from '../services/prisma.js'; import { isValidSecretId } from '../helpers/regexp.js'; async function downloadFiles(fastify) { - fastify.post('/', async (request, reply) => { - const { key, secretId } = request.body; - - if (!isValidSecretId.test(secretId)) { - return reply.code(403).send({ error: 'Not a valid secret id' }); - } - - const fileKey = sanitize(key); - - const file = await fileAdapter.download(fileKey); + fastify.post( + '/', + { + schema: { + body: { + type: 'object', + required: ['key', 'secretId'], + properties: { + key: { type: 'string' }, + secretId: { type: 'string' }, + }, + }, + }, + }, + async (request, reply) => { + const { key, secretId } = request.body; + + if (!isValidSecretId.test(secretId)) { + return reply.code(400).send({ error: 'Not a valid secret id' }); + } - const secret = await prisma.secret.findFirst({ where: { id: secretId } }); + const fileKey = sanitize(key); - if (secret?.preventBurn !== 'true' && Number(secret?.maxViews) === 1) { - await prisma.secret.delete({ where: { id: secretId } }); + const file = await fileAdapter.download(fileKey); - if (secret?.file) { - const { key } = JSON.parse(secret?.file); + const secret = await prisma.secret.findFirst({ where: { id: secretId } }); - await fileAdapter.remove(key); + // When the secret is null, we delete the file. + // The deletion will happen in the secrets controller + if (!secret) { + await fileAdapter.remove(fileKey); } - } - return reply.code(201).send({ - content: file, - }); - }); + return reply.code(201).send({ + content: file, + }); + } + ); } export default downloadFiles; diff --git a/src/server/controllers/secret.js b/src/server/controllers/secret.js index 68efb4b5..b807dc95 100644 --- a/src/server/controllers/secret.js +++ b/src/server/controllers/secret.js @@ -1,8 +1,7 @@ -import config from 'config'; -import prettyBytes from 'pretty-bytes'; import validator from 'validator'; import getClientIp from '../helpers/client-ip.js'; import { compare, hash } from '../helpers/password.js'; +import VALID_TTL from '../helpers/validate-ttl.js'; import prisma from '../services/prisma.js'; import { validUsername } from './authentication.js'; @@ -124,46 +123,42 @@ async function secret(fastify) { '/', { preValidation: [fastify.userFeatures, fastify.attachment], + schema: { + // Add a schema to define expected input + body: { + type: 'object', + required: ['text', 'ttl'], + properties: { + text: { type: 'string' }, + title: { type: 'string', maxLength: 255 }, + ttl: { type: 'integer', minimum: 1, enum: VALID_TTL }, + password: { type: 'string' }, + allowedIp: { type: 'string' }, + preventBurn: { type: 'boolean' }, + maxViews: { type: 'integer', minimum: 1, maximum: 999 }, + isPublic: { type: 'boolean' }, + }, + }, + }, }, async (req, reply) => { const { text, title, ttl, password, allowedIp, preventBurn, maxViews, isPublic } = req.body; const { files } = req.secret; - if (Buffer.byteLength(text) > config.get('api.maxTextSize')) { - return reply.code(413).send({ - error: `The secret size (${prettyBytes( - Buffer.byteLength(text) - )}) exceeded our limit of ${config.get('api.maxTextSize')}.`, - }); - } - - if (title?.length > 255) { - return reply.code(413).send({ - error: `The title is longer than 255 characters which is not allowed.`, - }); - } - if (allowedIp && !ipCheck(allowedIp)) { - return reply.code(409).send({ error: 'The IP address is not valid' }); - } - - if ( - !validator.isBoolean(String(isPublic)) || - !validator.isBoolean(String(preventBurn)) - ) { - return reply.code(409).send({ error: 'The value is not a valid boolean' }); + return reply.code(400).send({ message: 'The IP address is not valid' }); } const secret = await prisma.secret.create({ data: { title, - maxViews: Number(maxViews) <= 999 ? Number(maxViews) : 1, + maxViews, data: text, allowed_ip: allowedIp, password: password ? await hash(password) : undefined, - preventBurn: preventBurn, - isPublic: isPublic, + preventBurn, + isPublic, files: { create: files, }, diff --git a/src/server/helpers/validate-ttl.js b/src/server/helpers/validate-ttl.js index d3ecf216..13b4bc13 100644 --- a/src/server/helpers/validate-ttl.js +++ b/src/server/helpers/validate-ttl.js @@ -11,6 +11,4 @@ const VALID_TTL = [ 300, // 5 minutes ]; -export default function isValidTTL(ttl) { - return VALID_TTL.some((_ttl) => _ttl === ttl); -} +export default VALID_TTL;