From 17d22dda3f709dbe477b3865711166a404bdae42 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Tue, 22 Jul 2025 21:00:51 +0200 Subject: [PATCH] enforce x-client header (#15476) --- test/api/unit/middlewares/auth.test.js | 44 +++++++++++++++++++++++++- website/common/locales/en/front.json | 1 + website/server/middlewares/auth.js | 7 ++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/test/api/unit/middlewares/auth.test.js b/test/api/unit/middlewares/auth.test.js index b75e339d63..4ddd401abc 100644 --- a/test/api/unit/middlewares/auth.test.js +++ b/test/api/unit/middlewares/auth.test.js @@ -1,8 +1,11 @@ +import nconf from 'nconf'; +import requireAgain from 'require-again'; import { generateRes, generateReq, } from '../../../helpers/api-unit.helper'; -import { authWithHeaders as authWithHeadersFactory } from '../../../../website/server/middlewares/auth'; + +const authPath = '../../../../website/server/middlewares/auth'; describe('auth middleware', () => { let res; let req; let @@ -16,6 +19,7 @@ describe('auth middleware', () => { describe('auth with headers', () => { it('allows to specify a list of user field that we do not want to load', done => { + const authWithHeadersFactory = requireAgain(authPath).authWithHeaders; const authWithHeaders = authWithHeadersFactory({ userFieldsToExclude: ['items'], }); @@ -35,6 +39,7 @@ describe('auth middleware', () => { }); it('makes sure some fields are always included', done => { + const authWithHeadersFactory = requireAgain(authPath).authWithHeaders; const authWithHeaders = authWithHeadersFactory({ userFieldsToExclude: [ 'items', 'auth.timestamps', @@ -62,6 +67,7 @@ describe('auth middleware', () => { }); it('errors with InvalidCredentialsError and code when token is wrong', done => { + const authWithHeadersFactory = requireAgain(authPath).authWithHeaders; const authWithHeaders = authWithHeadersFactory({ userFieldsToExclude: [] }); req.headers['x-api-user'] = user._id; @@ -75,5 +81,41 @@ describe('auth middleware', () => { return done(); }); }); + + describe('when ENFORCE_CLIENT_HEADER is true', () => { + let authFactory; + + beforeEach(() => { + sandbox.stub(nconf, 'get').withArgs('ENFORCE_CLIENT_HEADER').returns('true'); + authFactory = requireAgain(authPath).authWithHeaders; + }); + + it('errors with missingClientHeader when x-client header is not present', done => { + const authWithHeaders = authFactory({ userFieldsToExclude: [] }); + + req.headers['x-api-user'] = user._id; + req.headers['x-api-key'] = user; + authWithHeaders(req, res, err => { + expect(err).to.exist; + expect(err.name).to.equal('BadRequest'); + expect(err.message).to.equal(res.t('missingClientHeader')); + return done(); + }); + }); + + it('allows request to pass when x-client header is present', done => { + const authWithHeaders = authFactory({ userFieldsToExclude: [] }); + + req.headers['x-api-user'] = user._id; + req.headers['x-api-key'] = user.apiToken; + req.headers['x-client'] = 'habitica-web'; + + authWithHeaders(req, res, err => { + if (err) return done(err); + expect(res.locals.user).to.exist; + return done(); + }); + }); + }); }); }); diff --git a/website/common/locales/en/front.json b/website/common/locales/en/front.json index ba87f0a80e..249ed1edf1 100644 --- a/website/common/locales/en/front.json +++ b/website/common/locales/en/front.json @@ -109,6 +109,7 @@ "tweet": "Tweet", "checkOutMobileApps": "Check out our mobile apps!", "missingAuthHeaders": "Missing authentication headers.", + "missingClientHeader": "Missing x-client headers.", "missingUsernameEmail": "Missing username or email.", "missingEmail": "Missing email.", "missingUsername": "Missing username.", diff --git a/website/server/middlewares/auth.js b/website/server/middlewares/auth.js index ff1db6fd5b..d5cd9abde3 100644 --- a/website/server/middlewares/auth.js +++ b/website/server/middlewares/auth.js @@ -4,6 +4,7 @@ import url from 'url'; import { InvalidCredentialsError, NotAuthorized, + BadRequest, } from '../libs/errors'; import { model as User, @@ -12,6 +13,8 @@ import gcpStackdriverTracer from '../libs/gcpTraceAgent'; import common from '../../common'; import { getLanguageFromUser } from '../libs/language'; +const ENFORCE_CLIENT_HEADER = nconf.get('ENFORCE_CLIENT_HEADER') === 'true'; + const OFFICIAL_PLATFORMS = ['habitica-web', 'habitica-ios', 'habitica-android']; const COMMUNITY_MANAGER_EMAIL = nconf.get('EMAILS_COMMUNITY_MANAGER_EMAIL'); const USER_FIELDS_ALWAYS_LOADED = ['_id', '_v', 'notifications', 'preferences', 'auth', 'flags', 'permissions']; @@ -63,6 +66,10 @@ export function authWithHeaders (options = {}) { const client = req.header('x-client'); const optional = options.optional || false; + if (ENFORCE_CLIENT_HEADER && !client) { + return next(new BadRequest(res.t('missingClientHeader'))); + } + if (!userId || !apiToken) { if (optional) return next(); return next(new NotAuthorized(res.t('missingAuthHeaders')));