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=Falseif wildcard origins are needed. - Or, require
CORS_ORIGINSto 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/authleakstenant_idandclient_ideven when auth is disabled. These values fall back to the Graph API credentials (TENANT_ID/CLIENT_ID), which may be sensitive./metricsexposes 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_idwhenauth_enabled=false. - Gate
/metricsbehind 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_sizecapped at 500 (returns 422 for 501, 0, -1)hourscapped at 720 (returns 422 for 721)- Invalid cursors return 400 with "Invalid cursor"
- Malformed JSON bodies return 422 with field-level validation errors
AlertConditionop field strictly validated againstLiteral["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-Afterheader 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.