diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c74ebf..fcbb1d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ on: - 'pytest.ini' - 'setup.py' - 'tests/**' - pull_request_target: + pull_request: branches: - master paths: @@ -37,8 +37,8 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: python-version: '3.10' - run: pip install uv @@ -68,9 +68,9 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} @@ -84,4 +84,4 @@ jobs: - name: Run tests run: | - IPINFO_TOKEN='${{ secrets.IPINFO_TOKEN }}' REDIS_URL='redis://localhost:6379' REDIS_PREFIX='test:tornadoapi_guard:' uv run pytest -v --cov=tornadoapi_guard + IPINFO_TOKEN='${{ secrets.IPINFO_TOKEN || 'test_token' }}' REDIS_URL='redis://localhost:6379' REDIS_PREFIX='test:tornadoapi_guard:' uv run pytest -v --cov=tornadoapi_guard --cov-fail-under=100 diff --git a/.github/workflows/code-ql.yml b/.github/workflows/code-ql.yml index 0654459..f465791 100644 --- a/.github/workflows/code-ql.yml +++ b/.github/workflows/code-ql.yml @@ -48,7 +48,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml index ad17c67..5c1cfe5 100644 --- a/.github/workflows/container-release.yml +++ b/.github/workflows/container-release.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set version tag id: set_version @@ -33,7 +33,7 @@ jobs: echo "IS_RELEASE=false" >> $GITHUB_ENV fi - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.10' diff --git a/.github/workflows/docksec.yml b/.github/workflows/docksec.yml new file mode 100644 index 0000000..36de464 --- /dev/null +++ b/.github/workflows/docksec.yml @@ -0,0 +1,49 @@ +name: DockSec + +on: + push: + branches: [master] + paths: + - 'Dockerfile' + - '.github/workflows/docksec.yml' + pull_request: + paths: + - 'Dockerfile' + - '.github/workflows/docksec.yml' + schedule: + - cron: '0 6 * * 1' + +permissions: + contents: read + +jobs: + scan: + name: Scan Dockerfile + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + security-events: write + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run DockSec + uses: OWASP/DockSec@d52c8e928552dc39003abda21ac6bc4662b06c39 # v2026.9.21 + with: + dockerfile: Dockerfile + scan_only: 'true' + sarif: 'true' + output_dir: ${{ github.workspace }}/docksec-results + + - name: Upload SARIF to Code Scanning + # Not `if: always()`: on a fork pull_request, GITHUB_TOKEN is read-only and this + # upload would fail every time, turning a routine fork contribution into a red + # required check. Skip it there instead of forcing a failure. + if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v3 + with: + sarif_file: docksec-results + category: docksec diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 54656f3..2fe90c9 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,14 +22,18 @@ on: permissions: contents: write +concurrency: + group: docs-deploy + cancel-in-progress: false + jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v7 with: python-version: 3.x diff --git a/.github/workflows/issue-link.yml b/.github/workflows/issue-link.yml new file mode 100644 index 0000000..5f600ac --- /dev/null +++ b/.github/workflows/issue-link.yml @@ -0,0 +1,36 @@ +name: Issue link + +on: + pull_request: + types: [opened, edited, reopened, synchronize, labeled, unlabeled] + +permissions: + issues: read + pull-requests: read + +jobs: + issue-link: + name: issue-link + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v9 + with: + script: | + const pr = context.payload.pull_request; + if (pr.user.type === 'Bot') return core.info(`bot author ${pr.user.login}, exempt`); + if (pr.labels.some((label) => label.name === 'no-issue')) return core.info('no-issue label, exempt'); + const pattern = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|delivers?(?:\s+issue)?)\b:?\s*(?:https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/|#)(\d+)/gi; + const numbers = [...new Set([...(pr.body || '').matchAll(pattern)].map((match) => Number(match[1])))]; + if (numbers.length === 0) return core.setFailed('PR body must say "Closes #N" for an open issue in this repo, or the PR must carry the no-issue label'); + const reasons = []; + for (const number of numbers) { + try { + const { data } = await github.rest.issues.get({ ...context.repo, issue_number: number }); + if (data.pull_request) reasons.push(`#${number} is a pull request, not an issue`); + else if (data.state !== 'open') reasons.push(`#${number} is ${data.state}`); + else return core.info(`delivers open issue #${number}`); + } catch (error) { + reasons.push(`#${number}: ${error.message}`); + } + } + core.setFailed(reasons.join('; ')); diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 7446f7b..c5a79e2 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -10,7 +10,7 @@ jobs: pull-requests: write steps: - - uses: actions/labeler@v6 + - uses: actions/labeler@v7 with: repo-token: ${{ secrets.GITHUB_TOKEN }} configuration-path: .github/labeler.yml diff --git a/.github/workflows/live-smoke.yml b/.github/workflows/live-smoke.yml new file mode 100644 index 0000000..6b78996 --- /dev/null +++ b/.github/workflows/live-smoke.yml @@ -0,0 +1,121 @@ +name: Live Smoke + +on: + push: + branches: + - master + paths: + - 'tornadoapi_guard/**' + - 'examples/**' + - '.github/workflows/live-smoke.yml' + pull_request: + paths: + - 'tornadoapi_guard/**' + - 'examples/**' + - '.github/workflows/live-smoke.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: live-smoke-${{ github.ref }} + cancel-in-progress: true + +jobs: + live-smoke: + name: Dockerized smoke run (simple_app) + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + LIVE_SMOKE_PROJECT: tornadoapi-guard-live-smoke + + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: '3.12' + + - name: Install uv + run: | + pip install uv + + - name: Build the tornadoapi-guard wheel from this checkout + run: | + uv build --wheel --out-dir examples/live_smoke_wheels + + - name: Stage the example app with the checkout wheel + run: | + rm -rf live_smoke_stack + cp -r examples/simple_app live_smoke_stack + wheel=$(ls examples/live_smoke_wheels/tornadoapi_guard-*.whl | head -n 1) + cp "$wheel" live_smoke_stack/ + # Install the wheel from this checkout instead of the PyPI release; + # the wheel must be COPYed into the image before pip runs + python3 - <<'EOF' + import pathlib + stack = pathlib.Path("live_smoke_stack") + wheel = next(stack.glob("tornadoapi_guard-*.whl")) + req = stack / "requirements.txt" + lines = [line for line in req.read_text().splitlines() if not line.startswith("tornadoapi-guard")] + lines.append(f"tornadoapi-guard @ file:///app/{wheel.name}") + req.write_text("\n".join(lines) + "\n") + dockerfile = stack / "Dockerfile" + df = dockerfile.read_text() + anchor = "COPY requirements.txt /app/" + assert anchor in df + dockerfile.write_text(df.replace(anchor, anchor + f"\nCOPY {wheel.name} /app/")) + EOF + + - name: Bring the smoke stack up + run: | + docker compose -p "$LIVE_SMOKE_PROJECT" -f live_smoke_stack/docker-compose.yml up --build -d --wait + + - name: Run smoke assertions + run: | + set -euo pipefail + base="http://localhost:8000" + + # Wait for the app to finish initializing (engine redis + ipinfo setup) + ready="" + for i in $(seq 1 30); do + if [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 "$base/")" = "200" ]; then + ready=1; break + fi + sleep 2 + done + if [ -z "$ready" ]; then + echo "FAIL: app never became ready"; exit 1 + fi + + check() { + local name="$1" expected="$2"; shift 2 + local status + status=$(curl -sS --max-time 10 -o /tmp/smoke_body -w '%{http_code}' "$@") || { + echo "FAIL: $name (curl error)"; exit 1 + } + if [ "$status" != "$expected" ]; then + echo "FAIL: $name (expected $expected, got $status)"; cat /tmp/smoke_body; exit 1 + fi + echo "ok: $name -> $status" + } + + check "GET / serves" 200 "$base/" + check "GET /basic/health serves" 200 "$base/basic/health" + # The guard's custom_request_check blocks debug=true with a 403 body + check "GET /?debug=true blocked by custom check" 403 "$base/?debug=true" + + - name: Dump container logs on failure + if: failure() + run: | + docker compose -p "$LIVE_SMOKE_PROJECT" -f live_smoke_stack/docker-compose.yml logs --no-color + + - name: Bring the smoke stack down + if: always() + run: | + docker compose -p "$LIVE_SMOKE_PROJECT" -f live_smoke_stack/docker-compose.yml down -v --remove-orphans diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8099e09..f028e38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,20 +14,20 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.10' - name: Install dependencies run: | python -m pip install --upgrade pip - pip install setuptools wheel twine + pip install build twine - name: Build package run: | - python setup.py sdist bdist_wheel + python -m build - name: Publish package env: diff --git a/.github/workflows/scheduled-lint.yml b/.github/workflows/scheduled-lint.yml index 8dcff0d..172fcc7 100644 --- a/.github/workflows/scheduled-lint.yml +++ b/.github/workflows/scheduled-lint.yml @@ -12,9 +12,9 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.10' @@ -32,9 +32,9 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.10' diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml index ce212e3..5e32878 100644 --- a/.github/workflows/sync-labels.yml +++ b/.github/workflows/sync-labels.yml @@ -21,7 +21,7 @@ jobs: labeler: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Sync labels from .github/labels.yml uses: crazy-max/ghaction-github-labeler@v5 diff --git a/.github/workflows/upstream-drift.yml b/.github/workflows/upstream-drift.yml new file mode 100644 index 0000000..2302ae2 --- /dev/null +++ b/.github/workflows/upstream-drift.yml @@ -0,0 +1,73 @@ +name: Upstream Drift + +on: + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + +jobs: + drift: + name: Test against guard-core@master + runs-on: ubuntu-latest + + services: + redis: + image: redis + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout tornadoapi-guard + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Checkout guard-core@master + uses: actions/checkout@v7 + with: + repository: rennf93/guard-core + ref: master + path: guard-core-upstream + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: '3.12' + + - name: Install uv + run: | + pip install uv + + - name: Build the guard-core wheel from guard-core@master + run: | + cd guard-core-upstream + pip install build + python -m build --wheel --outdir ../wheels + + - name: Install the tornadoapi-guard dev environment + run: | + uv sync --extra dev + + - name: Force-install the guard-core@master wheel over the resolved one + run: | + wheel=$(ls wheels/guard_core-*.whl | head -n 1) + uv pip install --python .venv/bin/python --no-deps --force-reinstall "$wheel" + + - name: Run the tornadoapi-guard test suite against guard-core@master + run: | + IPINFO_TOKEN='test_token' REDIS_URL='redis://localhost:6379' REDIS_PREFIX='drift:tornadoapi_guard:' uv run python -m pytest -v --cov=tornadoapi_guard + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: upstream-drift-logs + path: | + wheels/ + retention-days: 3 diff --git a/.gitignore b/.gitignore index d34fa2f..1d77f65 100644 --- a/.gitignore +++ b/.gitignore @@ -228,3 +228,7 @@ ZZZ/ .claude/ .cursor/ .kiro/ + +# generated by the live-smoke workflow +live_smoke_stack/ +examples/live_smoke_wheels/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c3d38ab..0dc002b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ Release Notes ___ +v1.2.0 (unreleased) +------------------- + +Adapter parity with the family baseline (targets the 1.2.0 adapter train) + +- **CI modernization** - `ci.yml` uses `pull_request` instead of `pull_request_target` (no base-branch secrets context on PR code), all actions align on v7 majors, and the release build uses `python -m build` instead of the legacy `setup.py sdist bdist_wheel`. +- **New workflows to match the adapter set** - `live-smoke.yml` (builds the wheel from the checkout and smoke-tests the example app in Docker), `upstream-drift.yml` (daily suite run against a guard-core@master wheel), and `docksec.yml` (Dockerfile scanning with SARIF). +- **Added (examples)** - `examples/advanced_app`: production-shaped topology with an nginx edge (connection shedding, forwarded headers), Redis-backed guard state, health/ready endpoints for orchestration, a route-scoped strict rate limit, and an admin-token-protected utility route. +- **Docs deploy serialization** - `docs.yml` gains the `docs-deploy` concurrency group the other adapters already use. + +___ + v1.0.0 (2026-09-24) ------------------- diff --git a/examples/advanced_app/Dockerfile b/examples/advanced_app/Dockerfile new file mode 100644 index 0000000..8a3fab3 --- /dev/null +++ b/examples/advanced_app/Dockerfile @@ -0,0 +1,35 @@ +ARG PYTHON_VERSION=3.12 +FROM python:${PYTHON_VERSION}-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libsodium23 \ + wget \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +ENV PIP_NO_CACHE_DIR=false \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=10 \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +COPY examples/advanced_app/requirements.txt /app/ + +RUN pip install --no-cache-dir -r requirements.txt + +COPY examples/advanced_app/main.py /app/ + +RUN mkdir -p /app/data/ipinfo + +EXPOSE 8000 + +HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=6 \ + CMD wget -qO- http://127.0.0.1:8000/health + +RUN useradd --create-home --uid 10001 guard \ + && mkdir -p /app/data/ipinfo \ + && chown -R guard:guard /app +USER guard + +CMD ["python", "main.py"] diff --git a/examples/advanced_app/README.md b/examples/advanced_app/README.md new file mode 100644 index 0000000..8dda8fe --- /dev/null +++ b/examples/advanced_app/README.md @@ -0,0 +1,36 @@ +# TornadoAPI Guard advanced example + +Production-shaped topology mirroring the ecosystem's other advanced examples: +an nginx edge in front of the Tornado app, Redis-backed guard state, and +orchestration-friendly health endpoints. + +## Topology + +- `nginx` (host port 80): connection shedding (`limit_req`), forwarded-header + normalization, proxying to the app. +- `app`: the Tornado application with `SecurityMiddleware` wired; trusts a + single proxy hop, so real client IPs come from `X-Forwarded-For`. +- `redis`: guard rate-limit, ban, and cache state. + +## Routes + +| Route | Behavior | +| --- | --- | +| `GET /` | Service index | +| `GET /health`, `GET /ready` | Guard-excluded liveness/readiness for orchestration | +| `GET /rate/strict` | Route-scoped rate limit (5 requests / 60s) | +| `GET /admin/check` | Requires the `X-Admin-Token` header | +| any `?debug=true` | Blocked by the custom request check (403) | + +## Run + +```bash +docker compose up --build +curl -sS http://localhost/ +curl -sS -H "X-Admin-Token: admin-token-change-me" http://localhost/admin/check +curl -sS http://localhost/rate/strict +``` + +Configuration is environment-driven (`REDIS_URL`, `REDIS_PREFIX`, +`IPINFO_TOKEN`, `ADMIN_TOKEN`, `RATE_LIMIT`, `RATE_LIMIT_WINDOW`, +`AUTO_BAN_THRESHOLD`, `AUTO_BAN_DURATION`); see `docker-compose.yml`. diff --git a/examples/advanced_app/docker-compose.yml b/examples/advanced_app/docker-compose.yml new file mode 100644 index 0000000..750a3cb --- /dev/null +++ b/examples/advanced_app/docker-compose.yml @@ -0,0 +1,73 @@ +services: + nginx: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + app: + condition: service_healthy + restart: unless-stopped + deploy: + resources: + limits: + cpus: "0.5" + memory: 128M + + app: + build: + context: ../.. + dockerfile: examples/advanced_app/Dockerfile + image: tornadoapi-guard-advanced-app:local + expose: + - "8000" + environment: + # Trust only the compose network for forwarded headers (nginx hop). + - TRUSTED_PROXIES=172.16.0.0/12,10.0.0.0/8 + - REDIS_URL=redis://redis:6379 + - REDIS_PREFIX=${REDIS_PREFIX:-tornadoapi_guard:} + - IPINFO_TOKEN=${IPINFO_TOKEN:-test_token} + - ADMIN_TOKEN=${ADMIN_TOKEN:-admin-token-change-me} + - RATE_LIMIT=${RATE_LIMIT:-30} + - RATE_LIMIT_WINDOW=${RATE_LIMIT_WINDOW:-60} + - AUTO_BAN_THRESHOLD=${AUTO_BAN_THRESHOLD:-5} + - AUTO_BAN_DURATION=${AUTO_BAN_DURATION:-300} + - HOST=0.0.0.0 + - PORT=8000 + healthcheck: + # wget --spider issues HEAD, which Tornado handlers reject with 405 + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8000/health"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 10s + depends_on: + redis: + condition: service_healthy + restart: unless-stopped + deploy: + resources: + limits: + cpus: "1.0" + memory: 256M + + redis: + image: redis:7-alpine + command: redis-server --save 60 1 --loglevel warning --maxmemory 128mb --maxmemory-policy allkeys-lru + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 6 + restart: unless-stopped + deploy: + resources: + limits: + cpus: "0.5" + memory: 192M + +volumes: + redis-data: diff --git a/examples/advanced_app/main.py b/examples/advanced_app/main.py new file mode 100644 index 0000000..8b3635b --- /dev/null +++ b/examples/advanced_app/main.py @@ -0,0 +1,191 @@ +"""Production-shaped TornadoAPI Guard example: nginx edge, trusted proxies, +admin-token-protected utilities, and health endpoints for orchestration.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +from datetime import datetime, timezone +from typing import Any + +import tornado.httpserver +import tornado.ioloop +import tornado.web +from guard_core.protocols.request_protocol import GuardRequest +from guard_core.protocols.response_protocol import GuardResponse + +from tornadoapi_guard import ( + SecurityConfig, + SecurityDecorator, + SecurityHandler, + SecurityMiddleware, + TornadoGuardResponse, +) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +def _json_dumps(payload: dict[str, Any]) -> str: + def _default(value: Any) -> str: + if isinstance(value, datetime): + return value.isoformat() + raise TypeError(f"{type(value).__name__} is not JSON serializable") + + return json.dumps(payload, default=_default) + + +class JSONHandler(SecurityHandler): + def write_json(self, payload: dict[str, Any], status_code: int = 200) -> None: + self.set_status(status_code) + self.set_header("Content-Type", "application/json; charset=utf-8") + self.write(_json_dumps(payload)) + + def write_error(self, status_code: int, **kwargs: Any) -> None: + self.set_header("Content-Type", "application/json; charset=utf-8") + # Static reason phrase lookup: never reflect request-derived text + reason = tornado.httputil.responses.get(status_code, "error") + self.finish( + _json_dumps( + { + "detail": reason, + "error_code": f"HTTP_{status_code}", + "timestamp": datetime.now(timezone.utc).isoformat(), + } + ) + ) + + +_REDIS_URL = os.getenv("REDIS_URL", "") +_ENABLE_REDIS = bool(_REDIS_URL) +_ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", "admin-token-change-me") + + +async def reject_debug(request: GuardRequest) -> GuardResponse | None: + if request.query_params.get("debug") == "true": + return TornadoGuardResponse( + status_code=403, + headers={"Content-Type": "application/json; charset=utf-8"}, + body=_json_dumps({"detail": "Debug mode not allowed"}).encode("utf-8"), + ) + return None + + +security_config = SecurityConfig( + trusted_proxies=["127.0.0.1", "10.0.0.0/8", "172.16.0.0/12"], + trusted_proxy_depth=1, + trust_x_forwarded_proto=True, + enable_rate_limiting=True, + rate_limit=int(os.getenv("RATE_LIMIT", "30")), + rate_limit_window=int(os.getenv("RATE_LIMIT_WINDOW", "60")), + enable_ip_banning=True, + auto_ban_threshold=int(os.getenv("AUTO_BAN_THRESHOLD", "5")), + auto_ban_duration=int(os.getenv("AUTO_BAN_DURATION", "300")), + enable_penetration_detection=True, + log_format="json", + enable_redis=_ENABLE_REDIS, + redis_url=_REDIS_URL or "redis://localhost:6379", + redis_prefix=os.getenv("REDIS_PREFIX", "tornadoapi_guard:"), + enforce_https=False, + exclude_paths=["/health", "/ready"], + passive_mode=False, + custom_request_check=reject_debug, +) + +security_middleware = SecurityMiddleware(config=security_config) +guard_decorator = SecurityDecorator(security_config) +security_middleware.set_decorator_handler(guard_decorator) + + +class RootHandler(JSONHandler): + async def get(self) -> None: + self.write_json( + { + "message": "TornadoAPI Guard advanced example", + "details": { + "topology": "nginx edge -> tornado app", + "routes": { + "/health": "Liveness (guard-excluded)", + "/ready": "Readiness (guard-excluded)", + "/rate/strict": "Strict per-route rate limit", + "/admin/check": "Admin-token-protected utility", + }, + }, + } + ) + + +class HealthHandler(JSONHandler): + async def get(self) -> None: + self.write_json({"status": "healthy"}) + + +class ReadyHandler(JSONHandler): + async def get(self) -> None: + self.write_json({"status": "ready"}) + + +class RateStrictHandler(JSONHandler): + @guard_decorator.rate_limit(requests=5, window=60) + async def get(self) -> None: + self.write_json( + { + "message": "Strict rate limit endpoint", + "details": {"limit": "5 requests per 60 seconds"}, + } + ) + + +class AdminCheckHandler(JSONHandler): + @guard_decorator.require_headers({"X-Admin-Token": _ADMIN_TOKEN}) + async def get(self) -> None: + # No request-derived values in the response: forwarded headers are + # attacker-controlled and must not be reflected back. + self.write_json( + { + "message": "Admin check passed", + "details": {"timestamp": datetime.now(timezone.utc)}, + } + ) + + +def build_application() -> tornado.web.Application: + handlers: list[tuple[str, type[tornado.web.RequestHandler]]] = [ + (r"/", RootHandler), + (r"/health", HealthHandler), + (r"/ready", ReadyHandler), + (r"/rate/strict", RateStrictHandler), + (r"/admin/check", AdminCheckHandler), + ] + return tornado.web.Application( + handlers, + security_middleware=security_middleware, + guard_decorator=guard_decorator, + default_handler_class=JSONHandler, + ) + + +async def main() -> None: + logger.info("TornadoAPI Guard advanced example starting up...") + await security_middleware.initialize() + app = build_application() + server = tornado.httpserver.HTTPServer(app) + host = os.getenv("HOST", "0.0.0.0") + port = int(os.getenv("PORT", "8000")) + server.listen(port, address=host) + logger.info("Listening on http://%s:%s", host, port) + try: + await asyncio.Event().wait() + finally: + logger.info("TornadoAPI Guard advanced example shutting down...") + server.stop() + await security_middleware.reset() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/advanced_app/nginx/nginx.conf b/examples/advanced_app/nginx/nginx.conf new file mode 100644 index 0000000..0abbd84 --- /dev/null +++ b/examples/advanced_app/nginx/nginx.conf @@ -0,0 +1,67 @@ +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + + # Defense in depth: the edge sheds gross overflow before the guard's + # Redis-backed limits see the request. + limit_req_zone $binary_remote_addr zone=general:10m rate=30r/s; + + client_max_body_size 10m; + + upstream app { + server app:8000; + } + + server { + listen 80; + server_name _; + + location /health { + proxy_pass http://app; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /ready { + proxy_pass http://app; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + limit_req zone=general burst=50 nodelay; + + proxy_pass http://app; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 30s; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + } + } +} diff --git a/examples/advanced_app/requirements.txt b/examples/advanced_app/requirements.txt new file mode 100644 index 0000000..56442bf --- /dev/null +++ b/examples/advanced_app/requirements.txt @@ -0,0 +1,4 @@ +tornado +tornadoapi-guard +redis +requests diff --git a/examples/simple_app/docker-compose.yml b/examples/simple_app/docker-compose.yml index 4001e89..db2bb75 100644 --- a/examples/simple_app/docker-compose.yml +++ b/examples/simple_app/docker-compose.yml @@ -16,7 +16,7 @@ services: - ./:/app:z depends_on: redis: - condition: service_started + condition: service_healthy redis: image: redis:alpine @@ -25,6 +25,11 @@ services: volumes: - redis-data:/data command: redis-server --save 60 1 --loglevel warning + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 volumes: redis-data: diff --git a/examples/simple_app/main.py b/examples/simple_app/main.py index 07a8f14..ece85f0 100644 --- a/examples/simple_app/main.py +++ b/examples/simple_app/main.py @@ -86,7 +86,8 @@ async def custom_response_modifier(response: GuardResponse) -> GuardResponse: security_config = SecurityConfig( - whitelist=["127.0.0.1", "::1", "10.0.0.0/8"], + # Include the Docker client networks so host curls are not flagged + whitelist=["127.0.0.1", "::1", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"], blacklist=["192.168.100.0/24"], trusted_proxies=["127.0.0.1", "10.0.0.0/8"], trusted_proxy_depth=2, @@ -100,6 +101,7 @@ async def custom_response_modifier(response: GuardResponse) -> GuardResponse: auto_ban_threshold=5, auto_ban_duration=300, enable_penetration_detection=True, + behavior_scan_response_body=True, cloud_ip_refresh_interval=1800, log_format="json", enable_redis=_ENABLE_REDIS, diff --git a/pyproject.toml b/pyproject.toml index dca1ab7..ea468b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tornadoapi_guard" -version = "1.0.0" +version = "1.2.0" description = "Production-ready security middleware for Tornado - IP filtering, rate limiting, penetration detection, and 20+ per-route security decorators." authors = [ {name = "Renzo Franceschini", email = "rennf93@users.noreply.github.com"}