Files
aoc/PEN_TEST_REPORT_v1.7.11.md
2026-04-27 14:19:28 +02:00

8.3 KiB

AOC v1.7.11 Soft Penetration Test Report

Date: 2026-04-27 Target: Local AOC instance (port 8001), auth disabled, AI disabled Tester: Automated + manual curl-based probing Scope: FastAPI backend, REST API endpoints, middleware, headers


Executive Summary

AOC v1.7.11 has one CRITICAL vulnerability (CORS credentials leak) and several defense-in-depth gaps. The good news: input validation, NoSQL injection resistance, and error handling are solid. The bad news: CORS is dangerously permissive, security headers are missing, and the rate limiter fails open on Redis failure.

Severity Count Categories
CRITICAL 1 CORS with credentials
HIGH 1 Missing security headers
MEDIUM 2 Fail-open rate limiter, OpenAPI exposure
LOW 2 Information disclosure, webhook content injection
INFO 3 Positive findings (no stack traces, input validation, NoSQL resistance)

CRITICAL

1. CORS Reflects Any Origin with allow_credentials=true

Finding: The CORS middleware returns Access-Control-Allow-Origin: <any origin> AND Access-Control-Allow-Credentials: true for every origin that sends an Origin header.

Evidence:

curl -H "Origin: https://evil-attacker.com" http://localhost:8001/api/config/auth
# Response headers:
# access-control-allow-origin: https://evil-attacker.com
# access-control-allow-credentials: true

Impact: An attacker can host a malicious page on any domain and make authenticated cross-origin requests to the AOC API using the victim's browser cookies/tokens. This effectively bypasses Same-Origin Policy for authenticated actions.

Root Cause: main.py configures CORS with allow_origins=["*"] (from CORS_ORIGINS env var, default "*") AND allow_credentials=True. According to CORS spec, a wildcard origin with credentials is technically invalid, but Starlette/FastAPI appears to reflect the request origin instead.

Recommendation:

  • When AUTH_ENABLED=true, reject requests from origins not in an explicit allowlist.
  • Set allow_credentials=False if wildcard origins are needed.
  • Or, require CORS_ORIGINS to be explicitly configured (no default wildcard) when auth is enabled.

HIGH

2. Missing Security Headers

Finding: The following security headers are absent from all responses:

Header Purpose Status
X-Content-Type-Options: nosniff Prevents MIME sniffing MISSING
X-Frame-Options: DENY or SAMEORIGIN Clickjacking protection MISSING
Strict-Transport-Security HSTS enforcement MISSING
Referrer-Policy: strict-origin-when-cross-origin Limits referrer leakage MISSING
Permissions-Policy Restricts browser features MISSING

Impact: Increased attack surface for clickjacking, MIME confusion attacks, and information leakage via referrer headers.

Recommendation: Add a security headers middleware to set these on all responses. HSTS only when served over HTTPS.


MEDIUM

3. Rate Limiter Fails Open on Redis Failure

Finding: In rate_limiter.py line 81-82:

except Exception as exc:
    logger.warning("Rate limiter Redis error; allowing request", error=str(exc))

If Redis becomes unreachable, all rate limits are silently bypassed.

Evidence: When Redis was down, 150+ requests to /api/events all returned 200 with no 429s.

Impact: A DoS on Redis (or a network partition) removes all rate limiting, allowing unlimited API abuse.

Recommendation: Make the rate limiter fail-closed: return 429 or 503 when Redis is unavailable, or use an in-memory fallback with a conservative limit.

4. OpenAPI Schema Publicly Exposed

Finding: /docs, /redoc, and /openapi.json are accessible without authentication and return the full API schema.

Evidence:

curl -s http://localhost:8001/openapi.json | jq '.paths | keys'
# Returns all 15+ API paths including internal endpoints

Impact: Attackers get a complete map of the API, including request/response schemas, parameter types, and endpoint structure. This significantly reduces reconnaissance time.

Recommendation: Disable OpenAPI docs in production (docs_url=None, redoc_url=None, openapi_url=None) or gate them behind admin authentication.


LOW

5. Information Disclosure via /api/config/auth and /metrics

Finding:

  • /api/config/auth leaks tenant_id and client_id even when auth is disabled. These values fall back to the Graph API credentials (TENANT_ID/CLIENT_ID), which may be sensitive.
  • /metrics exposes Python version (3.14.3), GC statistics, and application-internal metric names.

Evidence:

{
  "auth_enabled": false,
  "tenant_id": "0ec9f34c-17c8-4541-b084-7d64ecdcc997",
  "client_id": "cc31fd45-1eca-431f-a2c6-ba81cd4c5d50"
}

Impact: Low direct impact (tenant/client IDs are not secrets), but aids reconnaissance and narrows the attack surface.

Recommendation:

  • Return empty strings for tenant_id/client_id when auth_enabled=false.
  • Gate /metrics behind IP allowlist or admin auth (standard Prometheus practice).

6. Webhook Validation Token Echoed Without Sanitization

Finding: The /api/webhooks/graph endpoint echoes validationToken query parameter as text/plain without any sanitization or length limits.

Evidence:

curl -X POST "http://localhost:8001/api/webhooks/graph?validationToken=<script>alert(1)</script>"
# Returns: <script>alert(1)</script> with Content-Type: text/plain

Impact: Low in the intended Microsoft Graph flow (token is Microsoft-generated), but if the endpoint is hit directly, an attacker could use this for cache poisoning, response splitting, or social engineering by making the endpoint return attacker-controlled content.

Recommendation: Validate the validationToken format (e.g., JWT-like structure, length limits) before echoing, or set Content-Type: text/plain; charset=utf-8 with X-Content-Type-Options: nosniff to reduce MIME confusion risk.


INFO (Positive Findings)

A. No Stack Traces in Error Responses

All errors (422, 404, 429, 500 if triggered) return generic JSON messages without internal details or stack traces. Good.

B. Pydantic Input Validation is Effective

  • page_size capped at 500 (returns 422 for 501, 0, -1)
  • hours capped at 720 (returns 422 for 721)
  • Invalid cursors return 400 with "Invalid cursor"
  • Malformed JSON bodies return 422 with field-level validation errors
  • AlertCondition op field strictly validated against Literal["eq", "neq", "contains", "in", "after_hours"]

C. NoSQL Injection Resistant

MongoDB operators passed as string filter values are treated as literals, not operators:

curl "http://localhost:8001/api/events?operation=\$ne"
# Returns 0 results (treated as literal string "$ne")

The _build_query() function in events.py uses re.escape() for search input and constructs queries safely.

D. Bulk Tags Pre-Count Check Works

bulk_tags endpoint capped at 10,000 matched documents via pre-count check. 93 events were successfully tagged with no bypass.

E. Rate Limiting Works When Redis is Healthy

  • /api/fetch-audit-logs: 429 after 11 requests (limit: 10/hr)
  • /api/events: 429 after ~120 requests (limit: 120/min)
  • Exempt paths work correctly: /health, /metrics, /api/config/auth, /api/config/features
  • Retry-After header is returned on 429 responses

Recommendations Summary

Priority Action Effort
P0 Fix CORS: do not allow credentials with wildcard/reflected origins Small
P1 Add security headers middleware (X-Content-Type-Options, X-Frame-Options, HSTS, Referrer-Policy) Small
P2 Make rate limiter fail-closed on Redis errors Small
P2 Disable OpenAPI docs in production or gate behind auth Small
P3 Sanitize or validate webhook validationToken before echo Small
P3 Gate /metrics behind IP allowlist Small
P3 Hide tenant_id/client_id from /api/config/auth when auth is disabled Tiny
P4 Consider Alpine.js CSP build to remove unsafe-eval from script-src Medium

Test Environment

Backend: uvicorn on localhost:8001 (auth=false, ai=false)
MongoDB: docker container, port 27018
Redis:   docker container, port 6380

Test commands and raw outputs available in /tmp/pen_test*.sh scripts.