# 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: ` AND `Access-Control-Allow-Credentials: true` for every origin that sends an `Origin` header. **Evidence:** ```bash 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: ```python 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:** ```bash 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:** ```json { "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:** ```bash curl -X POST "http://localhost:8001/api/webhooks/graph?validationToken=" # Returns: 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: ```bash 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.*