2026-07-26 12:18:52 +02:00
|
|
|
"""Error logging endpoint — accepts frontend errors and logs them.
|
|
|
|
|
|
|
|
|
|
No auth required so errors can be logged even during logout.
|
2026-08-13 17:51:04 +02:00
|
|
|
Rate-limited to 10 requests per minute per IP via central check_rate_limit().
|
2026-07-26 20:49:15 +02:00
|
|
|
Context data is sanitized to prevent leaking sensitive information.
|
2026-07-26 12:18:52 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import logging
|
2026-07-26 20:49:15 +02:00
|
|
|
import re
|
2026-07-26 12:18:52 +02:00
|
|
|
from typing import Any
|
|
|
|
|
|
2026-08-13 17:51:04 +02:00
|
|
|
from fastapi import APIRouter, HTTPException, Request, Response, status
|
2026-07-26 12:18:52 +02:00
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/v1/errors", tags=["errors"])
|
|
|
|
|
|
2026-08-13 17:51:04 +02:00
|
|
|
# -- Rate limit constants (10 req/min per IP) --
|
|
|
|
|
_RATE_LIMIT_MAX = 10
|
|
|
|
|
_RATE_LIMIT_WINDOW = 60 # seconds
|
2026-07-26 12:18:52 +02:00
|
|
|
|
2026-07-26 20:49:15 +02:00
|
|
|
# -- Sensitive key patterns to strip from context --
|
|
|
|
|
_SENSITIVE_PATTERNS = re.compile(
|
|
|
|
|
r"(?i)(token|password|secret|authorization|cookie|session|api[_-]?key|"
|
|
|
|
|
r"access[_-]?token|refresh[_-]?token|csrf|bearer|private[_-]?key|"
|
|
|
|
|
r"client[_-]?secret|x[_-]?auth|x[_-]?api[_-]?key)",
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-26 12:18:52 +02:00
|
|
|
|
2026-07-26 20:49:15 +02:00
|
|
|
def _sanitize_context(context: Any, max_depth: int = 3, _depth: int = 0) -> Any:
|
2026-08-13 20:39:32 +02:00
|
|
|
"""Recursively remove sensitive keys and limit depth/size of context data.
|
|
|
|
|
|
|
|
|
|
Combines regex-based pattern matching with the central
|
|
|
|
|
:mod:`app.core.sensitive_data` module to ensure entity-specific
|
|
|
|
|
sensitive fields are also redacted.
|
|
|
|
|
"""
|
2026-07-26 20:49:15 +02:00
|
|
|
if _depth > max_depth:
|
|
|
|
|
return "[truncated]"
|
|
|
|
|
if isinstance(context, dict):
|
|
|
|
|
sanitized = {}
|
|
|
|
|
for key, value in context.items():
|
|
|
|
|
if _SENSITIVE_PATTERNS.search(str(key)):
|
|
|
|
|
sanitized[key] = "[redacted]"
|
|
|
|
|
else:
|
|
|
|
|
sanitized[key] = _sanitize_context(value, max_depth, _depth + 1)
|
|
|
|
|
return sanitized
|
|
|
|
|
if isinstance(context, list):
|
|
|
|
|
return [_sanitize_context(item, max_depth, _depth + 1) for item in context[:20]]
|
|
|
|
|
if isinstance(context, str) and len(context) > 500:
|
|
|
|
|
return context[:500] + "[truncated]"
|
|
|
|
|
return context
|
|
|
|
|
|
|
|
|
|
|
2026-08-13 20:39:32 +02:00
|
|
|
def _sanitize_entity_context(context: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
"""Sanitize context dict using both pattern matching and entity-aware redaction.
|
|
|
|
|
|
|
|
|
|
If the context contains an ``entity_type`` key, uses :func:`sanitize_dict`
|
|
|
|
|
from :mod:`app.core.sensitive_data` to redact entity-specific sensitive
|
|
|
|
|
fields. Falls back to pattern-based sanitization otherwise.
|
|
|
|
|
"""
|
|
|
|
|
from app.core.sensitive_data import sanitize_dict
|
|
|
|
|
|
|
|
|
|
entity_type = context.get("entity_type") or context.get("type")
|
|
|
|
|
if entity_type:
|
|
|
|
|
sanitized = sanitize_dict(context, str(entity_type))
|
|
|
|
|
else:
|
|
|
|
|
sanitized = dict(context)
|
|
|
|
|
return _sanitize_context(sanitized)
|
|
|
|
|
|
|
|
|
|
|
2026-07-26 12:18:52 +02:00
|
|
|
# -- Request schema --
|
|
|
|
|
|
|
|
|
|
class ErrorReport(BaseModel):
|
|
|
|
|
timestamp: str | None = None
|
|
|
|
|
message: str = Field(..., max_length=2000)
|
|
|
|
|
stack: str | None = Field(None, max_length=10000)
|
|
|
|
|
context: dict[str, Any] | None = None
|
|
|
|
|
url: str | None = Field(None, max_length=500)
|
2026-08-16 01:17:18 +02:00
|
|
|
user_agent: str | None = Field(None, max_length=500)
|
2026-07-26 12:18:52 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("", status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
async def report_error(error: ErrorReport, request: Request) -> Response:
|
|
|
|
|
"""Log a frontend error. No auth required. Rate-limited per IP."""
|
2026-08-13 17:51:04 +02:00
|
|
|
from app.core.rate_limit import check_rate_limit, get_client_ip
|
2026-07-26 20:49:15 +02:00
|
|
|
|
|
|
|
|
client_ip = get_client_ip(request)
|
2026-07-26 12:18:52 +02:00
|
|
|
|
2026-08-13 17:51:04 +02:00
|
|
|
try:
|
|
|
|
|
await check_rate_limit(
|
|
|
|
|
f"rate:errors:{client_ip}",
|
|
|
|
|
_RATE_LIMIT_MAX,
|
|
|
|
|
_RATE_LIMIT_WINDOW,
|
|
|
|
|
)
|
|
|
|
|
except HTTPException:
|
2026-07-26 12:18:52 +02:00
|
|
|
return Response(status_code=status.HTTP_429_TOO_MANY_REQUESTS)
|
|
|
|
|
|
2026-07-26 20:49:15 +02:00
|
|
|
# Sanitize context to prevent leaking sensitive data
|
2026-08-13 20:39:32 +02:00
|
|
|
if error.context:
|
|
|
|
|
sanitized_context = _sanitize_entity_context(error.context)
|
|
|
|
|
else:
|
|
|
|
|
sanitized_context = None
|
2026-07-26 20:49:15 +02:00
|
|
|
|
2026-07-26 12:18:52 +02:00
|
|
|
# Log with structured info
|
|
|
|
|
logger.error(
|
|
|
|
|
"Frontend error reported: %s",
|
|
|
|
|
error.message,
|
|
|
|
|
extra={
|
|
|
|
|
"error_timestamp": error.timestamp,
|
|
|
|
|
"error_message": error.message,
|
|
|
|
|
"error_stack": error.stack,
|
2026-07-26 20:49:15 +02:00
|
|
|
"error_context": sanitized_context,
|
2026-07-26 12:18:52 +02:00
|
|
|
"error_url": error.url,
|
|
|
|
|
"error_user_agent": error.userAgent,
|
|
|
|
|
"client_ip": client_ip,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-26 20:49:15 +02:00
|
|
|
# If forgejo_error_reporter plugin is active, forward sanitized error
|
2026-07-26 12:49:39 +02:00
|
|
|
try:
|
2026-08-16 01:17:18 +02:00
|
|
|
from app.plugins.builtins.contracts import get_contract
|
|
|
|
|
reporter_contract = get_contract("forgejo_error_reporter")
|
|
|
|
|
if reporter_contract is not None:
|
|
|
|
|
entry = {
|
2026-07-26 12:49:39 +02:00
|
|
|
"message": error.message,
|
|
|
|
|
"stack": error.stack,
|
|
|
|
|
"url": error.url,
|
|
|
|
|
"userAgent": error.userAgent,
|
|
|
|
|
"timestamp": error.timestamp,
|
2026-07-26 20:49:15 +02:00
|
|
|
"context": sanitized_context,
|
2026-07-26 12:49:39 +02:00
|
|
|
}
|
2026-08-16 01:17:18 +02:00
|
|
|
await reporter_contract.report_error_to_forgejo(entry)
|
2026-07-26 12:49:39 +02:00
|
|
|
except Exception:
|
|
|
|
|
pass # Plugin not active or error in reporting
|
|
|
|
|
|
2026-07-26 12:18:52 +02:00
|
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|