Appearance
Security Findings
A defensive review of weaknesses in the platform, first written from the code at commit daeb43f, re-checked against main at 220e6d3, and re-checked again at 6971373. Each has a severity (the reviewer's judgment), the evidence, the impact and a recommended fix. There is no exploit code here. The Security Architecture page lists the controls that are already in place.
Read before hosting this documentation publicly
This page names real weaknesses in a running system. The repository is public, but a tidy, indexed weakness list next to deployment details is far more useful to an attacker than source code alone. If you publish this site at cyberonline.site:
- Put the whole site behind Cloudflare Access (or HTTP auth), so only your team and the panel can read it. The hosting guide shows how.
- Or drop this page and the threat model from the public build and keep them internal.
- Fix or accept the High items below before the live host is reachable by students.
- Nothing here is a secret value: no passwords, tokens or flags are printed. The risk is the collected roadmap, not any single line.
Severity summary
| Severity | Count | Items |
|---|---|---|
| High (verify on live host) | 2 | F1, F2 |
| Medium | 9 | F3 to F10, F19 (F6 narrowed; F9 partly addressed) |
| Low | 6 open, 1 partly fixed, 1 fixed | F11 to F13, F15 to F17 open; F14 partly fixed; F18 fixed |
Several depend on live configuration the repository cannot show; those say verify.
Changes since the 220e6d3 review (re-checked at 6971373)
- F6 narrowed by pull requests 308 and 309: every lab network now has an egress ACL that rejects all outbound traffic except the lab's own subnet and the Wazuh manager on TCP 1514/1515, and the reaper re-applies it. Pull request 303 adds a script that binds the Wazuh API to loopback inside its container and rotates the built-in accounts. Enrollment itself is still passwordless.
- F9 partly addressed further: pull request 269 type-checks the portal on every pull request; pull request 306 repaired the failing portal tests. CI still runs no
pytestorjest. - F14 partly fixed by pull requests 313 and 314: the portal now sends framing,
nosniffandReferrer-Policyheaders and no longer advertises Next.js. There is still no HSTS or full Content Security Policy. - New: F19, open advisories against the portal's Next.js version, with interim controls from pull requests 312 and 315. Resolved on
mainby pull request 316 (Next.js 15.5.28). - New controls that are not findings: lab provisioning now fails closed when the SIEM agents cannot connect or a stale agent name is still registered (pull requests 195, 197, 211, 275); an internal CA for the Wazuh API that a release must verify (pull request 275); Admin user creation no longer emails passwords (pull request 198). See Security Architecture.
Changes at the 220e6d3 review
- F18 fixed by pull request 146: logout now sends
id_token_hint. - F9 partly addressed by pull request 143: CI now fails on an undeclared Python dependency, but still runs no test suite.
- New controls that close gaps the first review did not list as findings: required TOTP MFA for every user (SEC-03), a password policy with a common-password blocklist and temporary brute-force lockout (SEC-04). See Login, Sessions & Tokens.
- F2 matters more now: MFA enrolment, the password policy and the login theme are stored in the same un-exported Keycloak database.
High
F1. Keycloak admin and master-realm routes exposed through the gateway
- Evidence: the repository nginx config proxies
~ ^/admin/(serverinfo|realms|master)and~ ^/realms/masterto Keycloak, added (per manual Issue 12) so the admin console works over the tunnel. The network appendix says the admin console should be reachable by SSH tunnel only. - Impact: the Keycloak admin surface and master realm would be reachable on the public hostname, a large increase in attack surface for the identity provider.
- Fix: do not expose admin or master-realm paths through the public origin; reach the console over SSH. If remote console access is required, put it behind Cloudflare Access. Verify the live nginx config, which may differ.
- Source:
cyberrange/deploy/guacamole/nginx-ampere.conf,manual/appendix-network-map.md,manual/09-known-issues.md. Discrepancy D3.
F2. Keycloak running in development mode
- Evidence: the unit inside the gateway container runs
kc.sh start-devwith noKC_DBconfigured, which uses the embedded H2 dev-file database and development defaults. - Impact:
start-devis not for production; the H2 file database and dev settings are not sized or hardened for real use, and identity data has no separate managed store. - Fix: run
kc.sh startin production mode with an external database (or an explicitly accepted, backed-up H2), behind HTTPS with production settings. Verify the live host. Until then, export the realm after any realm change: since SEC-03 and SEC-04 a lost Keycloak database also loses every user's enrolled authenticator, the password policy and the login theme. - Source:
cyberrange/deploy/keycloak/keycloak.service. Open question 3.
Medium
F3. Access token in the terminal WebSocket URL
- Evidence: the terminal connects to
/api/ssh-websocket?token=<access token>&pod_id=...; the gateway nginx sets no customaccess_log, so its default access log would record the query string. - Impact: access tokens can land in proxy or Cloudflare logs and browser history.
- Fix: pass the token in a subprotocol or a short-lived one-time ticket instead of the query string; disable or scrub access logging for this location. Verify whether gateway access logs are on.
- Source:
cyberrange/portal/src/components/terminal/XtermView.tsx,bridge-src/bridge.py,nginx-ampere.conf.
F4. Access token exposed to browser JavaScript
- Evidence: the NextAuth session callback puts the access token on the session, so
useSession()exposes it to page scripts (needed for the terminal WebSocket URL). - Impact: any XSS in the portal could read a valid 12-hour token.
- Fix: keep the token server-side; mint a narrow, short-lived credential just for the terminal instead of surfacing the full access token.
- Source:
cyberrange/portal/src/lib/auth.ts.
F5. Direct access grants enabled on the portal client
- Evidence:
create_keycloak_realm.shcreates theportalclient withdirectAccessGrantsEnabled=true(the resource-owner password grant). - Impact: allows username/password token requests that bypass the browser login flow and its protections.
- Fix: disable direct access grants unless a specific flow needs them. Verify the live realm. The MFA change (pull request 146) left this flow unchanged on purpose; an account that has not enrolled an authenticator can still get a token by password grant. The verification scripts moved to a separate
cyberrange-verifyclient, which removes their need for it onportal. - Source:
cyberrange/deploy/keycloak/create_keycloak_realm.sh. Open question 6.
F6. Passwordless Wazuh enrollment reachable from every lab Narrowed
- Evidence: agents auto-enroll without a password (
<use_password>no</use_password>); every lab network is peered to the monitoring network so it can reach the manager. Since pull request 308 the lab's egress ACL allows only TCP 1514/1515 to the manager (plus the lab's own subnet) and rejects everything else, including the Wazuh API port 55000 and the uplink DNS resolver.harden_wazuh_api.sh(pull request 303) binds the API to127.0.0.1inside the manager container and rotates the built-inwazuhandwazuh-wuiaccounts. - Impact: a student with root in a lab can still reach the enrollment port, so could register spurious agents or send noise to the shared SIEM. The API itself is no longer reachable from a lab once the ACL is in place and the hardening script has run.
- Fix: use an enrollment password or per-agent keys; restrict enrollment to provisioning. Verify live
use_password, and thatharden_wazuh_api.shhas been run on each host: its evidence record (WAZUH-API-286-loopback-and-rotation.md) is still marked Pending. - Source:
manual/06-scoring.md,pod_net.py(pod_acl_rules),deploy/host/harden_wazuh_api.sh,cyberrange/docs/evidence/WAZUH-API-286-loopback-and-rotation.md. Open question 7.
F7. Admin token can open any lab's terminal
- Evidence: the bridge authorizes a connection by calling
/pods/{id}/status, which allows the owner or an admin. - Impact: an admin token can open a shell into any student's lab by crafting the URL; no admin page does this, but the capability exists. Instructors cannot.
- Fix: decide whether admins should have lab shell access; if not, require ownership in the bridge. Document it if intended.
- Source:
cyberrange/bridge-src/bridge.py,pods_router.pyrequire_owner_or_admin.
F8. Host operator SSH key installed in every lab container
- Evidence: provisioning installs
/home/llms_admin/.ssh/id_rsa.pubintoroot'sauthorized_keysin all three containers, although scoring uses the LXD control plane, not SSH. - Impact: an unused standing access path from the host operator key into every lab's root account.
- Fix: remove the key installation if nothing uses it; otherwise document why it is needed.
- Source:
cyberrange/src/provisioning/provision.py.
F9. No automated tests in CI Partly addressed
- Evidence: the release workflow builds and packages but runs no
pytestorjest; tests run only on developer machines. Since pull request 143 it does runcheck_runtime_deps.py, which fails the build on an undeclared Python dependency. Since pull request 269 a second workflow,portal-typecheck.yml, runsnpx tsc --noEmiton every pull request (added after a type error that Jest did not catch broke every release build). Pull request 306 repaired the portal's Lab History and middleware Jest expectations. - Impact: a regression (including a security regression, such as a broken route guard) can still be released without a test gate. A type check catches type errors only. At
220e6d3,mainhad 4 failing backend tests and 1 failing portal test and nothing blocked the merge; nothing in CI would block a new failure either. - Fix: run the backend and portal suites in CI and block release on failure; make
portal-typechecka required status check. - Source:
.github/workflows/ampere-release.yml,.github/workflows/portal-typecheck.yml.
F10. Unbounded scoring-table growth
- Evidence: the poller inserts a
milestone_verificationrow and anaudit_logrow for every check, includingFAIL, every 3 seconds per unpassed milestone of each active lab; there is no retention job. - Impact: SQLite grows continuously during labs; over time this affects size and query cost, and
GET /pods/{id}/milestonesreturns the full history. - Fix: record only status changes or thin old
FAILrows; add a retention job and periodicVACUUM. Team decision. - Source:
score_poller.py,scoring.py. Pull request 268'ssourcecolumn (schema v15) labels each row but still writes one per check.
F19. Portal framework security advisories (resolved on main)
- Status: resolved after this snapshot. Pull request 316 (merged into
mainasba1e1e5, 2026-10-23) upgraded the portal tonext15.5.28 and React 19, pinnedpostcss8.5.31, and setpoweredByHeader: falsewith path-scoped framing headers.npm audit --omit=dev --package-lock-onlyonba1e1e5reports 0 vulnerabilities. - Before the fix: at
6971373the portal pinnednext^14.2.35, andnpm audit --omit=devreported 2 production advisories:next(critical, including remote code execution in the Image Optimization API, cache poisoning and middleware bypasses) and its bundledpostcss(high). Pull requests 312 and 315 added interim controls: the Image Optimization API is disabled (images: { unoptimized: true }, still set),next-authandaxioswere bumped, and annpm run audit:prodscript was added. - Remaining: keep
npm run audit:prodin CI so new advisories are caught. - Source:
cyberrange/portal/package.json,package-lock.json,next.config.mjsatba1e1e5.
Low
| # | Finding | Impact | Fix | Source |
|---|---|---|---|---|
| F11 | scenario_id is not validated and is interpolated into Kali cloud-init YAML | YAML/cloud-init injection into the student's own container (already has sudo); low impact | Validate against the catalog IDs (01,06,09,11) | models.py, provision.py |
| F12 | Legacy Guacamole API routes still reachable | Dead attack surface (/api/guac-launch, /api/guac-websocket; guacd not deployed) | Remove the unused routes and dependency | portal/src/app/api/guac-* |
| F13 | /capacity is public | Leaks free RAM, active pod count and limits without auth | Require a role, or reduce the detail | pods_router.py |
| F14 | Partly fixed Few security headers on the portal origin | Was: no headers at all. Since pull requests 313 and 314 Next.js sends X-Frame-Options: DENY and frame-ancestors 'none' (relaxed to same-origin only for /lab/dvwa/*), nosniff and Referrer-Policy, and drops X-Powered-By. Still no HSTS and no Content Security Policy beyond frame-ancestors on portal pages | Add HSTS and a script/style CSP (at nginx or in next.config.mjs) | portal/next.config.mjs, nginx-ampere.conf |
| F15 | Service logs in /tmp | Logs lost on reboot; readability depends on umask | Move to a persistent, access-controlled location; ship off-host | systemd units |
| F16 | urllib3 InsecureRequestWarning suppressed globally | Hides genuine TLS-verification warnings elsewhere | Scope the suppression to the intended call | provision_api_fastapi.py |
| F17 | BFF path parameters interpolated unencoded | Low risk given upstream is trusted and IDs are typed; still worth hardening | encodeURIComponent path params | apiProxy.ts |
| F18 | Fixed id_token_hint never sent on logout | Was: federated logout might not fully end the Keycloak session | Fixed in pull request 146: id_token kept server-side and sent as the hint | auth.ts, federated-logout route |
Notes on scope
- Severities are the reviewer's judgment from the code, not a formal assessment or a live test.
- "Verify" items depend on the live host and are collected in Open Questions.
- No secret values (passwords, tokens, flags) appear anywhere in this documentation. Lab default credentials are mentioned only as the subject of a scenario's vulnerability.