Skibidimonitor defensive security review
Context
This is an attack-defense service review performed against the local source tree. The objective was to close flag-disclosure paths without changing normal checker behavior: registration, login, team creation, card creation, public snapshots, owner workbench views, exports, and badge cards keep their existing routes and response formats.
No live opponent service was attacked and no real competition flag was accessed. Regression tests use the synthetic value FLAG{private-sentinel}.
Reconnaissance
The review started from docker-compose.yml and src/main.rs, then followed every route into authentication extractors, ownership checks, snapshot rendering, caching, and outbound badge requests. The JSON reports in auth_bypass.json, idor.json, cache_poisoning.json, ssrf.json, and report.json were compared with the implementation.
The important trust boundaries were:
RenderMode::Publicselects only public, non-private-note cards.RenderMode::Workbenchselects every card and can therefore contain flags.- A workbench response must require both authentication and ownership.
- Cached workbench data must never be reachable through a public cache lookup.
- Badge URLs are attacker-controlled outbound requests.
- Snapshots embedded in HTML must not be able to terminate their JSON
<script>element.
The original project compiled but contained no tests. cargo test --locked completed with zero tests before patching.
Findings and minimal proofs
1. Unauthenticated internal workbench inspection (CWE-306)
GET /internal/worker/inspect?team=<slug> had no authentication extractor and rendered RenderMode::Workbench. Knowing a team slug was enough to retrieve its private and draft cards.
Minimal proof sequence:
- Store a private card containing a synthetic flag in
victim-team. - Request
/internal/worker/inspect?team=victim-teamwithout a session. - Observe the synthetic flag in the JSON response.
The route now requires CurrentUser, loads the requested team, and returns 403 Forbidden unless the current user owns it. The route and successful JSON response remain unchanged.
2. Cross-team export IDOR (CWE-639)
GET /api/panels/{slug}/export required a valid session but discarded the authenticated user and exported any requested team in workbench mode.
Minimal proof sequence:
- Create an attacker account and obtain its session cookie.
- Request
/api/panels/victim-team/exportwith the attacker cookie. - Observe the victim's private synthetic flag.
The export handler now loads the team and verifies team.owner_id == user.id before rendering. Owners still receive the same workbench snapshot; other users receive 403 Forbidden.
3. Workbench-to-public cache poisoning (CWE-524)
Both public and workbench handlers used panel:{slug}. The authenticated workbench handler inserted an all-card snapshot under that key, and the unauthenticated public handler returned it without checking its render mode.
Minimal proof sequence:
- As the owner, request
/api/panels/victim-team/workbench-snapshot. - Without a session, request
/api/panels/victim-team/snapshot. - Observe private and draft cards in the public response.
Only public snapshots are now cached, under panel:{slug}:public. Workbench snapshots are never inserted. Card and badge creation invalidate the public entry so checker reads immediately observe new public content. Cleanup now truly clears all cached entries rather than only pruning expired ones.
4. Badge SSRF (CWE-918)
The badge fetcher validated display_url but fetched data_url without validation. An attacker-controlled manifest could point data_url at loopback, metadata, or another internal service and receive the response in the badge preview JSON.
Minimal manifest:
{
"name": "probe",
"display_url": "https://example.com/badge",
"data_url": "http://127.0.0.1:12345/internal/worker/inspect?team=victim-team"
}
Submitting its URL to /api/badges/preview previously returned the internal response in data.
The fetched data_url is now restricted to HTTP(S), checked against forbidden IPv4 and IPv6 ranges, DNS-resolved before connection, and pinned to the validated answers to prevent DNS rebinding. Proxy use and redirects are disabled. Manifest hosts retain the competition-network policy needed for checker-hosted manifests, but loopback, link-local, documentation, unspecified, multicast, and mapped-loopback addresses remain forbidden. Upstream responses must be successful UTF-8 and are read incrementally with the existing 16 KiB manifest and 64 KiB data limits.
5. Stored script-element breakout
Snapshot JSON embedded in HTML only replaced lowercase </. HTML closing tag matching is case-insensitive, so a card containing mixed-case </ScRiPt> could terminate the JSON element before client-side sanitization.
All < characters in embedded JSON are now serialized as \u003c. JSON.parse reconstructs the original data for the renderer, while the HTML parser never sees a literal closing tag.
Verification
The regression suite covers:
- anonymous rejection from the internal inspection route;
- cross-owner export rejection and successful owner export;
- the complete workbench-cache-prime/public-read sequence;
- immediate public cache invalidation after a card write;
- loopback, private, metadata, IPv6 ULA, and IPv4-mapped IPv6 URL rejection;
- preservation of private competition-network manifest hosts;
- scheme and localhost rejection;
- mixed-case script-element breakout encoding.
Run locally:
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings
cargo build --locked --release
docker compose build
docker compose up -d
curl --fail http://127.0.0.1:12345/health
docker compose down
The source-level tests use an in-memory SQLite database and do not require external network access or a live flag.
The added .dockerignore excludes local Rust build artifacts, databases, secrets, and security reports from the Docker build context. This reduces the observed context from gigabytes to the service source and prevents accidental inclusion of local data in builder layers.
The local Compose-only CPU, memory, and PID limits were removed. In attack-defense deployment, those low limits could cause checker-visible self-denial-of-service under concurrent password hashing or badge requests; host-level competition quotas remain the appropriate enforcement boundary.