express: Move static handlers to `expressPreSession`

This avoids the need to exempt the paths from authentication checks,
and it eliminates unnecessary express-session state.
This commit is contained in:
Richard Hansen 2021-12-17 17:01:55 -05:00
parent 72f4ae444d
commit 649fbdccf5
10 changed files with 65 additions and 85 deletions

View File

@ -8,6 +8,9 @@
* `padOptions.showChat`
* `padOptions.userColor`
* `padOptions.userName`
* Requests for static content (e.g., `/robots.txt`) and special pages (e.g., the
HTTP API, `/stats`) no longer cause the server to generate database records
intended to manage browser sessions (`sessionstorage:*`).
* Fixed the return value of the `getText` HTTP API when called with a specific
revision.
* Fixed a potential attribute pool corruption bug with `copyPadWithoutHistory`.

View File

@ -23,7 +23,7 @@
{
"name": "static",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/static"
"expressPreSession": "ep_etherpad-lite/node/hooks/express/static"
}
},
{
@ -35,13 +35,14 @@
{
"name": "i18n",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/i18n"
"expressPreSession": "ep_etherpad-lite/node/hooks/i18n"
}
},
{
"name": "specialpages",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/specialpages"
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/specialpages",
"expressPreSession": "ep_etherpad-lite/node/hooks/express/specialpages"
}
},
{
@ -53,7 +54,7 @@
{
"name": "apicalls",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/apicalls"
"expressPreSession": "ep_etherpad-lite/node/hooks/express/apicalls"
}
},
{
@ -79,7 +80,7 @@
{
"name": "tests",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/tests"
"expressPreSession": "ep_etherpad-lite/node/hooks/express/tests"
}
},
{
@ -105,7 +106,7 @@
{
"name": "openapi",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/openapi"
"expressPreSession": "ep_etherpad-lite/node/hooks/express/openapi"
}
}
]

View File

@ -6,9 +6,9 @@ const formidable = require('formidable');
const apiHandler = require('../../handler/APIHandler');
const util = require('util');
exports.expressCreateServer = (hookName, args, cb) => {
exports.expressPreSession = async (hookName, {app}) => {
// The Etherpad client side sends information about how a disconnect happened
args.app.post('/ep/pad/connection-diagnostic-info', (req, res) => {
app.post('/ep/pad/connection-diagnostic-info', (req, res) => {
new formidable.IncomingForm().parse(req, (err, fields, files) => {
clientLogger.info(`DIAGNOSTIC-INFO: ${fields.diagnosticInfo}`);
res.end('OK');
@ -23,7 +23,7 @@ exports.expressCreateServer = (hookName, args, cb) => {
});
// The Etherpad client side sends information about client side javscript errors
args.app.post('/jserror', (req, res, next) => {
app.post('/jserror', (req, res, next) => {
(async () => {
const data = JSON.parse(await parseJserrorForm(req));
clientLogger.warn(`${data.msg} --`, {
@ -38,9 +38,7 @@ exports.expressCreateServer = (hookName, args, cb) => {
});
// Provide a possibility to query the latest available API version
args.app.get('/api', (req, res) => {
app.get('/api', (req, res) => {
res.json({currentVersion: apiHandler.latestApiVersion});
});
return cb();
};

View File

@ -540,9 +540,7 @@ const generateDefinitionForVersion = (version, style = APIPathStyle.FLAT) => {
return definition;
};
exports.expressCreateServer = (hookName, args, cb) => {
const {app} = args;
exports.expressPreSession = async (hookName, {app}) => {
// create openapi-backend handlers for each api version under /api/{version}/*
for (const version of Object.keys(apiHandler.version)) {
// we support two different styles of api: flat + rest
@ -690,7 +688,6 @@ exports.expressCreateServer = (hookName, args, cb) => {
});
}
}
return cb();
};
// helper to get api root

View File

@ -10,25 +10,16 @@ const settings = require('../../utils/Settings');
const util = require('util');
const webaccess = require('./webaccess');
exports.expressCreateServer = (hookName, args, cb) => {
// expose current stats
args.app.get('/stats', (req, res) => {
exports.expressPreSession = async (hookName, {app}) => {
app.get('/stats', (req, res) => {
res.json(require('../../stats').toJSON());
});
// serve index.html under /
args.app.get('/', (req, res) => {
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req}));
});
// serve javascript.html
args.app.get('/javascript', (req, res) => {
app.get('/javascript', (req, res) => {
res.send(eejs.require('ep_etherpad-lite/templates/javascript.html', {req}));
});
// serve robots.txt
args.app.get('/robots.txt', (req, res) => {
app.get('/robots.txt', (req, res) => {
let filePath = path.join(
settings.root,
'src',
@ -46,6 +37,34 @@ exports.expressCreateServer = (hookName, args, cb) => {
});
});
app.get('/favicon.ico', (req, res, next) => {
(async () => {
const fns = [
...(settings.favicon ? [path.resolve(settings.root, settings.favicon)] : []),
path.join(settings.root, 'src', 'static', 'skins', settings.skinName, 'favicon.ico'),
path.join(settings.root, 'src', 'static', 'favicon.ico'),
];
for (const fn of fns) {
try {
await fsp.access(fn, fs.constants.R_OK);
} catch (err) {
continue;
}
res.setHeader('Cache-Control', `public, max-age=${settings.maxAge}`);
await util.promisify(res.sendFile.bind(res))(fn);
return;
}
next();
})().catch((err) => next(err || new Error(err)));
});
};
exports.expressCreateServer = (hookName, args, cb) => {
// serve index.html under /
args.app.get('/', (req, res) => {
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req}));
});
// serve pad.html under /p
args.app.get('/p/:pad', (req, res, next) => {
// The below might break for pads being rewritten
@ -77,26 +96,5 @@ exports.expressCreateServer = (hookName, args, cb) => {
}));
});
args.app.get('/favicon.ico', (req, res, next) => {
(async () => {
const fns = [
...(settings.favicon ? [path.resolve(settings.root, settings.favicon)] : []),
path.join(settings.root, 'src', 'static', 'skins', settings.skinName, 'favicon.ico'),
path.join(settings.root, 'src', 'static', 'favicon.ico'),
];
for (const fn of fns) {
try {
await fsp.access(fn, fs.constants.R_OK);
} catch (err) {
continue;
}
res.setHeader('Cache-Control', `public, max-age=${settings.maxAge}`);
await util.promisify(res.sendFile.bind(res))(fn);
return;
}
next();
})().catch((err) => next(err || new Error(err)));
});
return cb();
};

View File

@ -28,14 +28,14 @@ const getTar = async () => {
return tar;
};
exports.expressCreateServer = async (hookName, args) => {
exports.expressPreSession = async (hookName, {app}) => {
// Cache both minified and static.
const assetCache = new CachingMiddleware();
args.app.all(/\/javascripts\/(.*)/, assetCache.handle.bind(assetCache));
app.all(/\/javascripts\/(.*)/, assetCache.handle.bind(assetCache));
// Minify will serve static files compressed (minify enabled). It also has
// file-specific hacks for ace/require-kernel/etc.
args.app.all('/static/:filename(*)', minify.minify);
app.all('/static/:filename(*)', minify.minify);
// Setup middleware that will package JavaScript files served by minify for
// CommonJS loader on the client-side.
@ -53,12 +53,12 @@ exports.expressCreateServer = async (hookName, args) => {
const associator = new StaticAssociator(associations);
jsServer.setAssociator(associator);
args.app.use(jsServer.handle.bind(jsServer));
app.use(jsServer.handle.bind(jsServer));
// serve plugin definitions
// not very static, but served here so that client can do
// require("pluginfw/static/js/plugin-definitions.js");
args.app.get('/pluginfw/plugin-definitions.json', (req, res, next) => {
app.get('/pluginfw/plugin-definitions.json', (req, res, next) => {
const clientParts = plugins.parts.filter((part) => part.client_hooks != null);
const clientPlugins = {};
for (const name of new Set(clientParts.map((part) => part.plugin))) {

View File

@ -29,8 +29,8 @@ const findSpecs = async (specDir) => {
return specs;
};
exports.expressCreateServer = (hookName, args, cb) => {
args.app.get('/tests/frontend/frontendTestSpecs.json', (req, res, next) => {
exports.expressPreSession = async (hookName, {app}) => {
app.get('/tests/frontend/frontendTestSpecs.json', (req, res, next) => {
(async () => {
const modules = [];
await Promise.all(Object.entries(plugins.plugins).map(async ([plugin, def]) => {
@ -59,14 +59,14 @@ exports.expressCreateServer = (hookName, args, cb) => {
const rootTestFolder = path.join(settings.root, 'src/tests/frontend/');
args.app.get('/tests/frontend/index.html', (req, res) => {
app.get('/tests/frontend/index.html', (req, res) => {
res.redirect(['./', ...req.url.split('?').slice(1)].join('?'));
});
// The regexp /[\d\D]{0,}/ is equivalent to the regexp /.*/. The Express route path used here
// uses the more verbose /[\d\D]{0,}/ pattern instead of /.*/ because path-to-regexp v0.1.7 (the
// version used with Express v4.x) interprets '.' and '*' differently than regexp.
args.app.get('/tests/frontend/:file([\\d\\D]{0,})', (req, res, next) => {
app.get('/tests/frontend/:file([\\d\\D]{0,})', (req, res, next) => {
(async () => {
let file = sanitizePathname(req.params.file);
if (['', '.', './'].includes(file)) file = 'index.html';
@ -74,9 +74,7 @@ exports.expressCreateServer = (hookName, args, cb) => {
})().catch((err) => next(err || new Error(err)));
});
args.app.get('/tests/frontend', (req, res) => {
app.get('/tests/frontend', (req, res) => {
res.redirect(['./frontend/', ...req.url.split('?').slice(1)].join('?'));
});
return cb();
};

View File

@ -9,23 +9,6 @@ const readOnlyManager = require('../../db/ReadOnlyManager');
hooks.deprecationNotices.authFailure = 'use the authnFailure and authzFailure hooks instead';
const staticPathsRE = new RegExp(`^/(?:${[
'api(?:/.*)?',
'favicon\\.ico',
'ep/pad/connection-diagnostic-info',
'javascript',
'javascripts/.*',
'jserror/?',
'locales\\.json',
'locales/.*',
'rest/.*',
'pluginfw/.*',
'robots.txt',
'static/.*',
'stats/?',
'tests/frontend(?:/.*)?',
].join('|')})$`);
// Promisified wrapper around hooks.aCallFirst.
const aCallFirst = (hookName, context, pred = null) => new Promise((resolve, reject) => {
hooks.aCallFirst(hookName, context, (err, r) => err != null ? reject(err) : resolve(r), pred);
@ -90,7 +73,6 @@ const preAuthorize = async (req, res, next) => {
return;
}
if (locals.skip) return;
if (staticPathsRE.test(req.path)) results.push(true);
if (requireAdmin) {
// Filter out all 'true' entries to prevent plugin authors from accidentally granting admin
// privileges to the general public.

View File

@ -100,13 +100,13 @@ const generateLocaleIndex = (locales) => {
};
exports.expressCreateServer = (n, args, cb) => {
exports.expressPreSession = async (hookName, {app}) => {
// regenerate locales on server restart
const locales = getAllLocales();
const localeIndex = generateLocaleIndex(locales);
exports.availableLangs = getAvailableLangs(locales);
args.app.get('/locales/:locale', (req, res) => {
app.get('/locales/:locale', (req, res) => {
// works with /locale/en and /locale/en.json requests
const locale = req.params.locale.split('.')[0];
if (Object.prototype.hasOwnProperty.call(exports.availableLangs, locale)) {
@ -118,11 +118,9 @@ exports.expressCreateServer = (n, args, cb) => {
}
});
args.app.get('/locales.json', (req, res) => {
app.get('/locales.json', (req, res) => {
res.setHeader('Cache-Control', `public, max-age=${settings.maxAge}`);
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.send(localeIndex);
});
return cb();
};

View File

@ -77,6 +77,11 @@ describe(__filename, function () {
settings.requireAuthorization = false;
await agent.get('/admin/').auth('admin', 'admin-password').expect(200);
});
it('authn authz anonymous /robots.txt -> 200', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
await agent.get('/robots.txt').expect(200);
});
it('authn authz user / -> 403', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;