# Ethical-hacker report — JOCYT website & client portal v1.0.0

**Scope:** public website, client portal, admin, Paystack payment path, file uploads, the database layer, and the Apache configuration files — attacked through real HTTP, on **SQLite, MariaDB 10.11 and real Apache 2.4 + mod_php 8.3**, the last two being what a cPanel host runs.
**Method:** automated attack suites (written for this system), the open-source SQL-injection scanner **sqlmap**, static code gates, and **mutation testing** (planting each vulnerability on purpose to prove a test catches it).
**Date:** 2 November 2026.

## Verdict

No exploitable vulnerability remains in the code that was tested. Six faults were found during testing and fixed; each now has a test or gate that fails if it comes back. A short list of accepted risks and server-side settings remains (below) — **items 1–3 of "Before go-live" are required.**

## Findings (found during this test, all fixed)

| # | Severity | Finding | How found | Fix + guard |
|---|---|---|---|---|
| F1 | **High** (availability) | All four search boxes (public Insights, admin leads, clients, audit log) used `LIKE … ESCAPE '\'`, which is valid in SQLite but a **syntax error on MySQL/MariaDB** — every search would have shown an error page on the live server. | Same test suite run on MariaDB | Portable escape character `!`, one shared function; static gate refuses the old form; mutation proves the MariaDB run catches it. |
| F2 | Medium | **Root `.htaccess` would have broken the JBCS dashboard** in its sub-folder (`DirectoryIndex index.php` vs its `index.html`) and frozen its scripts in browsers for a year. | Real-Apache test with a stand-in dashboard folder | `index.html` added; long caching confined to `assets/`. Permanent check in `qa/apache_checks.sh`. |
| F3 | Medium | **`robots.txt` published the admin path**, undoing the protection of renaming it. | Code review during the attack pass | Admin path removed from `robots.txt`; admin and portal pages send `X-Robots-Tag: noindex` instead. Test asserts the path never appears. |
| F4 | Low | Forged/expired forms were refused with HTTP **419**, which Apache + mod_php converts to **500 Internal Server Error**. The protection held, but it looked like a crash and would trigger monitoring. | Real-Apache run of the flow tests | Standard 403; gate refuses any non-standard status code. |
| F5 | Low | `X-Content-Type-Options` sent twice on pages under Apache. | Real-Apache attack suite | Static files get it from `.htaccess`, pages from PHP — never both. |
| F6 | Low | Two dashboard tiles linked to lists that did not match their figures. | Following every tile in a test | New filters; tests follow each link and count the rows. |

## What was attacked and how it held

| Area (OWASP 2021) | Attacks made | Result |
|---|---|---|
| **A01 Broken access control** | Client B opening/paying/replying to client A's invoice and ticket by id; anonymous access to 13 admin and portal URLs; sales/editor/manager reaching each other's areas by URL **and by direct POST**; manager switching off the 2FA requirement by posting the field; self-demotion; demoting the last super admin; disabled staff keeping a live session | All refused. Other clients' records return **404, not 403**, so ids cannot be probed. Disabling staff ends their session immediately. |
| **A02 Cryptographic failures** | Read the database directly | Passwords bcrypt; reset/verify/invite tokens stored as SHA-256 only; 2FA secrets **encrypted at rest** (libsodium, key from `APP_KEY`); webhook HMAC-SHA512 compared in constant time. |
| **A03 Injection** | 7 SQL payloads (boolean, UNION, stacked `DROP TABLE`, time-based `SLEEP`) on every search, filter and slug; **sqlmap** (levels 2–3, risk 2) on public search and three authenticated admin endpoints; stored XSS through article body, plan features, lead name, site name (JSON-LD breakout); reflected XSS through `?q=`, `?need=`, `?next=`; `javascript:` links; CSV formula injection; e-mail header injection | No injection. sqlmap: *"all tested parameters do not appear to be injectable."* All output escaped; articles use a markdown renderer that escapes HTML **first**; JSON-LD cannot be broken out of; spreadsheet formulas neutralised. |
| **A04 Insecure design** | Client-supplied price on a plan request; unknown billing cycle; negative/zero/over-balance/non-numeric payments and invoice lines; Paystack callback for 1 pesewa short; right amount in **USD**; another client's callback; signed webhook for GH₵ 1 against a GH₵ 750 attempt; the same webhook three times; paying an already-paid invoice | Every one refused. Prices come from the server; payments are re-summed from the payments table, so nothing counts twice. |
| **A05 Security misconfiguration** | 9 sensitive paths (config, database, SQL dump, `.git`, `.env`, backups) on the normal layout **and** on a host where everything was uploaded into `public_html`; directory listing; PHP planted in `uploads/` as `.php`, `.phtml`, `.php.png`, `.png`; HTML/SVG in uploads; HTTP methods PUT/DELETE/PATCH; forged `Host` header | All denied or inert. Planted PHP **never executes** (returned as inert text labelled `image/png`, with a sandbox CSP). Wrong `Host` is redirected to the real domain, so reset e-mails cannot be poisoned. |
| **A06 Vulnerable components** | — | No frameworks or Composer packages. Two vendored front-end files only (QR code generator, fonts), both static. |
| **A07 Authentication failures** | Password guessing (lockout after 5 per account per 15 min); bypassing the limit with spoofed `X-Forwarded-For` / `CF-Connecting-IP`; 2FA code guessing (stops after 6); **replaying a used 2FA code from another browser**; password alone reaching the admin; session fixation with an attacker-chosen id; reset link used twice; account enumeration by response text **and timing** | All held. Login time is the same for real and unknown e-mails (dummy hash). A password change signs out every other device. |
| **A08 Data integrity** | Unsigned / wrongly signed Paystack webhooks; upload polyglot (valid PNG + PHP); decompression bomb (6000×6000); path in upload filename | Webhooks need the HMAC signature. Every image is **decoded and re-encoded** — the polyglot's PHP is gone, the bomb is refused before decoding, file names are ours. |
| **A09 Logging & monitoring** | Checked audit trail after attacks | Logins, failed logins, 2FA failures, exports, backups, payments (and rejected payments), price and settings changes, staff changes — all in the audit log with a hashed IP. Errors go to a private log; the dashboard flags recent errors. |
| **A10 SSRF** | — | The only outbound call is to the Paystack URL fixed in `config.php`; checkout redirects are accepted only to `https://checkout.paystack.com/`. |

**Headers on every page:** strict Content-Security-Policy (no `unsafe-inline`, no `unsafe-eval`, per-request nonce), `frame-ancestors 'none'`, `X-Frame-Options: DENY`, `nosniff`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`, HSTS over HTTPS. Session cookie `HttpOnly`, `SameSite=Lax`, `Secure` over HTTPS; sessions regenerate at sign-in and expire after 45 minutes idle (admin) / 2 hours (portal) / 12 hours absolute.

## Mutation testing — do the tests actually catch attacks?

Each of these real vulnerabilities was planted, one at a time, in a copy of the code; the suites were run; a test had to go red. See `qa/mutate.py` and the sweep log in the release.

CSRF check removed · invoice IDOR · output escaping off · SVG uploads allowed · open redirect · 2FA replay allowed · rate limiting off · clickjacking protection removed · MySQL-only LIKE fault (MariaDB run) · same fault (static gate) · inline style breaking the CSP (gate) · non-standard 419 status (gate) · role check removed from invoices · Paystack amount check removed · unsigned webhooks accepted · mass assignment of `id`/`created_at` · dashboard door pointed at the wrong list.

## Accepted risks (decided, not overlooked)

1. **Registration says when an e-mail already has an account.** Hiding it makes sign-up confusing for real owners; the endpoint is limited to 5 accounts per hour per IP. Login and password reset do *not* reveal it.
2. **A public contact-form token can be reused for 48 hours.** Rate limiting (10/hour/IP), the honeypot and validation cover it; adding sessions to public pages would cost caching and privacy.
3. **Backups contain password hashes.** Downloading needs the super admin's password and is audit-logged; nightly copies are 0600 and outside the web root.
4. **Google Analytics, when switched on, loads a third-party script.** Only after the visitor accepts, and the CSP opens only for Google's domains.
5. **No WAF or DDoS protection** in the application itself — see "Before go-live" item 6.

## Before go-live (server side — cannot be done in code)

1. **HTTPS certificate active** (cPanel → SSL/TLS Status → AutoSSL) before setting `FORCE_HTTPS = true`.
2. **Set `APP_KEY` and `SETUP_KEY`** to long random values in `jocyt_site/app/config.php`, and change `ADMIN_PATH` from `admin`.
3. **Real e-mail sending:** `MAIL_DRIVER = 'smtp'` with a mailbox on the domain, plus **SPF, DKIM and DMARC** DNS records, or invoices and lead alerts will land in spam.
4. Turn on two-step verification on your own account at first sign-in (the admin will insist) and **create a second super admin**.
5. Add the daily job in cPanel → Cron Jobs.
6. Recommended: put the domain behind **Cloudflare** (free plan) for DDoS protection, then list Cloudflare's IP ranges in `TRUSTED_PROXIES` so rate limits see real visitor IPs.
7. When Paystack is switched on, paste the webhook URL shown in Business settings into Paystack, and use **test keys** for the first payment.
