enforce x-client header (#15476)

This commit is contained in:
Phillip Thelen
2025-07-22 21:00:51 +02:00
committed by GitHub
parent d1a18c121d
commit 17d22dda3f
3 changed files with 51 additions and 1 deletions

View File

@@ -1,8 +1,11 @@
import nconf from 'nconf';
import requireAgain from 'require-again';
import { import {
generateRes, generateRes,
generateReq, generateReq,
} from '../../../helpers/api-unit.helper'; } from '../../../helpers/api-unit.helper';
import { authWithHeaders as authWithHeadersFactory } from '../../../../website/server/middlewares/auth';
const authPath = '../../../../website/server/middlewares/auth';
describe('auth middleware', () => { describe('auth middleware', () => {
let res; let req; let let res; let req; let
@@ -16,6 +19,7 @@ describe('auth middleware', () => {
describe('auth with headers', () => { describe('auth with headers', () => {
it('allows to specify a list of user field that we do not want to load', done => { 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({ const authWithHeaders = authWithHeadersFactory({
userFieldsToExclude: ['items'], userFieldsToExclude: ['items'],
}); });
@@ -35,6 +39,7 @@ describe('auth middleware', () => {
}); });
it('makes sure some fields are always included', done => { it('makes sure some fields are always included', done => {
const authWithHeadersFactory = requireAgain(authPath).authWithHeaders;
const authWithHeaders = authWithHeadersFactory({ const authWithHeaders = authWithHeadersFactory({
userFieldsToExclude: [ userFieldsToExclude: [
'items', 'auth.timestamps', 'items', 'auth.timestamps',
@@ -62,6 +67,7 @@ describe('auth middleware', () => {
}); });
it('errors with InvalidCredentialsError and code when token is wrong', done => { it('errors with InvalidCredentialsError and code when token is wrong', done => {
const authWithHeadersFactory = requireAgain(authPath).authWithHeaders;
const authWithHeaders = authWithHeadersFactory({ userFieldsToExclude: [] }); const authWithHeaders = authWithHeadersFactory({ userFieldsToExclude: [] });
req.headers['x-api-user'] = user._id; req.headers['x-api-user'] = user._id;
@@ -75,5 +81,41 @@ describe('auth middleware', () => {
return done(); 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();
});
});
});
}); });
}); });

View File

@@ -109,6 +109,7 @@
"tweet": "Tweet", "tweet": "Tweet",
"checkOutMobileApps": "Check out our mobile apps!", "checkOutMobileApps": "Check out our mobile apps!",
"missingAuthHeaders": "Missing authentication headers.", "missingAuthHeaders": "Missing authentication headers.",
"missingClientHeader": "Missing x-client headers.",
"missingUsernameEmail": "Missing username or email.", "missingUsernameEmail": "Missing username or email.",
"missingEmail": "Missing email.", "missingEmail": "Missing email.",
"missingUsername": "Missing username.", "missingUsername": "Missing username.",

View File

@@ -4,6 +4,7 @@ import url from 'url';
import { import {
InvalidCredentialsError, InvalidCredentialsError,
NotAuthorized, NotAuthorized,
BadRequest,
} from '../libs/errors'; } from '../libs/errors';
import { import {
model as User, model as User,
@@ -12,6 +13,8 @@ import gcpStackdriverTracer from '../libs/gcpTraceAgent';
import common from '../../common'; import common from '../../common';
import { getLanguageFromUser } from '../libs/language'; 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 OFFICIAL_PLATFORMS = ['habitica-web', 'habitica-ios', 'habitica-android'];
const COMMUNITY_MANAGER_EMAIL = nconf.get('EMAILS_COMMUNITY_MANAGER_EMAIL'); const COMMUNITY_MANAGER_EMAIL = nconf.get('EMAILS_COMMUNITY_MANAGER_EMAIL');
const USER_FIELDS_ALWAYS_LOADED = ['_id', '_v', 'notifications', 'preferences', 'auth', 'flags', 'permissions']; 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 client = req.header('x-client');
const optional = options.optional || false; const optional = options.optional || false;
if (ENFORCE_CLIENT_HEADER && !client) {
return next(new BadRequest(res.t('missingClientHeader')));
}
if (!userId || !apiToken) { if (!userId || !apiToken) {
if (optional) return next(); if (optional) return next();
return next(new NotAuthorized(res.t('missingAuthHeaders'))); return next(new NotAuthorized(res.t('missingAuthHeaders')));