Files
cve-dashboard/docs/security/security-audit-2026-04-01.md

24 KiB
Raw Blame History

Security Audit Report — STEAM Security Dashboard

Date: 2026-04-01 Scope: Full codebase — backend routes, authentication, file handling, Python scripts, React frontend Methodology: Static analysis across four parallel audit tracks


Executive Summary

The audit identified 31 findings across four severity levels. The most serious issues are concentrated in the authentication and authorization layer — several endpoints are either completely unauthenticated or have role-checking middleware called with the wrong arguments, silently bypassing access control. These require immediate remediation before the application is exposed to a broader user base.

Severity Count
Critical 6
High 9
Medium 10
Low / Info 6
Total 31

The application has strong foundational security in several areas: all database queries use parameterized statements (no SQL injection risk), path traversal prevention is comprehensive, Python script execution uses spawn with argument arrays (no shell injection), and file type allowlisting is in place. The vulnerabilities are largely in middleware wiring and missing access controls rather than fundamental design flaws.


Critical Findings


C-1 — Missing Authentication on Ivanti Findings Endpoints

File: backend/routes/ivantiFindings.js:552600

The findings router imports requireRole but not requireAuth. No authentication middleware is applied at the router level or on individual routes. Four endpoints are fully unauthenticated:

const { requireRole } = require('../middleware/auth');  // requireAuth never imported

router.get('/', async (req, res) => {          // line 552 — no auth
router.post('/sync', async (req, res) => {     // line 561 — no auth
router.get('/counts', async (req, res) => {    // line 571 — no auth
router.get('/fp-workflow-counts', ...)         // line 580 — no auth

Impact: Any unauthenticated attacker on the network can read the full list of Ivanti host findings (hostnames, IPs, CVEs, severity, SLA status), trigger a sync operation, and enumerate all finding metrics.

Fix: Import requireAuth and apply it to the router or each route:

const { requireAuth, requireRole } = require('../middleware/auth');
router.use(requireAuth(db));

C-2 — Broken requireRole Call — Privilege Escalation in Knowledge Base

File: backend/routes/knowledgeBase.js:43, 305

requireRole is called with db as the first argument:

router.post('/upload', requireAuth(db), requireRole(db, 'editor', 'admin'), ...)
router.delete('/:id', requireAuth(db), requireRole(db, 'editor', 'admin'), ...)

The function signature is function requireRole(...allowedRoles). It does not accept db. The database object is treated as the first "allowed role", so the check becomes req.user.role === db — an object comparison that always evaluates false, meaning the check never blocks anyone. Any authenticated viewer can upload and delete knowledge base documents.

Fix: Remove db from all requireRole calls:

requireRole('editor', 'admin')

C-3 — Unauthenticated Ivanti Finding Note Writes

File: backend/routes/ivantiFindings.js:639

The PUT endpoint for saving finding notes has no authentication middleware:

router.put('/:findingId/note', (req, res) => {
    const note = String(req.body.note || '').slice(0, 255);
    db.run(`INSERT INTO ivanti_finding_notes ...`);
});

Impact: Any unauthenticated request can write notes to any finding. Notes are visible to all users and used during remediation triage. An attacker could inject false status information (e.g. "EXC-12345 — patched") to mislead the team or cover tracks.

Fix: Add requireAuth(db) to this route.


C-4 — No Brute Force Protection on Login Endpoint

File: backend/routes/auth.js:10

The login endpoint has no rate limiting, attempt counting, or lockout:

router.post('/login', async (req, res) => {
    const { username, password } = req.body;
    // Direct DB lookup, unlimited attempts

Impact: An attacker can run unlimited password guesses against any account at full network speed. With the default credentials documented in the README and displayed in the UI (see F-2), admin accounts are a trivial target.

Fix: Apply express-rate-limit to the login route:

const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 });
router.post('/login', loginLimiter, async (req, res) => { ... });

C-5 — Default Credentials Displayed in Login UI

File: frontend/src/components/LoginForm.js:104

The login form renders hardcoded credentials in plain text:

<p className="text-sm text-gray-500 text-center font-mono">
    Default: <span className="text-intel-accent">admin</span> /
             <span className="text-intel-accent">admin123</span>
</p>

Impact: Anyone who opens the login page — including unauthenticated users — sees the default admin credentials. Combined with C-4 (no rate limiting), this is a direct path to admin compromise if the password has not been changed.

Fix: Remove this block entirely. Document default credentials only in the deployment guide. Enforce password change on first login server-side.


C-6 — Missing Sandbox Attribute on Knowledge Base PDF Iframe

File: frontend/src/components/KnowledgeBaseViewer.js:195

The inline document viewer renders uploaded files in an unsandboxed iframe:

<iframe
    src={`${API_BASE}/knowledge-base/${article.id}/content`}
    title={article.title}
    className="w-full h-full rounded"
>

Impact: A malicious PDF or HTML file uploaded by an editor could execute JavaScript within the application's origin, accessing localStorage, sessionStorage, and DOM of the parent page. An attacker with editor access could upload a file that steals session data from any user who views it.

Fix: Add a restrictive sandbox attribute:

<iframe
    sandbox="allow-same-origin allow-scripts"
    src={...}
    title={article.title}
/>

High Findings


H-1 — /cleanup-sessions Missing Role Check

File: backend/routes/auth.js:223

The comment says "admin only" but the endpoint only checks for any valid session:

router.post('/cleanup-sessions', async (req, res) => {
    const sessionId = req.cookies?.session_id;
    if (!sessionId) return res.status(401).json({ error: '...' });
    // No role check

Fix: Apply requireAuth(db) and requireRole('admin').


H-2 — Hardcoded Fallback SESSION_SECRET

File: backend/server.js:31

const SESSION_SECRET = process.env.SESSION_SECRET || 'default-secret-change-me';

If the .env file is missing or the variable is unset, all sessions are signed with a publicly known string. An attacker who knows the secret can forge valid session cookies.

Fix: Fail hard on startup if the secret is not set:

const SESSION_SECRET = process.env.SESSION_SECRET;
if (!SESSION_SECRET) throw new Error('SESSION_SECRET environment variable must be set');

H-3 — Audit Log Parameter Mismatch — Silent Audit Trail Gaps

Files: backend/routes/archerTickets.js:8995, 172, 206 and backend/routes/knowledgeBase.js:235244, 287296

The logAudit helper expects an object with entityType and entityId. These callers use the wrong keys (targetType, targetId) or pass positional arguments instead of an object:

// archerTickets.js — wrong keys
logAudit(db, { ..., targetType: 'archer_ticket', targetId: this.lastID, ... });

// knowledgeBase.js — positional (wrong pattern)
logAudit(db, req.user.id, req.user.username, 'VIEW_KB_ARTICLE', 'knowledge_base', id, ...);

Impact: All Archer ticket and Knowledge Base operations produce audit log rows with NULL entity type and entity ID. Security investigations and compliance reviews will show these actions occurred but not what was affected.

Fix: Align all callers to the object format expected by auditLog.js:

logAudit(db, { userId, username, action, entityType, entityId, details, ipAddress });

H-4 — Viewers Can Write Compliance Notes

Files: backend/routes/compliance.js:522 (also flagged by file-upload audit)

The POST /notes endpoint is protected by authentication but not by role:

router.post('/notes', async (req, res) => {   // no requireRole()

Impact: Any viewer can add notes to any compliance item. Notes surface in the detail panel and influence remediation decisions. False notes cannot be deleted via the API.

Fix: requireRole('editor', 'admin') on this route.


H-5 — Sync Endpoints Accessible to All Authenticated Users

Files: backend/routes/ivantiFindings.js:561, backend/routes/ivantiWorkflows.js:262

POST /sync on both routers requires only authentication, not editor/admin role. Any viewer can trigger expensive Ivanti API calls repeatedly.

Impact: Viewer-role users can cause repeated large API fetches, potentially hitting Ivanti rate limits and blocking legitimate syncs for the team.

Fix: Add requireRole('editor', 'admin') to both POST /sync routes.


H-6 — HTTP Header Injection via Unsanitized Filename in Content-Disposition

File: backend/routes/knowledgeBase.js:258, 299

The original uploaded filename (user-controlled) is written directly into the Content-Disposition response header:

res.setHeader('Content-Disposition', `inline; filename="${row.file_name}"`);
res.setHeader('Content-Disposition', `attachment; filename="${row.file_name}"`);

row.file_name stores uploadedFile.originalname which is not sanitized for use in HTTP headers. A filename containing "\r\n characters can split the response and inject arbitrary headers.

Fix:

const safeFilename = row.file_name.replace(/["\r\n\\]/g, '');
res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}"`);

H-7 — Race Condition in Knowledge Base File Upload

File: backend/routes/knowledgeBase.js:91155

The file is moved to its permanent location (line 93) before the database record is created (line 114). If the DB insert fails, the file is orphaned on disk. Two concurrent uploads with the same slug can also bypass the uniqueness check due to the async gap between the slug check query and the insert.

Fix: Keep the file in the temp directory until the DB insert succeeds, then move it:

db.run(insertSql, [...], function(err) {
    if (err) { fs.unlinkSync(uploadedFile.path); return res.status(500)...; }
    fs.renameSync(uploadedFile.path, filePath);
    res.json({ success: true });
});

H-8 — Hardcoded Default Admin Password in setup.js

File: backend/setup.js:175

const passwordHash = await bcrypt.hash('admin123', 10);

If setup.js is re-run on an existing deployment (e.g. during a restore), the admin password resets to a known value. The password is also documented in the README and displayed in the login UI (C-5).

Fix: Generate a random password on first run and print it once to stdout, or require it as a CLI argument. Never hardcode credentials in source.


H-9 — ReactMarkdown Renders HTML Without Sanitization

File: frontend/src/components/KnowledgeBaseViewer.js:169171

<ReactMarkdown>{content}</ReactMarkdown>

ReactMarkdown by default allows raw HTML in markdown (via rehype-raw). A knowledge base article containing <img src=x onerror="..."> or <script> tags would execute JavaScript in the viewer's browser.

Fix: Add rehype-sanitize:

import rehypeSanitize from 'rehype-sanitize';
<ReactMarkdown rehypePlugins={[rehypeSanitize]}>{content}</ReactMarkdown>

Medium Findings


M-1 — No CSRF Token Protection on State-Changing Requests

Files: All POST / PUT / DELETE routes

Cookies are SameSite: lax which provides partial protection, but lax still allows top-level cross-site navigations to carry cookies. No CSRF token is validated server-side. Combined with the permissive CORS configuration, cross-site request forgery is possible against editors and admins.

Fix: Either upgrade session cookie to SameSite: strict, or implement a CSRF token (double-submit cookie pattern or csurf middleware).


M-2 — CORS Allows Credentials with Explicit Origin List

File: backend/server.js:111114

app.use(cors({ origin: CORS_ORIGINS, credentials: true }));

credentials: true with explicit origins means any subdomain compromise or DNS hijacking of a listed origin could allow cross-origin authenticated requests. This is the correct pattern for this use case, but worth hardening.

Fix: Ensure CORS_ORIGINS is reviewed whenever the deployment changes. Consider SameSite: strict on cookies to reduce reliance on CORS for CSRF protection.


M-3 — No Rate Limiting on NVD API Proxy

File: backend/routes/nvdLookup.js:13

Any authenticated user can trigger NVD API calls in rapid succession. NVD enforces a 5 req/30s unauthenticated limit, which can be exhausted by a single user making 5 lookups.

Fix: Add a server-side 1-hour cache keyed by CVE ID to avoid repeated external lookups, plus a per-user rate limit.


M-4 — Admin Self-Demotion Check Uses Loose Equality

File: backend/routes/users.js:118

if (userId == req.user.id && role && role !== 'admin') {

Using == allows type coercion. If userId is passed as a different type than req.user.id, the comparison may not match correctly.

Fix: String(userId) === String(req.user.id).


M-5 — Missing Hostname Format Validation

File: backend/routes/compliance.js:451

The hostname route parameter is used in SQL queries and responses. Only length is checked (>300). No format validation rejects characters outside a valid hostname range.

Fix:

if (!/^[a-zA-Z0-9._-]+$/.test(hostname)) {
    return res.status(400).json({ error: 'Invalid hostname format' });
}

M-6 — Vendor Field Validated Before Trim

File: backend/routes/ivantiTodoQueue.js:8, 56

Vendor length is validated before .trim() is called. A string of 200 spaces passes validation but becomes an empty string after trimming, which then passes without a vendor value for FP/Archer items that require one.

Fix: Trim first, then validate length and presence.


M-7 — Unsanitized Original Filename Stored in Compliance Temp JSON

File: backend/routes/compliance.js:262

filename: req.file.originalname,   // user-controlled, unsanitized

The original filename is stored in the temp JSON and later echoed back to the frontend. Special characters could cause log injection or unexpected display issues.

Fix: filename: sanitizePathSegment(req.file.originalname).


M-8 — Hardcoded Frontend Origin in CSP Header

File: backend/routes/knowledgeBase.js:261

res.setHeader('Content-Security-Policy',
    "frame-ancestors 'self' http://71.85.90.9:3000 http://localhost:3000");

IP address is hardcoded. If the deployment IP changes, the CSP header will block inline document viewing without an obvious error and require a code change.

Fix: Use CORS_ORIGINS from the environment variable.


M-9 — Sensitive API Error Messages Forwarded to UI

Files: frontend/src/App.js:801, 816, 847, 886

} catch (err) {
    alert(`Error: ${err.message}`);
}

Raw API error messages are displayed in browser alerts. If the backend leaks stack traces or query information in error responses, this information reaches the user directly.

Fix: Show generic user-facing messages; log details to the console in development only.


M-10 — User-Supplied Data in window.confirm Dialogs

File: frontend/src/App.js:806, 891

if (!window.confirm(`Delete ticket ${ticket.ticket_key}?`)) return;

A ticket with a crafted ticket_key value (e.g. containing newlines or misleading text) could produce a deceptive confirmation dialog used to social-engineer users.

Fix: Use a React modal component with escaped, controlled text instead of window.confirm.


Low / Info Findings


L-1 — Silent ROLLBACK on Compliance Transaction Failure

File: backend/routes/compliance.js:167

await dbRun(db, 'ROLLBACK').catch(() => {});

If the rollback itself fails, the error is swallowed entirely. A failed rollback leaves an open transaction that can cause subsequent operations to block.

Fix: Log rollback failures even if execution continues.


L-2 — Fire-and-Forget Audit Logging

File: backend/helpers/auditLog.js:9

Audit log writes fail silently. If the database is under load or unavailable, audit records are dropped with no alert.

Fix: Log audit write failures to stderr so they surface in server logs.


L-3 — Async Temp File Cleanup With No Error Handling

File: backend/routes/compliance.js:239, 247, 266, 281, 322

fs.unlink(req.file.path, () => {});

Cleanup failures accumulate silently, potentially causing disk exhaustion over time.

Fix: Log errors on unlink failure (excluding ENOENT which is expected).


L-4 — IVANTI_SKIP_TLS Disables Certificate Validation

File: backend/routes/ivantiFindings.js:385

IVANTI_SKIP_TLS=true disables TLS verification for all Ivanti API calls, enabling man-in-the-middle attacks against the sync. It is controlled purely by environment variable with no warning.

Fix: Log a prominent warning on startup when this flag is active, and ensure it is never set in production.


L-5 — console.error in Production Frontend Code

Files: frontend/src/contexts/AuthContext.js:26, KnowledgeBaseViewer.js:31, 56

Full error objects are logged to the browser console in production builds. In a monitored environment, these could expose internal details to anyone with DevTools open.

Fix: Guard with if (process.env.NODE_ENV === 'development') or use a structured logging library.


L-6 — localStorage Column Config Lacks Structural Validation

File: frontend/src/components/pages/ReportingPage.js:5168

Column order/visibility is loaded from localStorage and merged with defaults. If the stored data is tampered with (via XSS or DevTools), the parsed structure is used with only partial validation.

Fix: Validate each loaded item against the known COLUMN_DEFS whitelist before use (a hasOwnProperty check is already present; ensure it runs on every item before the merge).


Summary Table

ID Severity Title File
C-1 Critical Missing auth on Ivanti findings endpoints ivantiFindings.js:552
C-2 Critical requireRole(db) call bypasses role check in KB routes knowledgeBase.js:43,305
C-3 Critical Unauthenticated finding note writes ivantiFindings.js:639
C-4 Critical No brute force protection on login auth.js:10
C-5 Critical Default credentials displayed in login UI LoginForm.js:104
C-6 Critical Missing sandbox on PDF/document iframe KnowledgeBaseViewer.js:195
H-1 High /cleanup-sessions missing role check auth.js:223
H-2 High Hardcoded fallback SESSION_SECRET server.js:31
H-3 High Audit log parameter mismatch — silent trail gaps archerTickets.js, knowledgeBase.js
H-4 High Viewers can write compliance notes compliance.js:522
H-5 High Sync endpoints accessible to all authenticated users ivantiFindings.js:561, ivantiWorkflows.js:262
H-6 High HTTP header injection via Content-Disposition filename knowledgeBase.js:258,299
H-7 High Race condition in KB file upload knowledgeBase.js:91
H-8 High Hardcoded default admin password in setup.js setup.js:175
H-9 High ReactMarkdown renders HTML without sanitization KnowledgeBaseViewer.js:169
M-1 Medium No CSRF token protection All state-changing routes
M-2 Medium CORS credentials with explicit origin list server.js:111
M-3 Medium No rate limiting on NVD API proxy nvdLookup.js:13
M-4 Medium Admin self-demotion check uses loose equality users.js:118
M-5 Medium Missing hostname format validation compliance.js:451
M-6 Medium Vendor field validated before trim ivantiTodoQueue.js:8,56
M-7 Medium Unsanitized original filename in temp JSON compliance.js:262
M-8 Medium Hardcoded frontend IP in CSP header knowledgeBase.js:261
M-9 Medium API error messages forwarded to UI App.js:801,816,847,886
M-10 Medium User data in window.confirm dialogs App.js:806,891
L-1 Low Silent ROLLBACK on transaction failure compliance.js:167
L-2 Low Fire-and-forget audit logging auditLog.js:9
L-3 Low Async temp file cleanup with no error handling compliance.js:239+
L-4 Low IVANTI_SKIP_TLS with no startup warning ivantiFindings.js:385
L-5 Low console.error exposed in production frontend AuthContext.js, KnowledgeBaseViewer.js
L-6 Low localStorage column config lacks structural validation ReportingPage.js:51

Remediation Priority

Immediate — fix before adding users

  1. C-1 — Add requireAuth import and router-level middleware to ivantiFindings.js
  2. C-2 — Remove db from all requireRole(db, ...) calls in knowledgeBase.js
  3. C-3 — Add requireAuth(db) to the finding note PUT route
  4. C-4 — Add express-rate-limit to the login route (20 attempts / 15 min)
  5. C-5 — Remove default credentials from LoginForm.js
  6. H-2 — Hard-fail on startup if SESSION_SECRET is not set in env

Short-term — next maintenance window

  1. C-6 — Add sandbox attribute to the KB iframe
  2. H-3 — Fix logAudit call signatures in archerTickets.js and knowledgeBase.js
  3. H-4 — Add requireRole('editor', 'admin') to POST /compliance/notes
  4. H-5 — Add requireRole('editor', 'admin') to both POST /sync routes
  5. H-6 — Sanitize filename for Content-Disposition header
  6. H-7 — Move file after DB insert succeeds in KB upload
  7. H-8 — Remove hardcoded password from setup.js; generate random on first run
  8. H-9 — Add rehype-sanitize to ReactMarkdown usage

Medium-term

  1. M-1 — Implement CSRF token or upgrade cookie to SameSite: strict
  2. M-3 — Add server-side CVE lookup cache
  3. M-5 — Add hostname format regex validation
  4. M-8 — Pull frontend origin from CORS_ORIGINS env var for CSP header
  5. M-9 — Replace alert(err.message) with user-friendly error messages
  6. Remaining medium and low findings

Positive Security Observations

The following were explicitly verified as secure and should be preserved:

  • SQL injection prevention — all queries use SQLite3 parameterized statements throughout
  • Path traversal preventionsanitizePathSegment() and isPathWithinUploads() are comprehensive and consistently applied
  • Python script executionspawn('python3', [SCRIPT, filePath]) passes arguments as an array, not a shell string — no command injection possible
  • Python scripts — no eval(), exec(), pickle.load(), or shell calls in any script
  • File size enforcement — 10 MB limit applied via multer before route handlers execute
  • File type allowlisting — extension + MIME prefix validation applied at upload
  • Static file servingexpress.static with { dotfiles: 'deny', index: false } prevents directory listing
  • Temp file path validationisSafeTempPath() enforces .json extension on compliance temp files
  • Password hashing — bcrypt with cost factor 10 used throughout

Audit scope: static analysis only. Dynamic testing (active exploitation, fuzzing, dependency CVE scan) not performed.