The web SSH terminal and the credential API reported `ctfuser` for all 16
challenges, but only the 6 native GEMASTIK XVIII images provision ctfuser.
Every imported XVI/XVII image does `RUN echo root:${PASSWORD} | chpasswd`,
so 10 of 16 participant logins were refused with "Permission denied".
Root causes (all the same class of bug - login hardcoded in the wrong layer):
- main.py websocket ssh handler read st["ssh_user"], a single team-wide value
defaulting to ctfuser, instead of the per-challenge registry field
- /api/credential proxied the global receiver on :18080, which only knows the
6 native challenges, so the other 10 returned "Invalid challenge"
- team.html hardcoded the challenge picker to those same 6 challenges, making
the other 10 unreachable from the terminal entirely
- index.html rendered `<b>ctfuser</b>` and a stale hardcoded SSH port table
Fixes:
- orch.challenge_credential()/all_teams() read the TEAM's state.json, which
holds the same per-challenge password the panel chpasswds
- gen_receiver_services.py injects SSH_USER_<port> from the registry so the
receiver's /credential endpoint agrees with the panel
- receiver Challenge.credentials() honours SSH_USER_<port> (ctfuser fallback)
- new /api/team/{idx}/own-challenges feeds the picker; targets now carry
challenge + ssh_user
- UI takes user and port from the server instead of hardcoding them
Verified: 32/32 credential payloads correct across teams 1-2, and 32/32 real
paramiko SSH logins succeed with whoami confirming the expected account.
Also adds bulk team delete: POST /api/teams/bulk-delete runs one background
thread and is polled via GET /api/teams/bulk-delete/{job_id}, plus per-team
checkboxes with select-all/clear in the UI. Deletion must stay sequential
because delete_team() regenerates shared artifacts at the end.
104 lines
4.7 KiB
JavaScript
104 lines
4.7 KiB
JavaScript
// Functional DOM test of the bulk-delete checkbox UI.
|
|
// Loads the REAL index.html into jsdom, stubs fetch, and drives the actual
|
|
// render + selection functions. Proves: checkboxes render, select-all works,
|
|
// the bar appears only when a selection exists, and the payload is right.
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { JSDOM } = require(path.join('/tmp/uitest/node_modules/jsdom'));
|
|
|
|
const html = fs.readFileSync('/opt/gemastik18-final/panel/static/index.html', 'utf8');
|
|
|
|
// Fake 4 teams, mirroring the real API shape.
|
|
const FAKE_TEAMS = {
|
|
teams: [
|
|
{ index: 1, label: 'Max', status: 'running', ports: {}, domain: 'max.attackdefense.imrnes.team' },
|
|
{ index: 2, label: 'Aryma', status: 'running', ports: {}, domain: 'aryma.attackdefense.imrnes.team' },
|
|
{ index: 3, label: 'Ken', status: 'running', ports: {}, domain: 'ken.attackdefense.imrnes.team' },
|
|
],
|
|
};
|
|
|
|
const calls = [];
|
|
const dom = new JSDOM(html, { runScripts: 'dangerously', url: 'https://attackdefense.imrnes.team/' });
|
|
|
|
dom.window.fetch = async (url, opts) => {
|
|
calls.push({ url: String(url), method: (opts && opts.method) || 'GET', body: opts && opts.body });
|
|
const u = String(url);
|
|
if (u.endsWith('/api/teams') && !(opts && opts.method)) {
|
|
return { ok: true, json: async () => FAKE_TEAMS };
|
|
}
|
|
if (u.includes('/api/teams/bulk-delete/') && !(opts && opts.method)) {
|
|
return { ok: true, json: async () => ({ state: 'done', done: [{ team: 2 }], errors: {} }) };
|
|
}
|
|
return { ok: true, json: async () => ({ ok: true }) };
|
|
};
|
|
dom.window.confirm = () => true;
|
|
|
|
const w = dom.window, d = w.document;
|
|
// jsdom keeps top-level `let` out of window, so read it through eval in the page.
|
|
const sel_set = () => w.eval('TEAM_SELECTED');
|
|
const call = (fn) => w.eval(`${fn}()`);
|
|
let pass = 0, fail = 0;
|
|
const ok = (name, cond, extra) => {
|
|
if (cond) { pass++; console.log(' PASS ' + name); }
|
|
else { fail++; console.log(' FAIL ' + name + (extra ? ' -> ' + extra : '')); }
|
|
};
|
|
|
|
(async () => {
|
|
// Drive the real page init: it calls loadTeams on DOMContentLoaded.
|
|
await w.loadTeams();
|
|
|
|
const boxes = () => Array.from(d.querySelectorAll('input[id^="teamSel"]'));
|
|
ok('one checkbox per team rendered', boxes().length === 3, 'got ' + boxes().length);
|
|
ok('checkbox id encodes the team index', boxes()[0] && boxes()[0].id === 'teamSel1',
|
|
boxes()[0] && boxes()[0].id);
|
|
ok('checkbox onchange passes the index', boxes()[0] && /toggleTeamSelect\(1,/.test(boxes()[0].getAttribute('onchange') || ''),
|
|
boxes()[0] && boxes()[0].getAttribute('onchange'));
|
|
|
|
// Bar hidden with no selection.
|
|
const bar = d.getElementById('bulkDelBar');
|
|
ok('bar exists', !!bar);
|
|
ok('bar hidden when nothing selected', bar && bar.style.display === 'none',
|
|
bar && bar.style.display);
|
|
|
|
// Tick team 2 -> bar appears, count updates.
|
|
boxes()[1].checked = true;
|
|
boxes()[1].dispatchEvent(new w.Event('change', { bubbles: true }));
|
|
ok('bar shows after first tick', bar.style.display !== 'none', bar.style.display);
|
|
ok('TEAM_SELECTED holds team2', sel_set().has(2) && sel_set().size === 1,
|
|
'size=' + sel_set().size);
|
|
ok('bar mentions 1 team', /1\b/.test(bar.textContent), JSON.stringify(bar.textContent.trim().slice(0, 60)));
|
|
|
|
// Select all -> every team ticked. The button drives selectAllTeams(true).
|
|
const allBtn = Array.from(d.querySelectorAll('button'))
|
|
.find(b => /selectAllTeams\(true\)/.test(b.getAttribute('onclick') || ''));
|
|
ok('select-all button exists', !!allBtn);
|
|
allBtn.click();
|
|
ok('select-all ticks every box', boxes().every(b => b.checked));
|
|
ok('TEAM_SELECTED has all 3', sel_set().size === 3, 'size=' + sel_set().size);
|
|
ok('bar shows 3 teams', /3/.test(bar.textContent));
|
|
|
|
// Bulk delete issues ONE POST with the selected indices.
|
|
calls.length = 0;
|
|
const goBtn = Array.from(d.querySelectorAll('#bulkDelBar button'))
|
|
.find(b => /bulkDeleteTeams\(\)/.test(b.getAttribute('onclick') || ''));
|
|
ok('bulk delete button appears in the bar', !!goBtn);
|
|
goBtn.click();
|
|
await new Promise(r => setTimeout(r, 400));
|
|
const post = calls.find(c => c.url.includes('bulk-delete') && c.method === 'POST');
|
|
ok('bulk delete POSTs once', !!post, JSON.stringify(calls.map(c => c.method + ' ' + c.url)));
|
|
if (post) {
|
|
const payload = JSON.parse(post.body);
|
|
ok('payload carries all 3 indices', JSON.stringify(payload.indices) === '[1,2,3]',
|
|
JSON.stringify(payload));
|
|
}
|
|
|
|
// Clear selection hides the bar again.
|
|
call('clearTeamSelection');
|
|
ok('clear empties selection', sel_set().size === 0);
|
|
ok('bar hidden after clear', bar.style.display === 'none', bar.style.display);
|
|
ok('checkboxes cleared too', boxes().every(b => !b.checked));
|
|
|
|
console.log(`\n ${pass} passed, ${fail} failed`);
|
|
process.exit(fail === 0 ? 0 : 1);
|
|
})();
|