project-nomad/admin/docs/security-audit-v1.md
Chris Sherwood db1fe84553 fix(security): path traversal and SSRF protections from pre-launch audit
Fixes 4 high-severity findings from a comprehensive security audit:

1. Path traversal on ZIM file delete — resolve()+startsWith() containment
2. Path traversal on Map file delete — same pattern
3. Path traversal on docs read — same pattern (already used in rag_service)
4. SSRF on download endpoints — block private/internal IPs, require TLD

Also adds assertNotPrivateUrl() to content update endpoints.

Full audit report attached as admin/docs/security-audit-v1.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:01:54 -07:00

11 KiB

Project NOMAD Security Audit Report

Date: 2026-03-08 Version audited: v1.28.0 (main branch) Auditor: Claude Code (automated + manual review) Target: Pre-launch security review


Executive Summary

Project NOMAD's codebase is reasonably clean for a LAN appliance, with no critical authentication bypasses or remote code execution vulnerabilities. However, there are 4 findings that should be fixed before public launch — all are straightforward path traversal and SSRF issues with known fix patterns already used elsewhere in the codebase.

Severity Count Summary
HIGH 4 Path traversal (3), SSRF (1)
MEDIUM 5 Dozzle shell, unvalidated settings read, content update URL injection, verbose errors, no rate limiting
LOW 5 CSRF disabled, CORS wildcard, debug logging, npm dep CVEs, hardcoded HMAC
INFO 2 No auth by design, Docker socket exposure by design

Scans Performed

Scan Tool Result
Dependency audit npm audit 2 CVEs (1 high, 1 moderate)
Secret scan Manual grep (passwords, keys, tokens, certs) Clean — all secrets from env vars
SAST Semgrep (security-audit, OWASP, nodejs rulesets) 0 findings (AdonisJS not in rulesets)
Docker config review Manual review of compose, Dockerfiles, scripts 2 actionable findings
Code review Manual review of services, controllers, validators 4 path traversal + 1 SSRF
API endpoint audit Manual review of all 60+ routes Attack surface documented
DAST (OWASP ZAP) Skipped — Docker Desktop not running Recommended as follow-up

FIX BEFORE LAUNCH

1. Path Traversal — ZIM File Delete (HIGH)

File: admin/app/services/zim_service.ts:329-342 Endpoint: DELETE /api/zim/:filename

The filename parameter flows into path.join() with no directory containment check. An attacker can delete .zim files outside the storage directory:

DELETE /api/zim/..%2F..%2Fsome-file.zim

Fix: Resolve the full path and verify it starts with the expected storage directory:

async delete(file: string): Promise<void> {
  let fileName = file
  if (!fileName.endsWith('.zim')) {
    fileName += '.zim'
  }

  const basePath = join(process.cwd(), ZIM_STORAGE_PATH)
  const fullPath = resolve(basePath, fileName)

  // Prevent path traversal
  if (!fullPath.startsWith(basePath)) {
    throw new Error('Invalid filename')
  }

  // ... rest of delete logic
}

This pattern is already used correctly in rag_service.ts:deleteFileBySource().


2. Path Traversal — Map File Delete (HIGH)

File: admin/app/services/map_service.ts (delete method) Endpoint: DELETE /api/maps/:filename

Identical pattern to the ZIM delete. Same fix — resolve path, verify startsWith(basePath).


3. Path Traversal — Documentation Read (HIGH)

File: admin/app/services/docs_service.ts:61-83 Endpoint: GET /docs/:slug

The slug parameter flows into path.join(this.docsPath, filename) with no containment check. An attacker can read arbitrary .md files on the filesystem:

GET /docs/..%2F..%2F..%2Fetc%2Fpasswd

Limited by the mandatory .md extension, but could still read sensitive markdown files outside the docs directory (like CLAUDE.md, README.md, etc.).

Fix:

const basePath = this.docsPath
const fullPath = path.resolve(basePath, filename)

if (!fullPath.startsWith(path.resolve(basePath))) {
  throw new Error('Invalid document slug')
}

4. SSRF — Download Endpoints (HIGH)

File: admin/app/validators/common.ts:3-12 Endpoints: POST /api/zim/download-remote, POST /api/maps/download-remote, POST /api/maps/download-base-assets, POST /api/maps/download-remote-preflight

The URL validator uses require_tld: false, allowing internal/private URLs:

url: vine.string().url({ require_tld: false })

An attacker on the LAN can make NOMAD fetch from:

  • http://localhost:3306 (MySQL)
  • http://localhost:6379 (Redis)
  • http://169.254.169.254/ (cloud metadata — if NOMAD is ever cloud-hosted)
  • Any internal network service

Fix: Add a URL validation helper that rejects private/internal IPs:

function isPrivateUrl(urlString: string): boolean {
  const url = new URL(urlString)
  const hostname = url.hostname
  // Block localhost, private ranges, link-local, metadata
  const blocked = [
    /^localhost$/i,
    /^127\./,
    /^10\./,
    /^172\.(1[6-9]|2\d|3[01])\./,
    /^192\.168\./,
    /^169\.254\./,
    /^0\./,
    /^\[::1\]$/,
    /^\[fe80:/i,
  ]
  return blocked.some(re => re.test(hostname))
}

Alternatively, maintain an allowlist of known-good download domains (e.g., download.kiwix.org, github.com, api.projectnomad.us).

Note: The require_tld: false setting was intentionally added with the comment "Allow local URLs" — but this should only be needed for dev/testing. Consider making it configurable via environment variable, or keeping the allowlist approach for production.


FIX AFTER LAUNCH (Medium Priority)

5. Dozzle Web Shell Access (MEDIUM)

File: install/management_compose.yaml:56

- DOZZLE_ENABLE_SHELL=true

Dozzle on port 9999 is bound to all interfaces with shell access enabled. Anyone on the LAN can open a web shell into containers, including nomad_admin which has the Docker socket mounted. This creates a path from "LAN access" → "container shell" → "Docker socket" → "host root."

Fix: Set DOZZLE_ENABLE_SHELL=false. Log viewing and container restart functionality are preserved.


6. Unvalidated Settings Key Read (MEDIUM)

File: admin/app/controllers/settings_controller.ts Endpoint: GET /api/system/settings?key=...

The updateSetting endpoint validates the key against an enum, but getSetting accepts any arbitrary key string. Currently harmless since the KV store only contains settings data, but could leak sensitive info if new keys are added.

Fix: Apply the same enum validation to the read endpoint.


7. Content Update URL Injection (MEDIUM)

File: admin/app/validators/common.ts:72-88 Endpoint: POST /api/content-updates/apply

The download_url comes directly from the client request body. An attacker can supply any URL and NOMAD will download from it. The URL should be looked up server-side from the content manifest instead.

Fix: Validate download_url against the cached manifest, or apply the same SSRF protections as finding #4.


8. Verbose Error Messages (MEDIUM)

Files: rag_controller.ts, docker_service.ts, system_update_service.ts

Several controllers return raw error.message in API responses, potentially leaking internal paths, stack details, or Docker error messages to the client.

Fix: Return generic error messages in production. Log the details server-side.


9. No Rate Limiting (MEDIUM)

Zero rate limiting across all 60+ endpoints. While acceptable for a LAN appliance, some endpoints are particularly abusable:

  • POST /api/benchmark/run — spins up Docker containers for CPU/memory/disk stress tests
  • POST /api/rag/upload — file uploads (20MB limit per bodyparser config)
  • POST /api/system/services/affect — can stop/start any service repeatedly

Fix: Consider basic rate limiting on the benchmark and service control endpoints (e.g., 1 benchmark per minute, service actions throttled to prevent rapid cycling).


LOW PRIORITY / ACCEPTED RISK

10. CSRF Protection Disabled (LOW)

File: admin/config/shield.ts

CSRF is disabled, meaning any website a LAN user visits could fire requests at NOMAD's API. This amplifies findings 1-4 — path traversal and SSRF could be triggered by a malicious webpage, not just direct LAN access.

Assessment: Acceptable for a LAN appliance with no auth system. Enabling CSRF would require significant auth/session infrastructure changes.

11. CORS Wildcard with Credentials (LOW)

File: admin/config/cors.ts

origin: ['*'] with credentials: true. Standard for LAN appliances.

12. npm Dependency CVEs (LOW)

tar  <=7.5.9     HIGH    Hardlink Path Traversal via Drive-Relative Linkpath
ajv  <6.14.0     MODERATE ReDoS when using $data option

Both fixable via npm audit fix. Low practical risk since these are build/dev dependencies not directly exposed to user input.

Fix: Run npm audit fix and commit the updated lockfile.

13. Hardcoded HMAC Secret (LOW)

File: admin/app/services/benchmark_service.ts:35

The benchmark HMAC secret 'nomad-benchmark-v1-2026' is hardcoded in open-source code. Anyone can forge leaderboard submissions.

Assessment: Accepted risk. The leaderboard has compensating controls (rate limiting, plausibility validation, hardware fingerprint dedup). The secret stops casual abuse, not determined attackers.

14. Production Debug Logging (LOW)

File: install/management_compose.yaml:22

LOG_LEVEL=debug

Debug logging in production can expose internal state in log files.

Fix: Change to LOG_LEVEL=info for production compose template.


INFORMATIONAL (By Design)

No Authentication

All 60+ API endpoints are unauthenticated. This is by design — NOMAD is a LAN appliance and the network boundary is the access control. Issue #73 tracks the edge case of public IP interfaces.

Docker Socket Exposure

The nomad_admin container mounts /var/run/docker.sock. This is necessary for NOMAD's core functionality (managing Docker containers). The socket is not exposed to the network — only the admin container can use it.


Recommendations Summary

Priority Action Effort
Before launch Fix 3 path traversals (ZIM delete, Map delete, Docs read) ~30 min
Before launch Add SSRF protection to download URL validators ~1 hour
Soon after Disable Dozzle shell access 1 line change
Soon after Validate settings key on read endpoint ~15 min
Soon after Sanitize error messages in responses ~30 min
Nice to have Run npm audit fix 5 min
Nice to have Change production log level to info 1 line change
Follow-up OWASP ZAP dynamic scan against NOMAD3 ~1 hour

What Went Right

  • No hardcoded secrets — all credentials properly use environment variables
  • No command injection — Docker operations use the Docker API (dockerode), not shell commands
  • No SQL injection — all database queries use AdonisJS Lucid ORM with parameterized queries
  • No eval/Function — no dynamic code execution anywhere
  • RAG service already has the correct fix patterndeleteFileBySource() uses resolve() + startsWith() for path containment
  • Install script generates strong random passwords — uses /dev/urandom for APP_KEY and DB passwords
  • No privileged containers — GPU passthrough uses DeviceRequests, not --privileged
  • Health checks don't leak data — internal-only calls