๐ฌ QuizWizz v1.10.0 โ Full Plugin Audit
Historical record โ 2026-07-27
Preserved as observed. Current truth: Program Masterplan.
TL;DR
Full re-audit three days after the roast: the honor-roll code holds up โ every 2026-07-24 fix is still in place, the live authorization matrix is clean, and server-side scoring, budget ledger, and private-file signing all re-verify. New catch: an unauthenticated PHP fatal on the public banner endpoint (
?qwizz_banner=<id>on any published bannerless quiz โValueErroratLogoSource.php:393) that source review missed and a live probe caught. Remaining sins are the known packaging ones (mPDF zoo, PHP 8.3 floor) plus service-layer hardening gaps. Grade: A code / C+ packaging / B+ live posture.
- subject: QuizWizz โ build/publish/print/play validated MCQ + true-false quizzes
- version: 1.10.0 (schema 1.6.3)
- bench: https://wrdp.loca.zone (Docker WP + MariaDB 11.4, plain permalinks)
- method: 6 parallel read-only audit slices over all first-party PHP (~23k LOC) + JS (~4.4k LOC), footgun grep, live browser probes (admin + Studio + full quiz playthrough as admin and guest), live REST authorization matrix, curl endpoint probes
- prior round: roast ยท frontend ยท patch-spec
2026-07-28 reconciliation โ current verdicts
The observations below stand as recorded; this note corrects current remediation verdicts after checking dependency/API claims against the installed source. The canonical masterplan now governs status.
- F-3 narrowed โ
SettingsBackup::write()already storescreated_by; the gap is owner scoping inlist()and the download/restore/delete row query, not the schema or writes.- F-4 retracted โ the proposed mPDF
allow_remote/allow_imagesconstructor keys do not exist in the shipped mPDF, andPdfGenerator::render_html()escapes question text and admits only vetted local/data logo sources; remote fetch is not reachable through the current renderer. A boundary probe is retained in the masterplan.- F-6 verifiedโno action โ uploaded-file, size, JSON-parser, exact format/version, and strict schema checks already gate the question-pack payload; a
finfoMIME gate is unreliable for JSON and adds rejection risk rather than a boundary. Valid/renamed importer probes retained.- F-7a/F-7b verifiedโno action โ lock-guarded backfill race and non-CSPRNG shuffle are accepted low-risk behavior; F-7cโF-7f remain discrete cleanup work.
- U-6 content fixtureโno plugin change โ the English quiz body inside de_DE chrome is bench-fixture content, not a plugin-code defect.
- U-7/U-8 verified strengths โ retained as positive regression gates, not backlog items. Priority is separate from the severity labels below: F-1 is the current
P0without rewriting its original severity.
๐บ๏ธ Verdict Map
flowchart LR QW["QuizWizz 1.10.0 re-audit"] --> SEC["๐ก๏ธ Security"] QW --> LIVE["๐ Live posture"] QW --> PKG["๐ฆ Packaging"] QW --> UXF["๐จ Frontend UX"] SEC --> S1["โ prepared SQL everywhere"] SEC --> S2["โ caps + nonces on all routes"] SEC --> S3["โ server-authoritative scoring"] SEC --> S4["โ ๏ธ service-layer authz hardening"] LIVE --> L1["๐ด unauth fatal on banner endpoint"] LIVE --> L2["โ guest matrix all 401/404 correct"] LIVE --> L3["โ draft quizzes invisible to guests"] PKG --> P1["โ mPDF vendor zoo still shipped"] PKG --> P2["โ PHP 8.3 floor unchanged"] UXF --> U1["โ FE-1 button fix persists"] UXF --> U2["โ FE-8 spinner persists"] UXF --> U3["โ ๏ธ no score on completion screen"]
๐ Scorecard
| Dimension | Grade | ฮ vs 07-24 | One-line |
|---|---|---|---|
| ๐ก๏ธ Security (code) | A | = | prepared SQL, sanitized input, token+cap gates re-verified across all 63 PHP files |
| ๐ Live authorization | A | new | 16-route guest probe: admin/AI routes 401, drafts 404, legacy submit 410 |
| ๐งฎ AI cost governance | A+ | = | reserve/settle/release + max_price + zdr re-verified at current lines |
| ๐๏ธ Architecture | C+ | = | static mega-classes persist; service layer misses defense-in-depth caps |
| ๐ฆ Packaging / footprint | D | = | 16 MB vendor/, mPDF ~138k LOC + 21 ttfonts entries still shipped |
| ๐ Compat / reach | C | = | PHP 8.3 floor unchanged (quizwizz.php:7) |
| ๐จ Player/Studio UX | B | โ | FE-1/FE-8 fixes persist live; new polish defects found |
| ๐งฏ Robustness | C | new | public endpoint can fatal 500 (LogoSource) |
โ Prior-finding verification (2026-07-24 โ 2026-07-27)
| Prior ID | Verdict | Evidence (current) |
|---|---|---|
| BE-1 mPDF locale/font zoo | ๐ STILL-PRESENT | vendor/ 16 MB; vendor/mpdf/mpdf/ttfonts/ 21 entries (20 .ttf + DejaVuinfo.txt) โ release-owner build prune |
| BE-2 PHP 8.3 floor | ๐ STILL-PRESENT | quizwizz.php:7 Requires PHP: 8.3 |
BE-3 dead RateLimiter::allow() | โ FIXED (persists) | no function allow in includes/RateLimiter.php; consume() sole API |
BE-4 680-line static GenerateController, no DI | ๐ STILL-PRESENT | includes/Rest/GenerateController.php unchanged shape โ release-owner refactor |
BE-5 hard-wired new PdfGenerator() | ๐ STILL-PRESENT | includes/Rest/GenerateController.php:595 |
| BE-6 release-or-503 duplication ร6 | ๐ STILL-PRESENT | includes/Ai/OpenRouterClient.php:109-112,131-134,146-150,155-158,161-164,166-168 |
BE-7 dead QUIZWIZZ_VER alias | โ FIXED (persists) | quizwizz.php:19-28 โ only QUIZWIZZ_VERSION remains |
BE-8 Tested up to: 7.0 | ๐ STILL-PRESENT | quizwizz.php:8 |
| FE-1 invisible primary button | โ FIXED (persists, live-verified) | corrective rule at assets/css/qwizz-finetune.css:312-314; live: โOnline spielenโ computed background-image: linear-gradient(135deg, rgb(109,40,217), rgb(91,33,182)), w=286px |
| FE-8 missing loading spinner | โ FIXED (persists) | assets/css/qwizz-player.css:62-66 spinner + prefers-reduced-motion; includes/Frontend/QuizEmbed.php:36 class present |
SubjectTaxonomy::builder_cards() ArgumentCountError | โ FIXED (stale log) | signature SubjectTaxonomy.php:393 takes 2 args; both callers (Admin/SettingsPage.php:54-57, Frontend/Shortcode.php:55-67) pass 2; debug.log entries are from 23-Jul; settings page renders fatal-free live |
No regressions. Every still-present item is release-owner territory per the locked build pipeline.
๐ด๐ ๐ก New findings
F-1 ๐ (top finding) โ Unauthenticated PHP fatal on public banner endpoint
- live repro (guest, no token):
curl 'https://wrdp.loca.zone/?qwizz_banner=110'โ HTTP 500,PHP Fatal error: Uncaught ValueError: finfo::file(): Argument #2 ($flags) cannot be empty in includes/LogoSource.php:393(debug.log stack trace confirmed) - root cause chain:
includes/LogoSource.php:271โ quiz 110 is published + non-ephemeral โ$public=true, token check skipped (correct per design)includes/LogoSource.php:276โqw_banner_pathmeta empty (quiz has no banner) โ$path = ''includes/LogoSource.php:277-278โis_private_path('')false โdetect_mime('')โfinfo->file('')throws on PHP 8 (LogoSource.php:393)- the empty-path guard exists only in
banner_url()(LogoSource.php:245-246), not in the streaming entry point; the 404 guard at:281runs after the fatal
- impact: any anonymous visitor can fatal any published bannerless quizโs banner URL โ WSOD/error-log spam on a public endpoint; on
WP_DEBUG_DISPLAYhosts it also leaks the file path - fix: bail
404when'' === $pathright after:276; belt-and-braces guard'' === $pathindetect_mime() - note: the parallel source-only slice rated this endpoint โsafeโ (
LogoSource.php:243-312) โ the live probe caught what reading missed. Evidence-first wins again.
F-2 ๐ โ Service-layer authorization hardening gaps (defense-in-depth)
REST controllers gate correctly (verified live below), but the domain services trust their callers:
| # | Gap | File:line | Live exploitability |
|---|---|---|---|
| F-2a | QuizService::persist() has no current_user_can before wp_insert/update_post | includes/QuizService.php:43-156 | none found โ all REST callers gate first |
| F-2b | persist() honors client-supplied post_author | includes/QuizService.php:143-145 | gated callers only |
| F-2c | QuizService::experience() reads any quiz ID without status check | includes/QuizService.php:636-640 | blocked upstream: guest probe of draft quizzes 77/88/99 โ 404 qw_not_found |
| F-2d | AttemptService::start() doesnโt validate the quiz container | includes/AttemptService.php:21-89 | blocked upstream (same 404 probe) |
- fix: add explicit capability/status checks inside the services so a future caller canโt accidentally bypass
- live probe (guest, 2026-07-27): draft IDs 77/88/99 and bogus 99999 all
404 qw_not_found; published 110 โ200withattempt_tokenโ controller gate works
F-3 ๐ โ Settings backups are global to all admins
includes/Admin/SettingsBackup.php:212-217โbackup_idvia$_POST/$_GET(sanitized, nonced,manage_options) but no per-user ownership check: any admin can restore/delete any other adminโs backup- fix: store
created_by, check on mutate (privacy eraser already anonymizescreated_byโAdmin/Privacy.php:110-163)
F-4 ๐ โ mPDF remote-resource fetch not disabled
includes/Pdf/PdfGenerator.php:46-55โ mPDF constructed withoutallow_remote:false/allow_images:false; quiz HTML can embed remote image/CSS URLs that the server fetches during render (SSRF-shaped, admin-triggered)- fix: pass
'allow_remote'=>false, 'allow_images'=>falsein the constructor config
F-5 ๐ก โ Raw server-icon HTML into innerHTML (admin settings)
assets/js/qwizz-admin-settings.js:25,63,127โD.categories/D.bannerIconsinjected raw; other sinks (qwizz-player.js:243,628,710,qwizz-builder.js:314,366,401,411,996,1006,qwizz-finetune.js:314,qwizz-admin-editor.js:24,qwizz-admin-subjects.js:10,91,96,142) are plugin-controlled icon maps, and builder (qwizz-builder.js:310-340) + subjects (qwizz-admin-subjects.js:50-69) already ship DOMParser/SVG-allowlist sanitizers- fix: route the two settings sinks through the same allowlist sanitizer
F-6 ๐ก โ QuestionPackImporter validates schema but not MIME
includes/Import/QuestionPackImporter.php:20-29,32-128โ.jsonextension forced, size capped, schema validated; nofinfocontent check (defense-in-depth)- fix:
finfo_file()expectingapplication/json/text/plainbefore decode
F-7 ๐ก โ Smaller correctness notes
| # | Note | File:line |
|---|---|---|
| F-7a | QuestionIndex backfill cursor read-modify-write non-atomic (small race under lock) | includes/QuestionIndex.php:369-374 |
| F-7b | QuestionSelector uses random_int() then non-CSPRNG shuffle() (fine for quizzes) | includes/QuestionSelector.php:70-95 |
| F-7c | dead private helpers query_args()/count_query() | includes/QuestionSelector.php:9-63 |
| F-7d | misleading indentation masks error-branch ownership ร2 | includes/QuizService.php:190-207,464-475 |
| F-7e | static [data-copy] binding misses dynamically added buttons | assets/js/qwizz-admin-dashboard.js:13 |
| F-7f | AttemptService::finish() include_explanations param misleading (snapshot flag overrides) | includes/AttemptService.php:246-248 |
๐ Live authorization matrix (guest, 2026-07-27)
Full route table: 21 routes under quizwizz/v1 (quiz/{id}/start, attempt/answer, attempt/finish, quiz/{id}/submit, collection, pool-count, craft-draft, reroll, pdf-preview, library, quality-gate/{id}, admin/{models,quizzes,questions,collections,settings,starter-library}, translate, translate/{job_id}) + legacy admin-post handlers (qw_craft, qw_pdf, qwizz_backup_*, qwizz_import_question_pack).
| Probe (as guest) | Result | Verdict |
|---|---|---|
GET admin/settings, admin/quizzes, admin/questions, admin/models | 401 rest_forbidden | โ |
POST quality-gate/110 | 401 rest_forbidden | โ cap check inside handler |
POST translate (valid question_ids,lang) | 401 rest_forbidden | โ AI unreachable by guests |
POST quiz/110/start (published) | 200 + attempt_token | โ public play by design |
POST quiz/{77,88,99}/start (drafts) | 404 qw_not_found | โ no draft leak |
POST quiz/110/submit | 410 qw_legacy_submit_retired | โ retired with Gone, nice hygiene |
POST reroll / craft-draft | 409 qw_pool_short | โ rate-limited business gate |
POST pdf-preview (no params) | 400 qw_missing_parameter | โ validation first |
?qw_artifact=bogus&qw_artifact_token=bogus | 200 homepage (clean bail) | โ |
?qwizz_banner=110 (bannerless quiz) | 500 PHP fatal | ๐ด โ F-1 |
guest answer submit attempt/answer | 200, feedback โAntwort gespeichert.โ | โ server-side scoring |
๐จ Live UI findings (in-browser, cited)
Verified in Chromium at 1440ร900, cache disabled, full playthrough of quiz 110 (โZeitmaschine Geschichteโ, published for this audit) as admin and guest:
| # | Defect | Sev | Evidence (live DOM/CSS) |
|---|---|---|---|
| U-1 | Broken byline on quiz CPT single: โVerfasst von inโ with empty author + empty category | ๐ | rendered text on ?post_type=qw_quiz&p=110; qw_quiz registers supports => ['title'] only (includes/PostTypes.php:8-45) so the themeโs author/category line has nothing to show โ template should suppress the byline for this CPT |
| U-2 | Timer control row overlaps: โ10 Sekunden hinzufรผgenโ text clipped under the โNรคchste Frageโ button | ๐ก | screenshot ground truth; three buttons + timer text crowd one flex row after โAntwort prรผfenโ |
| U-3 | German plural bug: โNรคchste Frage in 1 Sekundenโ | ๐ก | live text; needs _n() singular/plural |
| U-4 | No correctness feedback and no final score: per-answer feedback is is-neutral โAntwort gespeichert.โ; completion shows only โQuiz abgeschlossen / Deine Antworten wurden gespeichert.โ | ๐ | .qwizz-player__feedback is-visible is-neutral; config-dependent (visitor settings offer instant-feedback/result toggles) but the default play loop gives a quiz taker zero scoring signal |
| U-5 | โErgebnis ansehenโ button leads to a screen with no results | ๐ก | final CTA label vs content mismatch (see U-4) |
| U-6 | Quiz content English inside de_DE chrome (โOxford University is older than the Aztec Empire.โ) | ๐ก | content-language vs UI-language mismatch; starter content not localized (translation feature exists) |
| U-7 | Admin settings IA much improved vs 07-24 | โ | live: search box, 7 tabs (Allgemein/Builder/Player/Darstellung/PDF & Marke/KI/Erweitert), consistent card rhythm โ the 07-24 density complaint is largely addressed |
| U-8 | Guest playthrough works end-to-end | โ | start โ answer โ finish, all 200s, spinner + violet CTAs render (FE-1/FE-8 persist) |
Bench note: plain permalinks (empty permalink_structure, empty .htaccess) make pretty CPT URLs (/quiz/<slug>/) 404 at Apache; query-var URLs work. Bench config, not a plugin defect.
โ Strengths re-verified (all slices)
- all SQL prepared/whitelist-derived โ
Ai/AiBudget.php:34-287,RateLimiter.php:128-142,AttemptService.php:97-101,QuestionIndex.php:551-572(dynamicIN()builds%splaceholders) - server-authoritative scoring + optimistic-concurrency idempotency โ
includes/AttemptService.php:204-220,260-282; client sends onlyattempt_token/question_id/choice(assets/js/qwizz-player.js:452-456) - attempt snapshot strict-validated after JSON decode โ
AttemptService.php:110-142 - answer-bearing meta hidden from core REST (
show_in_rest=false) โincludes/Meta.php:16-67;qw_questionnot publicly queryable โincludes/PostTypes.php:50-86 - AI egress SSRF-bounded, key env-first never persisted โ
includes/Ai/OpenRouterClient.php:14,18-27,115-129; budget reserve/settle/release +max_price+data_collection:deny+zdrโ:65-89 - AI invocation requires login + capability โ
includes/Ai/RequestContext.php:30-52,includes/Ai/Ability.php:93-96 - rate limiter transactional + fail-closed โ
includes/RateLimiter.php:42-110,131-144;client_keysalted-hash โ:15-39 - guest tokens
random_bytes+hash_equals, cookiehttponly/secure/samesiteโincludes/QuizAccess.php:11-24,32-43 - private files outside uploads, deny-rules, HMAC-signed expiring URLs โ
includes/Support/PrivateFiles.php:43-75,740-755,772-789 - PDF artifacts:
random_bytes+HMAC token, expiry,nosniff, sanitized filename โincludes/Pdf/PdfArtifact.php:149-177,196-204,87-146 - admin surfaces: nonces + caps on every handler (MetaBoxes
:368-372, ImportPage:242-246,292-294, SettingsPage:191-225, StarterPage:269-285, SiteHealth:121-146); output escaped (ListTables.php:70-73,227-297) - uninstall honors
preserve_on_uninstall, multisite-aware, capability cleanup โuninstall.php:51-55,148-199; GDPR exporter/eraser with failure reporting โincludes/Admin/Privacy.php:49-174 - operational locks atomic +
hash_equals-verified โincludes/Support/OperationalLock.php:23-61; migrations lock-gated CAS โincludes/Installer.php:133-181,185-386 - JS: vanilla, nonceโd fetch everywhere (
qwizz-player.js:101-108,qwizz-builder.js:130-143,qwizz-finetune.js:149-161), noeval/new Function/document.write, builder draft in sessionStorage with schema validation (qwizz-builder.js:212-279), block renderabsint(build/quiz/render.php:10-11)
๐ Remediation checklist
Bench-fixable now (dev-only diagnostics; ship via release owner per patch-spec protocol):
- ๐ F-1: guard empty
qw_banner_pathinLogoSource::maybe_stream_quiz_banner()(:276) +detect_mime()(:390) โ one-line 404 bail - ๐ก U-1: suppress theme byline on
qw_quizsingle (or addauthorsupport) - ๐ก U-3:
_n()plural for the next-question countdown - ๐ก U-2: flex-wrap the timer control row
Release-owner (locked pipeline):
- ๐ F-2a-d: capability/status checks inside
QuizService/AttemptService - ๐ F-3: per-user ownership on settings backups
- ๐ F-4:
allow_remote/allow_images=falsefor mPDF - ๐ก F-5: SVG-allowlist the two settings icon sinks
- ๐ก F-6:
finfoMIME check in question-pack import - ๐ U-4/U-5: default-on score feedback or honest completion CTA
- ๐ carried: BE-1 (mPDF prune), BE-2 (PHP floor), BE-4/5/6 (refactors), BE-8 (compat CI)
2026-07-28 โ Option F bench reconciliation
The checklist above is the original 2026-07-27 snapshot and is superseded by the canonical remediation masterplan. Option F Waves 1โ4 are now bench-verified: F-1, F-2aโd, F-3, F-5, F-7cโf, and U-1โU-5. F-4 remains retracted and F-6 remains verified-no-action; both retained-boundary probes pass. See the executable probe record for the full shell, embedded-PHP, browser, lint, activation, and reviewer evidence.
These are development-bench artifacts only. Wave 5 release-owner integration, CI, provenance, and signed release work remain open; no bench file is release output.
๐ References
- ๐ QuizWizz bench ยท ๐งช Live quiz used for probes ยท ๐จ Studio
- ๐ prior round: Roast ยท Frontend ยท Patch spec ยท Masterplan ยท Architecture map
- reviewers: 6 parallel audit slices (bootstrap/lifecycle, REST surface, AI/data, domain services, admin/PDF, JS frontend) + main-agent live browser/curl probes
- method: source read with current file:line + live DOM/CSS/HTTP evidence; no dynamic exploit beyond authorization probes
- quiz 110 was published on the bench for live verification and left published intentionally (was draft)