Skip to content

Security Audit & Load Testing Report ​

Assessment Details ​

FieldValue
Date2026-04-05 (load + ZAP); 2026-06-09 (cross-user authorization additions)
Versionv1.1.0-beta (milestone-2 branch, pre-merge to staging)
Assessed ByOnboard.Ninja Development Team
Target (SaaS)https://beta.onbd.io
Target (Self-Hosted)Docker stack (localhost:8081)
ToolsPHPUnit 11, OWASP ZAP 2.16, k6 (Grafana)

1. Automated Security Testing (PHPUnit) ​

Test file: tests/Feature/SecurityTest.php: 28 tests, 50 assertions

CategoryTestsStatus
Security HeadersX-Content-Type-Options, X-Frame-Options, Referrer-PolicyPASS
XSS PreventionCampaign name, description, txn_msg strip_tags()PASS
Authentication BypassDashboard, campaign creation, profile require authPASS
AuthorizationCross-user campaign view/update/delete forbidden (403)PASS
Mass Assignmentuser_id override ignored on campaign creationPASS
Claim API ValidationMissing code, invalid address, invalid Bech32 charset, nonexistent campaignPASS
SQL InjectionCampaign name injection, claim code injection - parameterized queriesPASS
Proxy API AuthUnauthenticated requests rejected (401), invalid tokens rejected (401)PASS
Input ValidationOversized name (>255), invalid network, end date before startPASS
Sensitive DataWallet keys (key, skey, vkey) hidden from JSON serializationPASS

Run command: php artisan test tests/Feature/SecurityTest.php

2. OWASP ZAP Baseline Scan ​

Tool: OWASP ZAP 2.16 (Docker: ghcr.io/zaproxy/zaproxy:stable) Scan type: Baseline (passive scanning) Target: http://localhost:8081 (Docker stack, null backend)

Findings ​

RiskAlertInstancesStatusNotes
MediumContent Security Policy (CSP) Header Not Set2Fixed (M3)CSP now set by SecurityHeaders middleware (config/security.php), incl. frame-ancestors 'none', object-src 'none', base-uri/form-action 'self'.
MediumMultiple X-Frame-Options Header Entries1Fixed (M3)Consolidated to the middleware (DENY); removed the duplicate add_header from nginx. Asserted single value in SecurityTest.
MediumSub Resource Integrity Attribute Missing2AcceptedVite-bundled assets served from same origin. SRI recommended for CDN-hosted scripts; lower risk for self-hosted assets. Tracked for a future Vite SRI plugin.
LowCookie No HttpOnly Flag1AcceptedXSRF-TOKEN cookie needs to be readable by JavaScript (Inertia/Axios). This is by design for CSRF protection.
LowCross-Origin-Embedder-Policy Header Missing1Opt-in (M3)Wired into the middleware but defaults off (CROSS_ORIGIN_EMBEDDER_POLICY) — require-corp can break cross-origin CDN assets; enable per deployment after verifying CDN CORP headers.
LowCross-Origin-Opener-Policy Header Missing1Fixed (M3)Cross-Origin-Opener-Policy: same-origin set by middleware.
LowCross-Origin-Resource-Policy Header Missing5Fixed (M3)Cross-Origin-Resource-Policy: same-origin set by middleware.
LowPermissions-Policy Header Not Set5Fixed (M3)Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=() set by middleware.
LowServer Leaks Version via "Server" Header5Fixed (M3)server_tokens off; added to docker/nginx/nginx.conf.
LowX-Content-Type-Options Missing (static assets)5Fixed (M3)add_header X-Content-Type-Options "nosniff" always; re-asserted inside the /build/ and static-asset location blocks (which override inherited headers).
InfoTimestamp Disclosure - Unix5AcceptedUnix timestamps in JSON responses (expected for date fields).
InfoSuspicious Comments1AcceptedStandard code comments in bundled JavaScript.
InfoModern Web Application1AcceptedDetection of SPA framework (expected).
InfoStorable and Cacheable Content5AcceptedPublic pages (login, welcome) are cacheable by design.

Summary ​

  • Total alerts: 16
  • High risk: 0
  • Medium risk: 3 (CSP, duplicate X-Frame-Options, SRI)
  • Low risk: 7 (cookies, CORS headers, server version, static asset headers)
  • Informational: 6

Remediation Plan (M3) — Completed ​

  1. ✅ Add Content Security Policy header — SecurityHeaders middleware via config/security.php
  2. ✅ Consolidate X-Frame-Options to middleware only (removed from nginx)
  3. ✅ Add server_tokens off to nginx config (docker/nginx/nginx.conf)
  4. ✅ Add COOP, CORP, Permissions-Policy headers to the middleware (COEP wired but opt-in by default — see findings table)
  5. ✅ Add X-Content-Type-Options to nginx for static assets (per-location)

All header changes are env-configurable (config/security.php) and covered by tests/Feature/SecurityTest.php (test_hardened_security_headers_are_present, test_coep_is_opt_in_and_off_by_default). Remaining accepted item: Subresource Integrity on bundled assets (low risk for same-origin Vite output).

3. Manual Security Audit ​

#CheckResultNotes
1CSRF: X-XSRF-TOKEN enforced on state-changing endpointsPASSLaravel/Inertia handles CSRF via encrypted cookie + X-XSRF-TOKEN header
2XSS: Script injection in campaign name/descriptionPASSstrip_tags() applied on store and update in CampaignController
3SQL Injection: Injection in claim code, campaign fieldsPASSEloquent parameterized queries throughout. Verified with ' OR 1=1; -- payloads
4Auth Bypass: Accessing /campaigns/{other_user_id}PASSCampaignPolicy + authorizeResource() in controller constructor
5Rate Limiting: Claim endpoint throttlingPASS60 req/min per IP, 120 req/min per campaign. Verified under load - 4222 rate-limited responses at minimum profile
6Security Headers: X-Content-Type-Options, X-Frame-Options, HSTSPASSSecurityHeaders middleware sets headers on all dynamic responses
7Sanctum Tokens: Invalid/expired tokensPASSReturns 401 Unauthorized. Verified in SecurityTest.php
8Mass Assignment: Extra fields on campaign createPASSController uses validated data + Auth::user()->id. Injected user_id ignored
9File Upload: Size and type limitsPASSconfig/cardano.php: max_file_size (10MB), max_codes (10000)
10Wallet Key Exposure: keys hidden from APIPASS$hidden array on Wallet model excludes key, skey, vkey
11Session Fixation: Session regenerated on loginPASSLaravel Breeze calls $request->session()->regenerate()
12Password Hashing: bcrypt with configurable roundsPASSLaravel default bcrypt, rounds=4 in testing, default in production

4. Load/Stress Testing Results ​

Tool: k6 v0.56 (Grafana) Backend: TRANSACTION_BACKEND=null (isolates app performance from external API latency) Test data: 500 pre-seeded claim codes per campaign Scripts: tests/load/claim-api.js, tests/load/dashboard.js

Claim API, POST /api/claim/v1/{campaign} ​

The claim API is the only publicly exposed unauthenticated endpoint and the most likely to experience traffic spikes during airdrop events.

MetricMinimum (1.5 GB)Recommended (2.5 GB)Comfortable (4.5 GB)
Peak VUs103050
Requests/sec26.672.5126.4
p95 Latency4.9 ms4.2 ms4.0 ms
Avg Latency3.8 ms3.5 ms3.8 ms
Max Latency175 ms187 ms297 ms
Claims Accepted183183181
Rate Limited (429)4,22211,62120,368
Server Errors (5xx)000
Success Rate100%100%100%
VerdictPASSPASSPASS

Notes: Rate limiting is working correctly, the majority of requests are throttled (60/min per IP). The ~183 accepted claims represent the rate limit window across the test duration. Zero server errors across all profiles.

Dashboard, Login + Page Views (Authenticated) ​

Tests the Inertia rendering pipeline under concurrent authenticated sessions.

MetricMinimum (1.5 GB)Recommended (2.5 GB)Comfortable (4.5 GB)
Peak VUs81530
Requests/sec6.612.223.7
Login p9551 ms53 ms56 ms
Dashboard p9514 ms16 ms22 ms
Campaign View p9537 ms36 ms38 ms
Error Rate0%0%0%
VerdictPASSPASSPASS

Docker Resource Usage (Post-Test Peak) ​

ServiceMinimum (512 MB)Recommended (1 GB)Comfortable (2 GB)
App150 MB (29%)155 MB (15%)162 MB (8%)
MySQL383 MB (75%)378 MB (37%)373 MB (18%)
Redis5 MB (4%)4 MB (2%)4 MB (1%)

5. Hardware Recommendations ​

Minimum Requirements (Self-Hosted) ​

  • CPU: 2 cores (1 for app, 0.5 for MySQL, 0.25 for Redis)
  • RAM: 1.5 GB total (512 MB app, 512 MB MySQL, 128 MB Redis)
  • Disk: 10 GB (application + database)
  • Expected capacity: ~10 concurrent claimers, ~8 admin sessions, ~27 claim req/sec
  • Warning: MySQL uses 75% of available memory at this tier. Not recommended for campaigns with >1000 codes or sustained high traffic.
  • CPU: 4 cores (2 for app, 1 for MySQL, 0.5 for Redis)
  • RAM: 2.5 GB total (1 GB app, 1 GB MySQL, 256 MB Redis)
  • Disk: 20 GB SSD
  • Expected capacity: ~30 concurrent claimers, ~15 admin sessions, ~73 claim req/sec
  • Best for: Most self-hosted deployments, events with up to several hundred attendees.

Comfortable Specifications ​

  • CPU: 7 cores (4 for app, 2 for MySQL, 0.5 for Redis)
  • RAM: 4.5 GB total (2 GB app, 2 GB MySQL, 256 MB Redis)
  • Disk: 40 GB SSD
  • Expected capacity: ~50 concurrent claimers, ~30 admin sessions, ~126 claim req/sec
  • Best for: Large events, multiple concurrent campaigns, high-traffic airdrop drops.

PHP-FPM Tuning ​

The docker/php/www.conf sets pm.max_children = 20. Each PHP-FPM worker uses ~30-50 MB RAM.

App RAMRecommended pm.max_childrenConcurrent Capacity
512 MB8-10~10 requests
1 GB15-20~20 requests
2 GB30-40~40 requests

If load tests show 502 Bad Gateway errors, reduce pm.max_children or increase app memory.

6. Remediation Summary ​

Items identified during security scanning, prioritized for future milestones:

#FindingSeverityStatusTarget
1Content Security Policy header not setMediumFixedM3 ✅
2Duplicate X-Frame-Options (middleware + nginx)MediumFixedM3 ✅
3Sub Resource Integrity missing on bundled assetsMediumAcceptedSame-origin Vite output; future SRI plugin
4nginx leaks server versionLowFixedM3 ✅
5Missing COOP/CORP headersLowFixedM3 ✅
5bMissing COEP headerLowOpt-inWired; off by default (CDN CORP risk)
6Missing Permissions-Policy headerLowFixedM3 ✅
7X-Content-Type-Options missing on static assetsLowFixedM3 ✅
8XSRF-TOKEN cookie not HttpOnlyLowAcceptedBy design (Inertia/Axios requires JS access)

7. Test Execution Summary ​

Test SuiteCountPassFailCommand
PHPUnit (backend + security)1641640php artisan test
Vitest (frontend components)40400npm test
Dusk (E2E browser)20200php artisan dusk
k6 Claim API (3 profiles)330./tests/load/run-loadtests.sh
k6 Dashboard (3 profiles)330./tests/load/run-loadtests.sh
OWASP ZAP baseline1--./tests/security/run-security-scan.sh
Total2312300

Released under the Apache License 2.0.