๐Ÿ”ฌ 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 โ†’ ValueError at LogoSource.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 stores created_by; the gap is owner scoping in list() and the download/restore/delete row query, not the schema or writes.
  • F-4 retracted โ€” the proposed mPDF allow_remote/allow_images constructor keys do not exist in the shipped mPDF, and PdfGenerator::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 finfo MIME 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 P0 without 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

DimensionGradeฮ” vs 07-24One-line
๐Ÿ›ก๏ธ Security (code)A=prepared SQL, sanitized input, token+cap gates re-verified across all 63 PHP files
๐ŸŒ Live authorizationAnew16-route guest probe: admin/AI routes 401, drafts 404, legacy submit 410
๐Ÿงฎ AI cost governanceA+=reserve/settle/release + max_price + zdr re-verified at current lines
๐Ÿ›๏ธ ArchitectureC+=static mega-classes persist; service layer misses defense-in-depth caps
๐Ÿ“ฆ Packaging / footprintD=16 MB vendor/, mPDF ~138k LOC + 21 ttfonts entries still shipped
๐ŸŒ Compat / reachC=PHP 8.3 floor unchanged (quizwizz.php:7)
๐ŸŽจ Player/Studio UXBโ†‘FE-1/FE-8 fixes persist live; new polish defects found
๐Ÿงฏ RobustnessCnewpublic endpoint can fatal 500 (LogoSource)

โœ… Prior-finding verification (2026-07-24 โ†’ 2026-07-27)

Prior IDVerdictEvidence (current)
BE-1 mPDF locale/font zoo๐Ÿ” STILL-PRESENTvendor/ 16 MB; vendor/mpdf/mpdf/ttfonts/ 21 entries (20 .ttf + DejaVuinfo.txt) โ€” release-owner build prune
BE-2 PHP 8.3 floor๐Ÿ” STILL-PRESENTquizwizz.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-PRESENTincludes/Rest/GenerateController.php unchanged shape โ€” release-owner refactor
BE-5 hard-wired new PdfGenerator()๐Ÿ” STILL-PRESENTincludes/Rest/GenerateController.php:595
BE-6 release-or-503 duplication ร—6๐Ÿ” STILL-PRESENTincludes/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-PRESENTquizwizz.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_path meta 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 :281 runs 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_DISPLAY hosts it also leaks the file path
  • fix: bail 404 when '' === $path right after :276; belt-and-braces guard '' === $path in detect_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:

#GapFile:lineLive exploitability
F-2aQuizService::persist() has no current_user_can before wp_insert/update_postincludes/QuizService.php:43-156none found โ€” all REST callers gate first
F-2bpersist() honors client-supplied post_authorincludes/QuizService.php:143-145gated callers only
F-2cQuizService::experience() reads any quiz ID without status checkincludes/QuizService.php:636-640blocked upstream: guest probe of draft quizzes 77/88/99 โ†’ 404 qw_not_found
F-2dAttemptService::start() doesnโ€™t validate the quiz containerincludes/AttemptService.php:21-89blocked 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 โ†’ 200 with attempt_token โ€” controller gate works

F-3 ๐ŸŸ  โ€” Settings backups are global to all admins

  • includes/Admin/SettingsBackup.php:212-217 โ€” backup_id via $_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 anonymizes created_by โ€” Admin/Privacy.php:110-163)

F-4 ๐ŸŸ  โ€” mPDF remote-resource fetch not disabled

  • includes/Pdf/PdfGenerator.php:46-55 โ€” mPDF constructed without allow_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'=>false in 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.bannerIcons injected 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 โ€” .json extension forced, size capped, schema validated; no finfo content check (defense-in-depth)
  • fix: finfo_file() expecting application/json/text/plain before decode

F-7 ๐ŸŸก โ€” Smaller correctness notes

#NoteFile:line
F-7aQuestionIndex backfill cursor read-modify-write non-atomic (small race under lock)includes/QuestionIndex.php:369-374
F-7bQuestionSelector uses random_int() then non-CSPRNG shuffle() (fine for quizzes)includes/QuestionSelector.php:70-95
F-7cdead private helpers query_args()/count_query()includes/QuestionSelector.php:9-63
F-7dmisleading indentation masks error-branch ownership ร—2includes/QuizService.php:190-207,464-475
F-7estatic [data-copy] binding misses dynamically added buttonsassets/js/qwizz-admin-dashboard.js:13
F-7fAttemptService::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)ResultVerdict
GET admin/settings, admin/quizzes, admin/questions, admin/models401 rest_forbiddenโœ…
POST quality-gate/110401 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/submit410 qw_legacy_submit_retiredโœ… retired with Gone, nice hygiene
POST reroll / craft-draft409 qw_pool_shortโœ… rate-limited business gate
POST pdf-preview (no params)400 qw_missing_parameterโœ… validation first
?qw_artifact=bogus&qw_artifact_token=bogus200 homepage (clean bail)โœ…
?qwizz_banner=110 (bannerless quiz)500 PHP fatal๐Ÿ”ด โ†’ F-1
guest answer submit attempt/answer200, 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:

#DefectSevEvidence (live DOM/CSS)
U-1Broken 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-2Timer 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-3German plural bug: โ€œNรคchste Frage in 1 Sekundenโ€๐ŸŸกlive text; needs _n() singular/plural
U-4No 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-6Quiz 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-7Admin 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-8Guest 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 (dynamic IN() builds %s placeholders)
  • server-authoritative scoring + optimistic-concurrency idempotency โ€” includes/AttemptService.php:204-220,260-282; client sends only attempt_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_question not 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_key salted-hash โ€” :15-39
  • guest tokens random_bytes + hash_equals, cookie httponly/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), no eval/new Function/document.write, builder draft in sessionStorage with schema validation (qwizz-builder.js:212-279), block render absint (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_path in LogoSource::maybe_stream_quiz_banner() (:276) + detect_mime() (:390) โ€” one-line 404 bail
  • ๐ŸŸก U-1: suppress theme byline on qw_quiz single (or add author support)
  • ๐ŸŸก 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=false for mPDF
  • ๐ŸŸก F-5: SVG-allowlist the two settings icon sinks
  • ๐ŸŸก F-6: finfo MIME 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


  • 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)