diff --git a/services/history-v1/api/middleware/security.js b/services/history-v1/api/middleware/security.js new file mode 100644 index 0000000000..79823c4018 --- /dev/null +++ b/services/history-v1/api/middleware/security.js @@ -0,0 +1,144 @@ +'use strict' + +const basicAuth = require('basic-auth') +const config = require('config') +const HTTPStatus = require('http-status') +const jwt = require('jsonwebtoken') +const tsscmp = require('tsscmp') +const { validateReq } = require('@overleaf/validation-tools') +const schemas = require('../schema') + +function hasValidBasicAuthCredentials(req) { + const credentials = basicAuth(req) + if (!credentials) return false + + // No security in the name, so just use straight comparison. + if (credentials.name !== 'staging') return false + + const password = config.get('basicHttpAuth.password') + if (password && tsscmp(credentials.pass, password)) return true + + // Support an old password so we can change the password without downtime. + if (config.has('basicHttpAuth.oldPassword')) { + const oldPassword = config.get('basicHttpAuth.oldPassword') + if (oldPassword && tsscmp(credentials.pass, oldPassword)) return true + } + + return false +} + +function setupSSL(app) { + const httpsOnly = config.get('httpsOnly') === 'true' + if (!httpsOnly) { + return + } + app.enable('trust proxy') + app.use(function (req, res, next) { + if (req.protocol === 'https') { + next() + return + } + if (req.method === 'GET' || req.method === 'HEAD') { + res.redirect('https://' + req.headers.host + req.url) + } else { + res + .status(HTTPStatus.FORBIDDEN) + .send('Please use HTTPS when submitting data to this server.') + } + }) +} + +exports.setupSSL = setupSSL + +function handleJWTAuth(req, res, next) { + if (hasValidBasicAuthCredentials(req)) { + return next() + } + + let token + if (req.query.token) { + token = req.query.token + } else if ( + req.headers.authorization && + req.headers.authorization.split(' ')[0] === 'Bearer' + ) { + token = req.headers.authorization.split(' ')[1] + } + + if (!token) { + const err = new Error('jwt missing') + err.statusCode = HTTPStatus.UNAUTHORIZED + err.headers = { 'WWW-Authenticate': 'Bearer' } + return next(err) + } + + let decoded + try { + decoded = decodeJWT(token) + } catch (error) { + if ( + error instanceof jwt.JsonWebTokenError || + error instanceof jwt.TokenExpiredError + ) { + const err = new Error(error.message) + err.statusCode = HTTPStatus.UNAUTHORIZED + err.headers = { 'WWW-Authenticate': 'Bearer error="invalid_token"' } + return next(err) + } + throw error + } + + const { params } = validateReq(req, schemas.projectId) + if (decoded.project_id.toString() !== params.project_id.toString()) { + const err = new Error('Wrong project_id') + err.statusCode = HTTPStatus.FORBIDDEN + return next(err) + } + + req.jwt = decoded + next() +} + +/** + * Verify and decode the given JSON Web Token + */ +function decodeJWT(token) { + const key = config.get('jwtAuth.key') + const algorithm = config.get('jwtAuth.algorithm') + try { + return jwt.verify(token, key, { algorithms: [algorithm] }) + } catch (err) { + // Support an old key so we can change the key without downtime. + if (config.has('jwtAuth.oldKey')) { + const oldKey = config.get('jwtAuth.oldKey') + return jwt.verify(token, oldKey, { algorithms: [algorithm] }) + } else { + throw err + } + } +} + +function handleBasicAuth(req, res, next) { + if (hasValidBasicAuthCredentials(req)) { + return next() + } + const error = new Error() + error.statusCode = HTTPStatus.UNAUTHORIZED + error.headers = { 'WWW-Authenticate': 'Basic realm="Application"' } + return next(error) +} + +function getAuthHandlers() { + if (!config.has('jwtAuth.key') || !config.has('basicHttpAuth.password')) { + throw new Error('missing authentication env vars') + } + + const handlers = {} + handlers.jwt = handleJWTAuth + handlers.basic = handleBasicAuth + handlers.token = handleJWTAuth + return handlers +} + +exports.hasValidBasicAuthCredentials = hasValidBasicAuthCredentials +exports.getAuthHandlers = getAuthHandlers diff --git a/services/history-v1/api/schema.js b/services/history-v1/api/schema.js index a669300a9b..993ea6a417 100644 --- a/services/history-v1/api/schema.js +++ b/services/history-v1/api/schema.js @@ -42,6 +42,13 @@ const changeSchema = z.object({ }) const schemas = { + projectId: z.object({ + params: z + .object({ + project_id: z.string().optional(), + }) + .optional(), + }), initializeProject: z.object({ body: z .object({