A production-ready internal web app for IT/Supplies/Office requests with attachments, approvals, assignments, status lifecycle, and an audit trail.
- admin@office.local / Admin123!
- manager@office.local / Manager123!
- employee@office.local / Employee123!
- PHP 8.1+ (recommended 8.2+)
- MySQL 8+
- Apache/Nginx or PHP built-in server (for quick local dev)
Create a MySQL database: office_request_hub
Create a DB user with least privileges to that DB only:
- SELECT, INSERT, UPDATE, DELETE
- CREATE, ALTER only during migrations (or run migrations with admin then downgrade privileges)
Copy:
.env.example→.env
Set:
- DB_HOST, DB_NAME, DB_USER, DB_PASS, DB_PORT
- APP_SECRET (long random)
- UPLOAD_DIR (absolute path recommended)
Run:
sql/001_schema.sqlsql/002_seed.sql- (optional)
sql/003_testdata.sql
If using PHP built-in server (local only):
- From project root:
php -S localhost:8080 -t public
Open:
- Set your domain/subdomain document root to:
/public - Ensure
UPLOAD_DIRis OUTSIDE web root, e.g.:/home/<cpanel_user>/orh_storage/uploads
- Upload the whole project
- Set docroot to
/public(or move contents of/publicto docroot) - Create DB + user in cPanel → MySQL Databases
- Import
sql/001_schema.sql, thensql/002_seed.sql - Create
.envin project root (same level as README.md) - Set:
COOKIE_SECURE=1(when HTTPS)UPLOAD_DIR=/home/<cpanel_user>/orh_storage/uploads
Where implemented
app/auth.phpAuth::login()creates DB session insessionstable- sets cookie
orh_sidwith HttpOnly + SameSite=Lax + Secure (if HTTPS)
app/security.php- cookie flags + CSP/security headers
actions/logout.phpinvalidates DB session (revoked_at)
How to verify
- DevTools → Application → Cookies:
orh_sidis HttpOnly ✅- Secure ✅ when HTTPS
- SameSite=Lax ✅
- Logout → cookie cleared AND DB
sessions.revoked_atset ✅ - Try to reuse cookie after logout → should be rejected (you’re redirected to login) ✅
Logs / Audit
audit_logs:- LOGIN_OK / LOGIN_FAIL
- LOGOUT
Where
app/rate_limit.php+Auth::login()inapp/auth.php
Verify
- Enter wrong password repeatedly:
- You keep receiving same message “Invalid email or password” ✅
- After threshold, you get blocked briefly (still same message) ✅
Audit
- LOGIN_FAIL (with email, ip), RATE_LIMIT bucket hits
Where
app/policies.php(Policy::canViewRequest, canEditRequest, canApproveRequest, canAssignRequest, canDownloadAttachment)- Every sensitive page/action checks policy:
- Request view:
app/views/requests/view.php - Status change:
actions/request_status.php - Assign:
actions/request_assign.php - Comment:
actions/comment_add.php - Download:
actions/download.php - API list queries are scoped by role:
actions/api/requests_list.php
- Request view:
Verify (IDOR regression)
- Login as employee.
- Open a request not owned by that employee (guess another ID).
- Expect: 403 Forbidden ✅
- Try download attachment from another user’s request:
- Expect: 403 Forbidden ✅
- Manager: can only see department requests; other dept must be denied ✅
Audit
- AUTHZ_DENY entries for request/attachment/route
Where
actions/request_create.phpvalidates and rejects unknown fieldsactions/request_status.phpenforces lifecycle transitionsapp/validator.php(server-side validation helpers)
Verify
- Try missing required fields → rejected ✅
- Try sending extra POST fields (e.g., requester_user_id) → rejected ✅
- Try invalid transitions (e.g., CLOSED → OPEN) → rejected ✅
Audit
- REQUEST_CREATE, REQUEST_STATUS, VALIDATION_FAIL, AUTHZ_DENY
Where
- Upload policy:
app/uploads.php+actions/upload.php- allow-list MIME: jpeg/png/pdf
- max size 5MB
- random stored name
- sha256 recorded
- Storage location:
UPLOAD_DIR(outside web root) - Download controller:
actions/download.phpenforces authorization and forces attachment disposition for PDF
Verify
- Upload
.exeor.phprenamed: rejected (mime check via finfo) ✅ - Upload > 5MB: rejected ✅
- Confirm uploaded files are NOT in
/public✅ - Download:
- requires being authorized to view the request ✅
- PDF downloads as attachment ✅
Audit
- UPLOAD_OK / UPLOAD_REJECT / UPLOAD_FAIL
- DOWNLOAD_OK / DOWNLOAD_FAIL
- metadata includes request_id, mime, size
Where
app/security.phpapplySecurityHeaders().htaccessas defense-in-depth
DevTools verification checklist
- Open any page → DevTools → Network → select document → Headers:
- X-Content-Type-Options: nosniff ✅
- Referrer-Policy: strict-origin-when-cross-origin ✅
- X-Frame-Options: DENY ✅
- Permissions-Policy present ✅
- Content-Security-Policy present ✅
- Strict-Transport-Security present (HTTPS only) ✅
Where
Security::e()used across views- No raw HTML from DB is rendered (only escaped + nl2br after escaping)
Verify
- Put
<script>alert(1)</script>in title/description/comment - It should display as text, not execute ✅
- Browser ↔ Server: untrusted user input, cookies, headers
- Server ↔ DB: only parameterized queries
- Uploads storage: must be outside web root; only server reads/writes
- Authorization boundary: request object ownership/department scoping
- Brute force → rate limits (per IP + per account), uniform errors
- Session hijacking → HttpOnly + SameSite + Secure (HTTPS), server-side sessions, bind session to IP+UA hashes, short TTL
- CSRF → CSRF token on all POST
- Webshell / executable upload → allow-list MIME via finfo, random stored name, storage outside web root
- Oversized files → strict max size
- Unauthorized download → download controller checks Policy::canDownloadAttachment
- Content sniffing → nosniff + correct Content-Type + Content-Disposition attachment for PDF
- Employee viewing others → Policy::canViewRequest; API query scoping by role
- Manager cross-department access → department scoping enforced at view + list
- Admin has full access → audited
For each endpoint:
- Auth required? (deny if not logged in)
- RBAC role check (if needed)
- Object-level policy check (Policy::*)
- CSRF for POST
- Rate limit (if abuse-prone)
- Validate fields + reject unknown fields
- Prepared statements only
- Audit log on write (and authz deny)
- Safe output encoding in views
- HTTPS enabled + COOKIE_SECURE=1
- UPLOAD_DIR outside web root and not publicly reachable
- DB user has least privileges
- Backups enabled (DB + uploads)
- Headers verified (CSP, HSTS on HTTPS, nosniff)
- Audit logs writing correctly
- IDOR tests pass (employee cannot view others)
- Upload rejection tests pass (bad mime / oversize)
This project includes a lightweight PHP test runner (no Composer).
From project root:
php tests/run.php http://localhost:8080