263 lines
12 KiB
JavaScript
263 lines
12 KiB
JavaScript
import assert from 'assert';
|
|
|
|
import { getContainer } from './helpers/container.js';
|
|
import { mintAsapToken } from './helpers/jwt.js';
|
|
import { setAccessManagerResponse } from './helpers/test_observer.js';
|
|
import { createXmppClient } from './helpers/xmpp_client.js';
|
|
|
|
// VPaaS tenant prefix — mod_muc_auth_ban only calls the access manager for
|
|
// sessions whose jitsi_web_query_prefix starts with this string.
|
|
// The prefix is set from the ?prefix= WebSocket URL param by mod_jitsi_session.
|
|
const VPAAS_PREFIX = 'vpaas-magic-cookie-test';
|
|
|
|
// Tokens are RS256 (deterministic): same payload in the same second → same
|
|
// token string. Use a per-test jti so each test gets a unique token and cannot
|
|
// accidentally match a token that was cached as banned by an earlier test.
|
|
let _tokenCounter = 0;
|
|
const freshToken = () => mintAsapToken({ room: '*',
|
|
jti: `ban-test-${++_tokenCounter}` });
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Creates an XMPP client that identifies as a VPaaS session.
|
|
* Passes ?prefix=<VPAAS_PREFIX>&token=<token> in the WebSocket URL so that
|
|
* mod_jitsi_session populates jitsi_web_query_prefix, which mod_muc_auth_ban
|
|
* uses to decide whether to call the external access manager.
|
|
*
|
|
* @param {string} token A valid login JWT (RS256 / HS256).
|
|
* @returns {Promise<XmppTestClient>}
|
|
*/
|
|
function createVpaasClient(token) {
|
|
return createXmppClient({
|
|
params: { prefix: VPAAS_PREFIX,
|
|
token }
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Asserts that a VPaaS session is banned, whichever way the ban surfaces.
|
|
*
|
|
* mod_muc_auth_ban's HTTP callback calls session:close(), and where that lands
|
|
* depends on when the access manager answers. Answering within the same event
|
|
* loop tick as the SASL auth refuses the connection outright; answering a tick
|
|
* later lets the session establish and then closes it. Both are the same ban,
|
|
* and which one happens is timing, so accept either rather than only the first.
|
|
*
|
|
* @param {string} token A login JWT the access manager will reject.
|
|
* @param {string} message Assertion message.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function assertBanned(token, message) {
|
|
let client;
|
|
|
|
try {
|
|
client = await createVpaasClient(token);
|
|
} catch (e) {
|
|
// refused during SASL
|
|
assert.match(String(e), /not-allowed/, message);
|
|
|
|
return;
|
|
}
|
|
|
|
// established, so the close must follow
|
|
const disconnected = await client.waitForDisconnect(5000).then(() => true, () => false);
|
|
|
|
if (!disconnected) {
|
|
await client.disconnect();
|
|
}
|
|
|
|
assert.ok(disconnected, message);
|
|
}
|
|
|
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('mod_muc_auth_ban', () => {
|
|
|
|
afterEach(async () => {
|
|
// Reset the mock access manager to the default (allow, HTTP 200)
|
|
// so that each test starts from a clean state.
|
|
await setAccessManagerResponse({ access: true,
|
|
status: 200 });
|
|
});
|
|
|
|
// ── No token ─────────────────────────────────────────────────────────────
|
|
//
|
|
// mod_muc_auth_ban checks session.auth_token first. When nil it returns
|
|
// nil (not false), so the ban-check event has no effect and auth proceeds.
|
|
|
|
it('user without a token is allowed (ban check is a no-op)', async () => {
|
|
// No token param → session.auth_token is nil → shouldAllow returns nil.
|
|
// VirtualHost "localhost" has allow_empty_token = true so auth still succeeds.
|
|
const c = await createXmppClient();
|
|
|
|
await c.disconnect();
|
|
});
|
|
|
|
// ── Non-VPaaS tenant ─────────────────────────────────────────────────────
|
|
//
|
|
// mod_muc_auth_ban returns true immediately for tenants whose prefix does
|
|
// NOT start with "vpaas-magic-cookie-". The access manager is never called.
|
|
|
|
it('non-VPaaS user with a valid token is allowed without an HTTP check', async () => {
|
|
// Configure the mock to return an error — if the module calls it for
|
|
// non-VPaaS sessions this test will catch that regression.
|
|
await setAccessManagerResponse({ status: 500 });
|
|
|
|
const token = freshToken();
|
|
|
|
// No ?prefix= param → jitsi_web_query_prefix is "" → bypasses VPaaS check.
|
|
const c = await createXmppClient({ params: { token } });
|
|
|
|
await c.disconnect();
|
|
});
|
|
|
|
// ── VPaaS — access granted ────────────────────────────────────────────────
|
|
|
|
it('VPaaS user is allowed when access manager returns access=true', async () => {
|
|
await setAccessManagerResponse({ access: true });
|
|
|
|
const token = freshToken();
|
|
const c = await createVpaasClient(token);
|
|
|
|
// Stay connected for a moment to confirm the async HTTP response does
|
|
// not trigger a session close.
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
await c.disconnect();
|
|
});
|
|
|
|
// ── VPaaS — access denied ─────────────────────────────────────────────────
|
|
//
|
|
// When access=false, mod_muc_auth_ban's HTTP callback calls session:close()
|
|
// and caches the token. The access manager runs on the same Prosody process
|
|
// (loopback), so the callback usually resolves within the same event loop
|
|
// tick as the SASL auth and the ban surfaces as a SASL failure, but under
|
|
// load it can resolve a tick later and close an established session instead.
|
|
// assertBanned accepts either.
|
|
|
|
it('VPaaS user is rejected when access manager returns access=false', async () => {
|
|
await setAccessManagerResponse({ access: false });
|
|
|
|
await assertBanned(freshToken(),
|
|
'VPaaS user must be rejected when access manager returns access=false');
|
|
});
|
|
|
|
// ── Cached ban ────────────────────────────────────────────────────────────
|
|
//
|
|
// When a token is rejected (access=false), mod_muc_auth_ban caches it with a
|
|
// 5-minute TTL. Subsequent connections with the same token hit the Lua LRU
|
|
// cache (no HTTP request) and are rejected immediately — even after the mock
|
|
// access manager is reset to allow. This verifies the cache is active.
|
|
|
|
it('cached banned token is rejected even when access manager is reset to allow', async () => {
|
|
// Step 1: Ban the token — cb fires, token enters the LRU cache.
|
|
await setAccessManagerResponse({ access: false });
|
|
|
|
const token = freshToken();
|
|
|
|
await assertBanned(token, 'initial ban must be rejected');
|
|
|
|
// Step 2: Reset the mock to allow. A fresh token must now succeed,
|
|
// proving the cache — not the mock response — drives the next rejection.
|
|
await setAccessManagerResponse({ access: true });
|
|
|
|
const fresh = await createVpaasClient(freshToken());
|
|
|
|
await fresh.disconnect();
|
|
|
|
// Step 3: Same banned token must still be rejected (cache wins over mock).
|
|
await assertBanned(token,
|
|
'cached banned token must be rejected even when mock is reset to allow');
|
|
});
|
|
|
|
// ── HTTP error — fail open ────────────────────────────────────────────────
|
|
//
|
|
// If the access manager returns a non-200 response, mod_muc_auth_ban logs
|
|
// a warning and does nothing — the session is NOT closed (fail open).
|
|
|
|
it('HTTP error from access manager does not ban the user (fail open)', async () => {
|
|
await setAccessManagerResponse({ status: 500 });
|
|
|
|
const token = freshToken();
|
|
const c = await createVpaasClient(token);
|
|
|
|
// Wait long enough for the async HTTP callback to have fired.
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
// The client must still be connected. waitForDisconnect would reject
|
|
// after the timeout — instead, assert the connection is alive by
|
|
// verifying disconnect() completes cleanly.
|
|
await c.disconnect();
|
|
});
|
|
|
|
// ── Non-JSON 200 — should fail open without crashing ─────────────────────
|
|
//
|
|
// When the access manager returns HTTP 200 but with a non-JSON body,
|
|
// json.decode() returns nil. mod_muc_auth_ban then calls r['access'] on
|
|
// nil, which crashes the callback. The crash must not affect the session
|
|
// (fail open: user stays connected).
|
|
|
|
it('non-JSON 200 from access manager does not crash or ban the user (fail open)', async () => {
|
|
await setAccessManagerResponse({ nonJson: true });
|
|
|
|
const token = freshToken();
|
|
const c = await createVpaasClient(token);
|
|
|
|
// Wait long enough for the async HTTP callback to have fired and crashed.
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
// Session must still be alive despite the callback crash.
|
|
const disconnected = await c.waitForDisconnect(300).then(() => true, () => false);
|
|
|
|
assert.strictEqual(disconnected, false,
|
|
'session must remain alive when access manager returns non-JSON 200');
|
|
|
|
await c.disconnect();
|
|
});
|
|
|
|
// ── Non-JSON 200 — Lua error in callback ─────────────────────────────────
|
|
//
|
|
// cjson.safe returns nil (not an error) for invalid JSON, so json.decode()
|
|
// returns nil when the access manager body is not JSON. Without a nil guard,
|
|
// r['access'] on the nil value crashes the callback with "attempt to index
|
|
// a nil value". This test catches that crash by asserting that no such error
|
|
// appears in the Prosody log during the test window.
|
|
|
|
it('non-JSON 200 does not produce a Lua error in the HTTP callback', async () => {
|
|
const since = Math.floor(Date.now() / 1000);
|
|
|
|
await setAccessManagerResponse({ nonJson: true });
|
|
const token = freshToken();
|
|
const c = await createVpaasClient(token);
|
|
|
|
// Give the async HTTP callback time to fire (and crash if the bug is present).
|
|
await new Promise(resolve => setTimeout(resolve, 600));
|
|
await c.disconnect();
|
|
|
|
// Collect Prosody log output from this test's window.
|
|
const until = Math.floor(Date.now() / 1000) + 1;
|
|
const container = getContainer();
|
|
const stream = await container.logs({ since,
|
|
until });
|
|
const logs = await new Promise((resolve, reject) => {
|
|
const chunks = [];
|
|
const timer = setTimeout(() => resolve(chunks.join('')), 2000);
|
|
|
|
stream.on('data', chunk => chunks.push(chunk.toString()));
|
|
stream.on('end', () => {
|
|
clearTimeout(timer);
|
|
resolve(chunks.join(''));
|
|
});
|
|
stream.on('error', reject);
|
|
});
|
|
|
|
assert.ok(
|
|
!logs.includes('attempt to index a nil value'),
|
|
'mod_muc_auth_ban HTTP callback must not crash on non-JSON 200 response '
|
|
+ '(missing nil guard on json.decode result)'
|
|
);
|
|
});
|
|
|
|
});
|