0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-15 03:01:37 -05:00

Added integrity token API & middleware for magic link requests

ref KTLO-1
Back-end implementation of request integrity tokens. The purpose here
is to prevent simple web bots from spamming the signup form.
This commit is contained in:
Sam Lord 2024-08-22 11:30:11 +01:00 committed by Sam Lord
parent 0053939185
commit a48b4e5cbf
4 changed files with 106 additions and 2 deletions

View file

@ -0,0 +1,62 @@
const crypto = require('crypto');
class RequestIntegrityTokenProvider {
#themeSecret;
#tokenDuration;
/**
* @param {object} options
* @param {string} options.themeSecret
* @param {number} options.tokenDuration
*/
constructor(options) {
this.#themeSecret = options.themeSecret;
this.#tokenDuration = options.tokenDuration;
}
/**
* @returns {string}
*/
create() {
const currentTime = Date.now();
const expiryTime = currentTime + this.#tokenDuration;
const nonce = crypto.randomBytes(16).toString('hex');
const hmac = crypto.createHmac('sha256', this.#themeSecret);
hmac.update(`${expiryTime.toString()}:${nonce}`);
return `${expiryTime.toString()}:${nonce}:${hmac.digest('hex')}`;
}
/**
* @param {string} token
* @returns {boolean}
*/
validate(token) {
const parts = token.split(':');
if (parts.length !== 3) {
// Invalid token string
return false;
}
const nonce = parts[0];
const timestamp = parseInt(parts[1], 10);
const hmacDigest = parts[2];
const hmac = crypto.createHmac('sha256', this.#themeSecret);
hmac.update(`${nonce}:${timestamp.toString()}`);
const expectedHmac = hmac.digest('hex');
if (expectedHmac !== hmacDigest) {
// HMAC mismatch
return false;
}
if (Date.now() > timestamp) {
// Token expired
return false;
}
return true;
}
}
module.exports = RequestIntegrityTokenProvider;

View file

@ -35,7 +35,7 @@ const getFreeTier = async function getFreeTier() {
* @param {import('express').Request} req - The member object
* @param {import('express').Response} res - The express response object to set the cookies on
* @param {Object} freeTier - The free tier object
* @returns
* @returns
*/
const setAccessCookies = function setAccessCookies(member, req, res, freeTier) {
if (!member) {
@ -157,6 +157,37 @@ const getIdentityToken = async function getIdentityToken(req, res) {
}
};
const createIntegrityToken = async function createIntegrityToken(req, res) {
try {
const token = membersService.requestIntegrityTokenProvider.create();
res.writeHead(200);
res.end(token);
} catch (err) {
res.writeHead(204);
res.end();
}
};
const verifyIntegrityToken = async function verifyIntegrityToken(req, res, next) {
try {
const token = req.query.requestIntegrityToken;
if (!token) {
logging.warn('Request with missing integrity token.');
// In future this will throw an error
return next();
}
if (membersService.requestIntegrityTokenProvider.validate(token)) {
return next();
} else {
logging.warn('Request with invalid integrity token.');
// In future this will throw an error
return next();
}
} catch (err) {
next(err);
}
};
const deleteSession = async function deleteSession(req, res) {
try {
await membersService.ssr.deleteSession(req, res);
@ -397,5 +428,7 @@ module.exports = {
updateMemberNewsletters,
deleteSession,
accessInfoSession,
deleteSuppression
deleteSuppression,
createIntegrityToken,
verifyIntegrityToken
};

View file

@ -19,6 +19,7 @@ const tiersService = require('../tiers');
const VerificationTrigger = require('@tryghost/verification-trigger');
const DatabaseInfo = require('@tryghost/database-info');
const settingsHelpers = require('../settings-helpers');
const RequestIntegrityTokenProvider = require('./RequestIntegrityTokenProvider');
const messages = {
noLiveKeysInDevelopment: 'Cannot use live stripe keys in development. Please restart in production mode.',
@ -193,6 +194,11 @@ module.exports = {
ssr: null,
verificationTrigger: null,
requestIntegrityTokenProvider: new RequestIntegrityTokenProvider({
themeSecret: settingsCache.get('theme_session_secret'),
tokenDuration: 1000 * 60 * 5
}),
stripeConnect: require('./stripe-connect'),
processImport: null,

View file

@ -70,10 +70,13 @@ module.exports = function setupMembersApp() {
membersApp.get('/api/session', middleware.getIdentityToken);
membersApp.delete('/api/session', bodyParser.json({limit: '5mb'}), middleware.deleteSession);
membersApp.get('/api/integrity-token', middleware.createIntegrityToken);
// NOTE: this is wrapped in a function to ensure we always go via the getter
membersApp.post(
'/api/send-magic-link',
bodyParser.json(),
middleware.verifyIntegrityToken,
// Prevent brute forcing email addresses (user enumeration)
shared.middleware.brute.membersAuthEnumeration,
// Prevent brute forcing passwords for the same email address