etherpad-lite/src/node/hooks/express/webaccess.ts

223 lines
10 KiB
TypeScript
Raw Normal View History

2020-11-18 17:25:56 -05:00
'use strict';
2024-02-23 19:48:55 +01:00
import {strict as assert} from "assert";
import log4js from 'log4js';
import {SocketClientRequest} from "../../types/SocketClientRequest";
import {WebAccessTypes} from "../../types/WebAccessTypes";
import {SettingsUser} from "../../types/SettingsUser";
const httpLogger = log4js.getLogger('http');
const settings = require('../../utils/Settings');
2020-11-18 17:25:56 -05:00
const hooks = require('../../../static/js/pluginfw/hooks');
const readOnlyManager = require('../../db/ReadOnlyManager');
2012-02-25 13:38:09 +01:00
hooks.deprecationNotices.authFailure = 'use the authnFailure and authzFailure hooks instead';
// Promisified wrapper around hooks.aCallFirst.
2024-02-23 19:48:55 +01:00
const aCallFirst = (hookName: string, context:any, pred = null) => new Promise((resolve, reject) => {
hooks.aCallFirst(hookName, context, (err:any, r: unknown) => err != null ? reject(err) : resolve(r), pred);
});
const aCallFirst0 =
2024-02-23 19:48:55 +01:00
// @ts-ignore
async (hookName: string, context:any, pred = null) => (await aCallFirst(hookName, context, pred))[0];
2024-02-23 19:48:55 +01:00
exports.normalizeAuthzLevel = (level: string|boolean) => {
if (!level) return false;
switch (level) {
case true:
return 'create';
case 'readOnly':
case 'modify':
case 'create':
return level;
default:
httpLogger.warn(`Unknown authorization level '${level}', denying access`);
}
return false;
};
2024-02-23 19:48:55 +01:00
exports.userCanModify = (padId: string, req: SocketClientRequest) => {
if (readOnlyManager.isReadOnlyId(padId)) return false;
if (!settings.requireAuthentication) return true;
const {session: {user} = {}} = req;
if (!user || user.readOnly) return false;
assert(user.padAuthorizations); // This is populated even if !settings.requireAuthorization.
const level = exports.normalizeAuthzLevel(user.padAuthorizations[padId]);
return level && level !== 'readOnly';
};
// Exported so that tests can set this to 0 to avoid unnecessary test slowness.
exports.authnFailureDelayMs = 1000;
2024-02-23 19:48:55 +01:00
const checkAccess = async (req:any, res:any, next: Function) => {
const requireAdmin = req.path.toLowerCase().startsWith('/admin-auth');
2020-11-18 17:31:18 -05:00
// ///////////////////////////////////////////////////////////////////////////////////////////////
// Step 1: Check the preAuthorize hook for early permit/deny (permit is only allowed for non-admin
// pages). If any plugin explicitly grants or denies access, skip the remaining steps. Plugins can
// use the preAuthzFailure hook to override the default 403 error.
// ///////////////////////////////////////////////////////////////////////////////////////////////
2024-02-23 19:48:55 +01:00
let results: null|boolean[];
let skip = false;
2024-02-23 19:48:55 +01:00
const preAuthorizeNext = (...args:any) => { skip = true; next(...args); };
try {
results = await aCallFirst('preAuthorize', {req, res, next: preAuthorizeNext},
// This predicate will cause aCallFirst to call the hook functions one at a time until one
// of them returns a non-empty list, with an exception: If the request is for an /admin
// page, truthy entries are filtered out before checking to see whether the list is empty.
// This prevents plugin authors from accidentally granting admin privileges to the general
// public.
2024-02-23 19:48:55 +01:00
// @ts-ignore
(r) => (skip || (r != null && r.filter((x) => (!requireAdmin || !x)).length > 0))) as boolean[];
} catch (err:any) {
httpLogger.error(`Error in preAuthorize hook: ${err.stack || err.toString()}`);
if (!skip) res.status(500).send('Internal Server Error');
return;
}
if (skip) return;
if (requireAdmin) {
// Filter out all 'true' entries to prevent plugin authors from accidentally granting admin
// privileges to the general public.
results = results.filter((x) => !x);
}
if (results.length > 0) {
// Access was explicitly granted or denied. If any value is false then access is denied.
if (results.every((x) => x)) return next();
if (await aCallFirst0('preAuthzFailure', {req, res})) return;
// No plugin handled the pre-authentication authorization failure.
return res.status(403).send('Forbidden');
}
2020-11-18 17:31:18 -05:00
// This helper is used in steps 2 and 4 below, so it may be called twice per access: once before
// authentication is checked and once after (if settings.requireAuthorization is true).
const authorize = async () => {
2024-02-23 19:48:55 +01:00
const grant = async (level: string|false) => {
level = exports.normalizeAuthzLevel(level);
2020-11-18 17:31:18 -05:00
if (!level) return false;
const user = req.session.user;
2020-11-18 17:31:18 -05:00
if (user == null) return true; // This will happen if authentication is not required.
const encodedPadId = (req.path.match(/^\/p\/([^/]*)/) || [])[1];
2020-11-18 17:31:18 -05:00
if (encodedPadId == null) return true;
let padId = decodeURIComponent(encodedPadId);
if (readOnlyManager.isReadOnlyId(padId)) {
// pad is read-only, first get the real pad ID
padId = await readOnlyManager.getPadId(padId);
if (padId == null) return false;
}
// The user was granted access to a pad. Remember the authorization level in the user's
// settings so that SecurityManager can approve or deny specific actions.
if (user.padAuthorizations == null) user.padAuthorizations = {};
user.padAuthorizations[padId] = level;
2020-11-18 17:31:18 -05:00
return true;
};
const isAuthenticated = req.session && req.session.user;
if (isAuthenticated && req.session.user.is_admin) return await grant('create');
const requireAuthn = requireAdmin || settings.requireAuthentication;
if (!requireAuthn) return await grant('create');
if (!isAuthenticated) return await grant(false);
if (requireAdmin && !req.session.user.is_admin) return await grant(false);
if (!settings.requireAuthorization) return await grant('create');
return await grant(await aCallFirst0('authorize', {req, res, next, resource: req.path}));
};
2012-04-19 14:25:12 +02:00
2020-11-18 17:31:18 -05:00
// ///////////////////////////////////////////////////////////////////////////////////////////////
// Step 2: Try to just access the thing. If access fails (perhaps authentication has not yet
// completed, or maybe different credentials are required), go to the next step.
// ///////////////////////////////////////////////////////////////////////////////////////////////
if (await authorize()) {
if(requireAdmin) {
res.status(200).send('Authorized')
return
}
return next();
}
2020-11-18 17:31:18 -05:00
// ///////////////////////////////////////////////////////////////////////////////////////////////
// Step 3: Authenticate the user. (Or, if already logged in, reauthenticate with different
// credentials if supported by the authn scheme.) If authentication fails, give the user a 401
// error to request new credentials. Otherwise, go to the next step. Plugins can use the
// authnFailure hook to override the default error handling behavior (e.g., to redirect to a login
// page).
// ///////////////////////////////////////////////////////////////////////////////////////////////
if (settings.users == null) settings.users = {};
2024-02-23 19:48:55 +01:00
const ctx:WebAccessTypes = {req, res, users: settings.users, next};
2020-11-18 17:31:18 -05:00
// If the HTTP basic auth header is present, extract the username and password so it can be given
// to authn plugins.
const httpBasicAuth = req.headers.authorization && req.headers.authorization.startsWith('Basic ');
2020-11-18 17:31:18 -05:00
if (httpBasicAuth) {
const userpass =
Buffer.from(req.headers.authorization.split(' ')[1], 'base64').toString().split(':');
ctx.username = userpass.shift();
// Prevent prototype pollution vulnerabilities in plugins. This also silences a prototype
// pollution warning below (when setting settings.users[ctx.username]) that isn't actually a
// problem unless the attacker can also set Object.prototype.password.
if (ctx.username === '__proto__') ctx.username = null;
2020-11-18 17:31:18 -05:00
ctx.password = userpass.join(':');
}
if (!(await aCallFirst0('authenticate', ctx))) {
// Fall back to HTTP basic auth.
2024-02-23 19:48:55 +01:00
// @ts-ignore
const {[ctx.username]: {password} = {}} = settings.users as SettingsUser;
if (!httpBasicAuth ||
!ctx.username ||
password == null || password.toString() !== ctx.password) {
2020-11-18 17:31:18 -05:00
httpLogger.info(`Failed authentication from IP ${req.ip}`);
if (await aCallFirst0('authnFailure', {req, res})) return;
if (await aCallFirst0('authFailure', {req, res, next})) return;
// No plugin handled the authentication failure. Fall back to basic authentication.
2024-03-20 08:43:17 +01:00
if (!requireAdmin) {
res.header('WWW-Authenticate', 'Basic realm="Protected Area"');
}
2020-11-18 17:31:18 -05:00
// Delay the error response for 1s to slow down brute force attacks.
await new Promise((resolve) => setTimeout(resolve, exports.authnFailureDelayMs));
res.status(401).send('Authentication Required');
return;
}
if (ctx.username === '__proto__' || ctx.username === 'constructor' || ctx.username === 'prototype') {
res.end(403);
return;
}
2020-11-18 17:31:18 -05:00
settings.users[ctx.username].username = ctx.username;
// Make a shallow copy so that the password property can be deleted (to prevent it from
// appearing in logs or in the database) without breaking future authentication attempts.
req.session.user = {...settings.users[ctx.username]};
delete req.session.user.password;
}
if (req.session.user == null) {
httpLogger.error('authenticate hook failed to add user settings to session');
return res.status(500).send('Internal Server Error');
}
const {username = '<no username>'} = req.session.user;
httpLogger.info(`Successful authentication from IP ${req.ip} for user ${username}`);
// ///////////////////////////////////////////////////////////////////////////////////////////////
// Step 4: Try to access the thing again. If this fails, give the user a 403 error. Plugins can
// use the authzFailure hook to override the default error handling behavior (e.g., to redirect to
// a login page).
// ///////////////////////////////////////////////////////////////////////////////////////////////
const auth = await authorize()
if (auth && !requireAdmin) return next();
if(auth && requireAdmin) {
res.status(200).send('Authorized')
return
}
2020-11-18 17:31:18 -05:00
if (await aCallFirst0('authzFailure', {req, res})) return;
if (await aCallFirst0('authFailure', {req, res, next})) return;
// No plugin handled the authorization failure.
res.status(403).send('Forbidden');
};
2012-02-25 13:38:09 +01:00
/**
* Express middleware to authenticate the user and check authorization. Must be installed after the
* express-session middleware.
*/
2024-02-23 19:48:55 +01:00
exports.checkAccess = (req:any, res:any, next:Function) => {
checkAccess(req, res, next).catch((err) => next(err || new Error(err)));
};