362 lines
15 KiB
JavaScript
362 lines
15 KiB
JavaScript
/* global __dirname, __filename, afterEach, before, beforeEach, clearTimeout, describe, it, require, setTimeout */
|
|
|
|
function m(mod) { return __dirname + '/../../../src/' + mod; }
|
|
|
|
const assert = require('assert').strict;
|
|
const common = require('../common');
|
|
const io = require(m('node_modules/socket.io-client'));
|
|
const padManager = require(m('node/db/PadManager'));
|
|
const plugins = require(m('static/js/pluginfw/plugin_defs'));
|
|
const setCookieParser = require(m('node_modules/set-cookie-parser'));
|
|
const settings = require(m('node/utils/Settings'));
|
|
|
|
const logger = common.logger;
|
|
|
|
// Waits for and returns the next named socket.io event. Rejects if there is any error while waiting
|
|
// (unless waiting for that error event).
|
|
const getSocketEvent = async (socket, event) => {
|
|
const errorEvents = [
|
|
'error',
|
|
'connect_error',
|
|
'connect_timeout',
|
|
'reconnect_error',
|
|
'reconnect_failed',
|
|
];
|
|
const handlers = {};
|
|
let timeoutId;
|
|
return new Promise((resolve, reject) => {
|
|
timeoutId = setTimeout(() => reject(new Error(`timed out waiting for ${event} event`)), 1000);
|
|
for (const event of errorEvents) {
|
|
handlers[event] = (errorString) => {
|
|
logger.debug(`socket.io ${event} event: ${errorString}`);
|
|
reject(new Error(errorString));
|
|
};
|
|
}
|
|
// This will overwrite one of the above handlers if the user is waiting for an error event.
|
|
handlers[event] = (...args) => {
|
|
logger.debug(`socket.io ${event} event`);
|
|
if (args.length > 1) return resolve(args);
|
|
resolve(args[0]);
|
|
};
|
|
Object.entries(handlers).forEach(([event, handler]) => socket.on(event, handler));
|
|
}).finally(() => {
|
|
clearTimeout(timeoutId);
|
|
Object.entries(handlers).forEach(([event, handler]) => socket.off(event, handler));
|
|
});
|
|
};
|
|
|
|
// Establishes a new socket.io connection. Passes the cookies from the `set-cookie` header(s) in
|
|
// `res` (which may be nullish) to the server. Returns a socket.io Socket object.
|
|
const connect = async (res) => {
|
|
// Convert the `set-cookie` header(s) into a `cookie` header.
|
|
const resCookies = (res == null) ? {} : setCookieParser.parse(res, {map: true});
|
|
const reqCookieHdr = Object.entries(resCookies).map(([name, cookie]) => {
|
|
return `${name}=${encodeURIComponent(cookie.value)}`;
|
|
}).join('; ');
|
|
|
|
logger.debug('socket.io connecting...');
|
|
const socket = io(`${common.baseUrl}/`, {
|
|
forceNew: true, // Different tests will have different query parameters.
|
|
path: '/socket.io',
|
|
// socketio.js-client on node.js doesn't support cookies (see https://git.io/JU8u9), so the
|
|
// express_sid cookie must be passed as a query parameter.
|
|
query: {cookie: reqCookieHdr},
|
|
});
|
|
try {
|
|
await getSocketEvent(socket, 'connect');
|
|
} catch (e) {
|
|
socket.close();
|
|
throw e;
|
|
}
|
|
logger.debug('socket.io connected');
|
|
|
|
return socket;
|
|
};
|
|
|
|
// Helper function to exchange CLIENT_READY+CLIENT_VARS messages for the named pad.
|
|
// Returns the CLIENT_VARS message from the server.
|
|
const handshake = async (socket, padID) => {
|
|
logger.debug('sending CLIENT_READY...');
|
|
socket.send({
|
|
component: 'pad',
|
|
type: 'CLIENT_READY',
|
|
padId: padID,
|
|
sessionID: null,
|
|
token: 't.12345',
|
|
protocolVersion: 2,
|
|
});
|
|
logger.debug('waiting for CLIENT_VARS response...');
|
|
const msg = await getSocketEvent(socket, 'message');
|
|
logger.debug('received CLIENT_VARS message');
|
|
return msg;
|
|
};
|
|
|
|
describe(__filename, function() {
|
|
let agent;
|
|
let authorize;
|
|
const backups = {};
|
|
const cleanUpPads = async () => {
|
|
const padIds = ['pad', 'other-pad', 'päd'];
|
|
await Promise.all(padIds.map(async (padId) => {
|
|
if (await padManager.doesPadExist(padId)) {
|
|
const pad = await padManager.getPad(padId);
|
|
await pad.remove();
|
|
}
|
|
}));
|
|
};
|
|
let socket;
|
|
|
|
before(async function() { agent = await common.init(); });
|
|
beforeEach(async function() {
|
|
backups.hooks = {};
|
|
for (const hookName of ['preAuthorize', 'authenticate', 'authorize']) {
|
|
backups.hooks[hookName] = plugins.hooks[hookName];
|
|
plugins.hooks[hookName] = [];
|
|
}
|
|
backups.settings = {};
|
|
for (const setting of ['editOnly', 'requireAuthentication', 'requireAuthorization', 'users']) {
|
|
backups.settings[setting] = settings[setting];
|
|
}
|
|
settings.editOnly = false;
|
|
settings.requireAuthentication = false;
|
|
settings.requireAuthorization = false;
|
|
settings.users = {
|
|
admin: {password: 'admin-password', is_admin: true},
|
|
user: {password: 'user-password'},
|
|
};
|
|
assert(socket == null);
|
|
authorize = () => true;
|
|
plugins.hooks.authorize = [{hook_fn: (hookName, {req}, cb) => {
|
|
return cb([authorize(req)]);
|
|
}}];
|
|
await cleanUpPads();
|
|
});
|
|
afterEach(async function() {
|
|
if (socket) socket.close();
|
|
socket = null;
|
|
await cleanUpPads();
|
|
Object.assign(plugins.hooks, backups.hooks);
|
|
Object.assign(settings, backups.settings);
|
|
});
|
|
|
|
describe('Normal accesses', function() {
|
|
it('!authn anonymous cookie /p/pad -> 200, ok', async function() {
|
|
const res = await agent.get('/p/pad').expect(200);
|
|
socket = await connect(res);
|
|
const clientVars = await handshake(socket, 'pad');
|
|
assert.equal(clientVars.type, 'CLIENT_VARS');
|
|
});
|
|
it('!authn !cookie -> ok', async function() {
|
|
socket = await connect(null);
|
|
const clientVars = await handshake(socket, 'pad');
|
|
assert.equal(clientVars.type, 'CLIENT_VARS');
|
|
});
|
|
it('!authn user /p/pad -> 200, ok', async function() {
|
|
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
const clientVars = await handshake(socket, 'pad');
|
|
assert.equal(clientVars.type, 'CLIENT_VARS');
|
|
});
|
|
it('authn user /p/pad -> 200, ok', async function() {
|
|
settings.requireAuthentication = true;
|
|
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
const clientVars = await handshake(socket, 'pad');
|
|
assert.equal(clientVars.type, 'CLIENT_VARS');
|
|
});
|
|
it('authz user /p/pad -> 200, ok', async function() {
|
|
settings.requireAuthentication = true;
|
|
settings.requireAuthorization = true;
|
|
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
const clientVars = await handshake(socket, 'pad');
|
|
assert.equal(clientVars.type, 'CLIENT_VARS');
|
|
});
|
|
it('supports pad names with characters that must be percent-encoded', async function() {
|
|
settings.requireAuthentication = true;
|
|
// requireAuthorization is set to true here to guarantee that the user's padAuthorizations
|
|
// object is populated. Technically this isn't necessary because the user's padAuthorizations
|
|
// is currently populated even if requireAuthorization is false, but setting this to true
|
|
// ensures the test remains useful if the implementation ever changes.
|
|
settings.requireAuthorization = true;
|
|
const encodedPadId = encodeURIComponent('päd');
|
|
const res = await agent.get(`/p/${encodedPadId}`).auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
const clientVars = await handshake(socket, 'päd');
|
|
assert.equal(clientVars.type, 'CLIENT_VARS');
|
|
});
|
|
});
|
|
|
|
describe('Abnormal access attempts', function() {
|
|
it('authn anonymous /p/pad -> 401, error', async function() {
|
|
settings.requireAuthentication = true;
|
|
const res = await agent.get('/p/pad').expect(401);
|
|
// Despite the 401, try to create the pad via a socket.io connection anyway.
|
|
socket = await connect(res);
|
|
const message = await handshake(socket, 'pad');
|
|
assert.equal(message.accessStatus, 'deny');
|
|
});
|
|
it('authn !cookie -> error', async function() {
|
|
settings.requireAuthentication = true;
|
|
socket = await connect(null);
|
|
const message = await handshake(socket, 'pad');
|
|
assert.equal(message.accessStatus, 'deny');
|
|
});
|
|
it('authorization bypass attempt -> error', async function() {
|
|
// Only allowed to access /p/pad.
|
|
authorize = (req) => req.path === '/p/pad';
|
|
settings.requireAuthentication = true;
|
|
settings.requireAuthorization = true;
|
|
// First authenticate and establish a session.
|
|
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
// Accessing /p/other-pad should fail, despite the successful fetch of /p/pad.
|
|
const message = await handshake(socket, 'other-pad');
|
|
assert.equal(message.accessStatus, 'deny');
|
|
});
|
|
});
|
|
|
|
describe('Authorization levels via authorize hook', function() {
|
|
beforeEach(async function() {
|
|
settings.requireAuthentication = true;
|
|
settings.requireAuthorization = true;
|
|
});
|
|
|
|
it("level='create' -> can create", async function() {
|
|
authorize = () => 'create';
|
|
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
const clientVars = await handshake(socket, 'pad');
|
|
assert.equal(clientVars.type, 'CLIENT_VARS');
|
|
assert.equal(clientVars.data.readonly, false);
|
|
});
|
|
it('level=true -> can create', async function() {
|
|
authorize = () => true;
|
|
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
const clientVars = await handshake(socket, 'pad');
|
|
assert.equal(clientVars.type, 'CLIENT_VARS');
|
|
assert.equal(clientVars.data.readonly, false);
|
|
});
|
|
it("level='modify' -> can modify", async function() {
|
|
await padManager.getPad('pad'); // Create the pad.
|
|
authorize = () => 'modify';
|
|
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
const clientVars = await handshake(socket, 'pad');
|
|
assert.equal(clientVars.type, 'CLIENT_VARS');
|
|
assert.equal(clientVars.data.readonly, false);
|
|
});
|
|
it("level='create' settings.editOnly=true -> unable to create", async function() {
|
|
authorize = () => 'create';
|
|
settings.editOnly = true;
|
|
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
const message = await handshake(socket, 'pad');
|
|
assert.equal(message.accessStatus, 'deny');
|
|
});
|
|
it("level='modify' settings.editOnly=false -> unable to create", async function() {
|
|
authorize = () => 'modify';
|
|
settings.editOnly = false;
|
|
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
const message = await handshake(socket, 'pad');
|
|
assert.equal(message.accessStatus, 'deny');
|
|
});
|
|
it("level='readOnly' -> unable to create", async function() {
|
|
authorize = () => 'readOnly';
|
|
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
const message = await handshake(socket, 'pad');
|
|
assert.equal(message.accessStatus, 'deny');
|
|
});
|
|
it("level='readOnly' -> unable to modify", async function() {
|
|
await padManager.getPad('pad'); // Create the pad.
|
|
authorize = () => 'readOnly';
|
|
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
const clientVars = await handshake(socket, 'pad');
|
|
assert.equal(clientVars.type, 'CLIENT_VARS');
|
|
assert.equal(clientVars.data.readonly, true);
|
|
});
|
|
});
|
|
|
|
describe('Authorization levels via user settings', function() {
|
|
beforeEach(async function() {
|
|
settings.requireAuthentication = true;
|
|
});
|
|
|
|
it('user.canCreate = true -> can create and modify', async function() {
|
|
settings.users.user.canCreate = true;
|
|
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
const clientVars = await handshake(socket, 'pad');
|
|
assert.equal(clientVars.type, 'CLIENT_VARS');
|
|
assert.equal(clientVars.data.readonly, false);
|
|
});
|
|
it('user.canCreate = false -> unable to create', async function() {
|
|
settings.users.user.canCreate = false;
|
|
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
const message = await handshake(socket, 'pad');
|
|
assert.equal(message.accessStatus, 'deny');
|
|
});
|
|
it('user.readOnly = true -> unable to create', async function() {
|
|
settings.users.user.readOnly = true;
|
|
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
const message = await handshake(socket, 'pad');
|
|
assert.equal(message.accessStatus, 'deny');
|
|
});
|
|
it('user.readOnly = true -> unable to modify', async function() {
|
|
await padManager.getPad('pad'); // Create the pad.
|
|
settings.users.user.readOnly = true;
|
|
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
const clientVars = await handshake(socket, 'pad');
|
|
assert.equal(clientVars.type, 'CLIENT_VARS');
|
|
assert.equal(clientVars.data.readonly, true);
|
|
});
|
|
it('user.readOnly = false -> can create and modify', async function() {
|
|
settings.users.user.readOnly = false;
|
|
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
const clientVars = await handshake(socket, 'pad');
|
|
assert.equal(clientVars.type, 'CLIENT_VARS');
|
|
assert.equal(clientVars.data.readonly, false);
|
|
});
|
|
it('user.readOnly = true, user.canCreate = true -> unable to create', async function() {
|
|
settings.users.user.canCreate = true;
|
|
settings.users.user.readOnly = true;
|
|
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
const message = await handshake(socket, 'pad');
|
|
assert.equal(message.accessStatus, 'deny');
|
|
});
|
|
});
|
|
|
|
describe('Authorization level interaction between authorize hook and user settings', function() {
|
|
beforeEach(async function() {
|
|
settings.requireAuthentication = true;
|
|
settings.requireAuthorization = true;
|
|
});
|
|
|
|
it('authorize hook does not elevate level from user settings', async function() {
|
|
settings.users.user.readOnly = true;
|
|
authorize = () => 'create';
|
|
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
const message = await handshake(socket, 'pad');
|
|
assert.equal(message.accessStatus, 'deny');
|
|
});
|
|
it('user settings does not elevate level from authorize hook', async function() {
|
|
settings.users.user.readOnly = false;
|
|
settings.users.user.canCreate = true;
|
|
authorize = () => 'readOnly';
|
|
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
|
socket = await connect(res);
|
|
const message = await handshake(socket, 'pad');
|
|
assert.equal(message.accessStatus, 'deny');
|
|
});
|
|
});
|
|
});
|