From f075f83b34a667902c8225b4091000c970bcaff4 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 28 Sep 2026 22:39:26 +0200 Subject: [PATCH 1/9] ci(workflows): add issue-link gate and fork-safe IPINFO_TOKEN fallback --- .github/workflows/ci.yml | 2 +- .github/workflows/issue-link.yml | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/issue-link.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c74ebf..4c11133 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 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('; ')); From 37ea267e9516dc98aa7b35eeeec6eaddc5d4e3f2 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 28 Sep 2026 23:31:58 +0200 Subject: [PATCH 2/9] ci: enforce the 100% coverage gate --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c11133..326f95b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,4 +84,4 @@ jobs: - name: Run tests run: | - IPINFO_TOKEN='${{ secrets.IPINFO_TOKEN || 'test_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 From 3a05834ace2590b99afd7da306a465207112cfbe Mon Sep 17 00:00:00 2001 From: Renn F Date: Tue, 29 Sep 2026 00:49:54 +0200 Subject: [PATCH 3/9] ci: finish adapter parity with the family baseline - modernize: pull_request instead of pull_request_target, actions on v7, python -m build for the release, docs-deploy concurrency - add live-smoke, upstream-drift, and docksec workflows - add examples/advanced_app (nginx edge, healthchecks, admin token) - enforce the 100% coverage gate - version 1.2.0: adapter train alignment, changelog documents the delta --- .github/workflows/ci.yml | 10 +- .github/workflows/code-ql.yml | 2 +- .github/workflows/container-release.yml | 4 +- .github/workflows/docksec.yml | 49 ++++++ .github/workflows/docs.yml | 8 +- .github/workflows/labeler.yml | 2 +- .github/workflows/live-smoke.yml | 104 +++++++++++++ .github/workflows/release.yml | 8 +- .github/workflows/scheduled-lint.yml | 8 +- .github/workflows/sync-labels.yml | 2 +- .github/workflows/upstream-drift.yml | 73 +++++++++ CHANGELOG.md | 12 ++ examples/advanced_app/Dockerfile | 27 ++++ examples/advanced_app/README.md | 36 +++++ examples/advanced_app/docker-compose.yml | 73 +++++++++ examples/advanced_app/main.py | 189 +++++++++++++++++++++++ examples/advanced_app/nginx/nginx.conf | 67 ++++++++ examples/advanced_app/requirements.txt | 4 + pyproject.toml | 2 +- 19 files changed, 659 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/docksec.yml create mode 100644 .github/workflows/live-smoke.yml create mode 100644 .github/workflows/upstream-drift.yml create mode 100644 examples/advanced_app/Dockerfile create mode 100644 examples/advanced_app/README.md create mode 100644 examples/advanced_app/docker-compose.yml create mode 100644 examples/advanced_app/main.py create mode 100644 examples/advanced_app/nginx/nginx.conf create mode 100644 examples/advanced_app/requirements.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 326f95b..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 }} 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/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..4bfa5c7 --- /dev/null +++ b/.github/workflows/live-smoke.yml @@ -0,0 +1,104 @@ +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 + python3 - <<'EOF' + import pathlib + req = pathlib.Path("live_smoke_stack/requirements.txt") + lines = [line for line in req.read_text().splitlines() if not line.startswith("tornadoapi-guard")] + lines.append("tornadoapi-guard @ file:///app/tornadoapi_guard-wheel.whl") + req.write_text("\n".join(lines) + "\n") + wheel_dir = pathlib.Path("live_smoke_stack") + wheel = next(wheel_dir.glob("tornadoapi_guard-*.whl")) + wheel.rename(wheel_dir / "tornadoapi_guard-wheel.whl") + 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" + + 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/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..a51de1d --- /dev/null +++ b/examples/advanced_app/Dockerfile @@ -0,0 +1,27 @@ +ARG PYTHON_VERSION=3.12 +FROM python:${PYTHON_VERSION}-slim + +RUN apt-get update && apt-get install -y \ + 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 + +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..08585d1 --- /dev/null +++ b/examples/advanced_app/main.py @@ -0,0 +1,189 @@ +"""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") + reason = self._reason or "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: + client = self.request.remote_ip or "unknown" + self.write_json( + { + "message": "Admin check passed", + "details": {"client": client, "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/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"} From cebbd5b876e4694a83620352b2eb85b179a0f1c0 Mon Sep 17 00:00:00 2001 From: Renn F Date: Tue, 29 Sep 2026 01:01:25 +0200 Subject: [PATCH 4/9] fix(examples): keep simple_app compatible with guard-core 4.2 config validation - behavior_scan_response_body=True so the return_monitor 404 demo is valid - whitelist includes the Docker client networks (gateway IPs were flagged) - redis healthcheck + service_healthy ordering for deterministic startup --- .github/workflows/live-smoke.yml | 12 +- examples/simple_app/docker-compose.yml | 7 +- examples/simple_app/main.py | 4 +- live_smoke_stack/Dockerfile | 31 + live_smoke_stack/README.md | 107 ++ live_smoke_stack/docker-compose.yml | 30 + live_smoke_stack/main.py | 968 ++++++++++++++++++ live_smoke_stack/requirements.txt | 4 + .../tornadoapi_guard-1.2.0-py3-none-any.whl | Bin 0 -> 15682 bytes 9 files changed, 1160 insertions(+), 3 deletions(-) create mode 100644 live_smoke_stack/Dockerfile create mode 100644 live_smoke_stack/README.md create mode 100644 live_smoke_stack/docker-compose.yml create mode 100644 live_smoke_stack/main.py create mode 100644 live_smoke_stack/requirements.txt create mode 100644 live_smoke_stack/tornadoapi_guard-1.2.0-py3-none-any.whl diff --git a/.github/workflows/live-smoke.yml b/.github/workflows/live-smoke.yml index 4bfa5c7..1d5e11d 100644 --- a/.github/workflows/live-smoke.yml +++ b/.github/workflows/live-smoke.yml @@ -55,7 +55,8 @@ jobs: 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 + # 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 req = pathlib.Path("live_smoke_stack/requirements.txt") @@ -65,6 +66,15 @@ jobs: wheel_dir = pathlib.Path("live_smoke_stack") wheel = next(wheel_dir.glob("tornadoapi_guard-*.whl")) wheel.rename(wheel_dir / "tornadoapi_guard-wheel.whl") + dockerfile = pathlib.Path("live_smoke_stack/Dockerfile") + df = dockerfile.read_text() + anchor = "COPY requirements.txt /app/" + assert anchor in df + df = df.replace( + anchor, + anchor + "\nCOPY tornadoapi_guard-wheel.whl /app/", + ) + dockerfile.write_text(df) EOF - name: Bring the smoke stack up 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/live_smoke_stack/Dockerfile b/live_smoke_stack/Dockerfile new file mode 100644 index 0000000..a55c2c7 --- /dev/null +++ b/live_smoke_stack/Dockerfile @@ -0,0 +1,31 @@ +ARG PYTHON_VERSION=3.10 +FROM python:${PYTHON_VERSION}-slim + +RUN apt-get update && apt-get install -y \ + libsodium23 \ + wget \ + build-essential \ + libffi-dev \ + curl \ + && 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 requirements.txt /app/ +COPY tornadoapi_guard-1.2.0-py3-none-any.whl /app/ + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /app/ + +RUN mkdir -p /app/data/ipinfo + +EXPOSE 8000 + +CMD ["python", "main.py"] diff --git a/live_smoke_stack/README.md b/live_smoke_stack/README.md new file mode 100644 index 0000000..af3dde3 --- /dev/null +++ b/live_smoke_stack/README.md @@ -0,0 +1,107 @@ +TornadoAPI Guard Example App +============================ + +This example demonstrates how to use TornadoAPI Guard inside a Tornado application. + +___ + +Running the example +------------------- + +Using Docker Compose +-------------------- + +```bash +# Start the example app and Redis +docker compose up + +# Restart +docker compose restart + +# Stop +docker compose down +``` + +Running locally (no Docker) +--------------------------- + +```bash +cd examples/simple_app +pip install -r requirements.txt +python main.py +``` + +The app listens on `http://127.0.0.1:8000` by default. Override host/port with the `HOST` and `PORT` environment variables. + +___ + +Available endpoints +------------------- + +- `/` — API overview with feature list and route map +- `/basic/` — Basic endpoint (subject to global rate limit) +- `/basic/ip` — Return client IP information +- `/basic/health` — Health check (excluded from all security checks) +- `/basic/echo` — Echo POST body with headers and method +- `/access/ip-whitelist` — Require whitelisted client IP +- `/access/ip-blacklist` — Reject blacklisted client IP +- `/access/country-block` — Block requests from specific countries +- `/access/country-allow` — Allow only specific countries +- `/access/no-cloud` — Block all known cloud provider IPs +- `/access/no-aws` — Block AWS IPs only +- `/access/bypass-demo` — Bypass rate limit + IP/country/cloud checks for this route +- `/auth/https-only` — Require HTTPS +- `/auth/bearer-auth` — Require Bearer token in Authorization header +- `/auth/api-key` — Require `X-API-Key` header +- `/auth/custom-headers` — Require arbitrary custom headers +- `/rate/custom-limit` — 5 requests per 60 seconds +- `/rate/strict-limit` — 1 request per 10 seconds +- `/rate/geo-rate-limit` — Per-country rate limits +- `/behavior/usage-monitor` — Log when >10 calls in 5 minutes +- `/behavior/return-monitor/{status_code}` — Ban IP after 3 HTTP 404s within 60s +- `/behavior/suspicious-frequency` — Throttle high-frequency requests +- `/behavior/behavior-rules` (POST) — Combined frequency + return pattern rules +- `/headers/` — Security headers overview +- `/headers/test-page` — HTML page demonstrating CSP +- `/headers/csp-report` (POST) — CSP violation report receiver +- `/headers/frame-test` — X-Frame-Options demonstration +- `/headers/hsts-info` — HSTS configuration info +- `/headers/security-analysis` — Per-request security analysis +- `/content/no-bots` — Block common bot user agents +- `/content/json-only` (POST) — Require `Content-Type: application/json` +- `/content/size-limit` (POST) — Limit request body to 100 KB +- `/content/referrer-check` — Require allowed `Referer` header +- `/content/custom-validation` — Reject a custom user-agent pattern +- `/advanced/business-hours` — Access only during 09:00–17:00 UTC +- `/advanced/weekend-only` — Demonstrates `time_window` decorator +- `/advanced/honeypot` (POST) — Honeypot bot detection +- `/advanced/suspicious-patterns` — Enhanced suspicious pattern detection +- `/admin/unban-ip` (POST) — Admin: unban an IP (localhost only) +- `/admin/stats` — Admin: security statistics (localhost only) +- `/admin/clear-cache` (POST) — Admin: clear security caches (localhost only) +- `/admin/emergency-mode` (PUT) — Admin: toggle emergency mode (localhost only) +- `/admin/cloud-status` — Admin: cloud provider refresh status (localhost only) +- `/test/xss-test` (POST) — XSS detection test +- `/test/sql-injection` — SQL injection detection test +- `/test/path-traversal/{path}` — Path traversal detection test +- `/test/command-injection` (POST) — Command injection detection test +- `/test/mixed-attack` (POST) — Combined attack vectors +- `/ws` — WebSocket echo endpoint + +___ + +Note on `/docs` +--------------- + +Tornado does not auto-generate an OpenAPI UI. Unlike `fastapi-guard`, there is no `/docs` page in this example. Use the `/` endpoint for the route map, and refer to the MkDocs tutorial under `docs/tutorial/examples/example-app.md` for a walkthrough of each feature. + +___ + +Environment variables +--------------------- + +- `REDIS_URL` — Redis connection string (default `redis://localhost:6379`) +- `REDIS_PREFIX` — Key prefix for Redis entries (default `tornadoapi_guard:`) +- `IPINFO_TOKEN` — Token for IPInfo geolocation (optional) +- `HOST` — Host interface to bind (default `127.0.0.1`) +- `PORT` — Listen port (default `8000`) diff --git a/live_smoke_stack/docker-compose.yml b/live_smoke_stack/docker-compose.yml new file mode 100644 index 0000000..4001e89 --- /dev/null +++ b/live_smoke_stack/docker-compose.yml @@ -0,0 +1,30 @@ +services: + tornadoapi-guard-example: + build: + context: . + dockerfile: ./Dockerfile + command: python main.py + ports: + - "8000:8000" + environment: + - REDIS_URL=redis://redis:6379 + - REDIS_PREFIX=${REDIS_PREFIX:-"tornadoapi_guard:"} + - IPINFO_TOKEN=${IPINFO_TOKEN:-"test_token"} + - HOST=0.0.0.0 + - PORT=8000 + volumes: + - ./:/app:z + depends_on: + redis: + condition: service_started + + redis: + image: redis:alpine + ports: + - "6379:6379" + volumes: + - redis-data:/data + command: redis-server --save 60 1 --loglevel warning + +volumes: + redis-data: diff --git a/live_smoke_stack/main.py b/live_smoke_stack/main.py new file mode 100644 index 0000000..ece85f0 --- /dev/null +++ b/live_smoke_stack/main.py @@ -0,0 +1,968 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import os +from datetime import datetime, timezone +from ipaddress import ip_address +from typing import Any + +import tornado.httpserver +import tornado.ioloop +import tornado.web +import tornado.websocket +from guard_core.protocols.request_protocol import GuardRequest +from guard_core.protocols.response_protocol import GuardResponse + +from tornadoapi_guard import ( + BehaviorRule, + SecurityConfig, + SecurityDecorator, + SecurityHandler, + SecurityMiddleware, + TornadoGuardResponse, + cloud_handler, +) + +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) -> Any: + 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") + reason = self._reason or "error" + self.finish( + _json_dumps( + { + "detail": reason, + "error_code": f"HTTP_{status_code}", + "timestamp": datetime.now(timezone.utc).isoformat(), + } + ) + ) + + +async def custom_request_check(request: GuardRequest) -> GuardResponse | None: + debug = request.query_params.get("debug") + if debug == "true": + logger.warning(f"Blocked debug request from {request.client_host or 'unknown'}") + 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 + + +async def custom_response_modifier(response: GuardResponse) -> GuardResponse: + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + return response + + +_REDIS_URL = os.getenv("REDIS_URL", "") +_ENABLE_REDIS = bool(_REDIS_URL) + + +security_config = SecurityConfig( + # 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, + trust_x_forwarded_proto=True, + block_cloud_providers={"AWS", "GCP", "Azure"}, + blocked_user_agents=["badbot", "evil-crawler", "sqlmap"], + enable_rate_limiting=True, + rate_limit=30, + rate_limit_window=60, + enable_ip_banning=True, + 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, + redis_url=_REDIS_URL or "redis://localhost:6379", + redis_prefix=os.getenv("REDIS_PREFIX", "tornadoapi_guard:"), + enforce_https=False, + custom_request_check=custom_request_check, + custom_response_modifier=custom_response_modifier, + security_headers={ + "enabled": True, + "csp": { + "default-src": ["'self'"], + "script-src": ["'self'", "'strict-dynamic'"], + "style-src": ["'self'", "'unsafe-inline'"], + "img-src": ["'self'", "data:", "https:"], + "font-src": ["'self'", "https://fonts.gstatic.com"], + "connect-src": ["'self'", "ws://localhost:8000"], + }, + "hsts": { + "max_age": 31536000, + "include_subdomains": True, + "preload": False, + }, + "frame_options": "SAMEORIGIN", + "referrer_policy": "strict-origin-when-cross-origin", + "permissions_policy": ( + "accelerometer=(), camera=(), geolocation=(), " + "gyroscope=(), magnetometer=(), microphone=(), " + "payment=(), usb=()" + ), + "custom": { + "X-App-Name": "TornadoAPI-Guard-Example", + "X-Security-Contact": "security@example.com", + }, + }, + enable_cors=True, + cors_allow_origins=["http://localhost:3000", "https://example.com"], + cors_allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + cors_allow_headers=["*"], + cors_allow_credentials=True, + cors_expose_headers=["X-Total-Count"], + cors_max_age=3600, + log_request_level="INFO", + log_suspicious_level="WARNING", + custom_log_file="security.log", + exclude_paths=[ + "/favicon.ico", + "/health", + ], + passive_mode=False, +) + + +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 Comprehensive Example API", + "details": { + "version": "2.0.0", + "features": [ + "IP filtering", + "Country blocking", + "Cloud provider blocking", + "Rate limiting", + "Security headers", + "Behavioral analysis", + "Content filtering", + "Authentication", + "Advanced security features", + ], + "routes": { + "/basic": "Basic security features", + "/access": "Access control demonstrations", + "/auth": "Authentication examples", + "/rate": "Rate limiting examples", + "/behavior": "Behavioral analysis", + "/headers": "Security headers demonstration", + "/content": "Content filtering", + "/advanced": "Advanced features", + "/admin": "Admin utilities", + "/test": "Security testing", + "/ws": "WebSocket endpoint", + }, + }, + } + ) + + +class BasicRootHandler(JSONHandler): + async def get(self) -> None: + self.write_json({"message": "Basic features endpoint"}) + + +class BasicIPHandler(JSONHandler): + async def get(self) -> None: + client = self.request.remote_ip or "unknown" + try: + client = str(ip_address(client)) + except ValueError: + pass + self.write_json( + { + "ip": client, + "country": "US", + "city": "Example City", + "region": "Example Region", + "is_vpn": False, + "is_cloud": False, + } + ) + + +class BasicHealthHandler(JSONHandler): + async def get(self) -> None: + self.write_json( + { + "status": "healthy", + "timestamp": datetime.now(timezone.utc), + } + ) + + +class BasicEchoHandler(JSONHandler): + async def post(self) -> None: + try: + data = json.loads(self.request.body or b"{}") + except json.JSONDecodeError: + data = {"raw": self.request.body.decode("utf-8", errors="replace")} + self.write_json( + { + "message": "Echo response", + "details": { + "data": data, + "headers": dict(self.request.headers), + "method": self.request.method, + "url": self.request.full_url(), + }, + } + ) + + +class AccessIPWhitelistHandler(JSONHandler): + @guard_decorator.require_ip(whitelist=["127.0.0.1", "10.0.0.0/8"]) + async def get(self) -> None: + self.write_json({"message": "Access granted from whitelisted IP"}) + + +class AccessIPBlacklistHandler(JSONHandler): + @guard_decorator.require_ip(blacklist=["192.168.1.0/24", "172.16.0.0/12"]) + async def get(self) -> None: + self.write_json({"message": "Access granted - you're not blacklisted"}) + + +class AccessCountryBlockHandler(JSONHandler): + @guard_decorator.block_countries(["CN", "RU", "KP"]) + async def get(self) -> None: + self.write_json({"message": "Access granted - your country is not blocked"}) + + +class AccessCountryAllowHandler(JSONHandler): + @guard_decorator.allow_countries(["US", "CA", "GB", "AU"]) + async def get(self) -> None: + self.write_json({"message": "Access granted from allowed country"}) + + +class AccessNoCloudHandler(JSONHandler): + @guard_decorator.block_clouds() + async def get(self) -> None: + self.write_json({"message": "Access granted - not from cloud provider"}) + + +class AccessNoAWSHandler(JSONHandler): + @guard_decorator.block_clouds(["AWS"]) + async def get(self) -> None: + self.write_json({"message": "Access granted - not from AWS"}) + + +class AccessBypassHandler(JSONHandler): + @guard_decorator.bypass(["rate_limit", "ip"]) + async def get(self) -> None: + self.write_json( + { + "message": ( + "This endpoint bypasses rate limiting and IP/country/cloud checks" + ), + "details": {"bypassed_checks": ["rate_limit", "ip"]}, + } + ) + + +class AuthHttpsHandler(JSONHandler): + @guard_decorator.require_https() + async def get(self) -> None: + self.write_json( + { + "message": "HTTPS connection verified", + "details": {"protocol": self.request.protocol}, + } + ) + + +class AuthBearerHandler(JSONHandler): + @guard_decorator.require_auth(type="bearer") + async def get(self) -> None: + self.write_json( + { + "authenticated": True, + "user": "example_user", + "method": "bearer", + "permissions": ["read", "write"], + } + ) + + +class AuthAPIKeyHandler(JSONHandler): + @guard_decorator.api_key_auth(header_name="X-API-Key") + async def get(self) -> None: + self.write_json( + { + "authenticated": True, + "user": "api_user", + "method": "api_key", + "permissions": ["read"], + } + ) + + +class AuthCustomHeadersHandler(JSONHandler): + @guard_decorator.require_headers( + {"X-Custom-Header": "required-value", "X-Client-ID": "required-value"} + ) + async def get(self) -> None: + self.write_json( + { + "message": "Required headers verified", + "details": {"headers": dict(self.request.headers)}, + } + ) + + +class RateCustomLimitHandler(JSONHandler): + @guard_decorator.rate_limit(requests=5, window=60) + async def get(self) -> None: + self.write_json( + { + "message": "Custom rate limit endpoint", + "details": {"limit": "5 requests per 60 seconds"}, + } + ) + + +class RateStrictLimitHandler(JSONHandler): + @guard_decorator.rate_limit(requests=1, window=10) + async def get(self) -> None: + self.write_json( + { + "message": "Strict rate limit endpoint", + "details": {"limit": "1 request per 10 seconds"}, + } + ) + + +class RateGeoHandler(JSONHandler): + @guard_decorator.geo_rate_limit( + { + "US": (100, 60), + "CN": (10, 60), + "RU": (20, 60), + "*": (50, 60), + } + ) + async def get(self) -> None: + self.write_json( + { + "message": "Geographic rate limiting applied", + "details": {"description": "Rate limits vary by country"}, + } + ) + + +class BehaviorUsageHandler(JSONHandler): + @guard_decorator.usage_monitor(max_calls=10, window=300, action="log") + async def get(self) -> None: + self.write_json( + { + "message": "Usage monitoring active", + "details": {"monitoring": "10 calls per 5 minutes"}, + } + ) + + +class BehaviorReturnMonitorHandler(JSONHandler): + @guard_decorator.return_monitor( + pattern="404", max_occurrences=3, window=60, action="ban" + ) + async def get(self, status_code: str) -> None: + code = int(status_code) + if code == 404: + self.set_status(404) + self.write_json({"detail": "Not found"}, status_code=404) + return + self.write_json({"message": f"Status code: {code}"}) + + +class BehaviorSuspiciousFrequencyHandler(JSONHandler): + @guard_decorator.suspicious_frequency( + max_frequency=0.5, window=10, action="throttle" + ) + async def get(self) -> None: + self.write_json( + { + "message": "Frequency monitoring active", + "details": {"max_frequency": "1 request per 2 seconds"}, + } + ) + + +class BehaviorComplexHandler(JSONHandler): + @guard_decorator.behavior_analysis( + [ + BehaviorRule( + rule_type="frequency", threshold=10, window=60, action="throttle" + ), + BehaviorRule( + rule_type="return_pattern", + pattern="404", + threshold=5, + window=60, + action="ban", + ), + ] + ) + async def post(self) -> None: + self.write_json( + { + "message": "Complex behavior analysis active", + "details": {"rules": ["frequency", "return_pattern"]}, + } + ) + + +class HeadersInfoHandler(JSONHandler): + async def get(self) -> None: + self.write_json( + { + "message": "All responses include comprehensive security headers", + "details": { + "headers": [ + "X-Content-Type-Options: nosniff", + "X-Frame-Options: SAMEORIGIN", + "X-XSS-Protection: 1; mode=block", + "Strict-Transport-Security: max-age=31536000", + "Content-Security-Policy: default-src 'self'", + "Referrer-Policy: strict-origin-when-cross-origin", + "Permissions-Policy: accelerometer=(), camera=(), ...", + "X-App-Name: TornadoAPI-Guard-Example", + "X-Security-Contact: security@example.com", + ], + "note": "Check browser developer tools to see all headers", + }, + } + ) + + +_TEST_PAGE_HTML = """ + + + + Security Headers Demo + + +

TornadoAPI Guard Security Headers Demo

+

Inspect the Network tab in DevTools to see applied security headers.

+ + +""" + + +class HeadersTestPageHandler(SecurityHandler): + async def get(self) -> None: + self.set_header("Content-Type", "text/html; charset=utf-8") + self.write(_TEST_PAGE_HTML) + + +class HeadersCspReportHandler(JSONHandler): + async def post(self) -> None: + try: + payload = json.loads(self.request.body or b"{}") + except json.JSONDecodeError: + payload = {} + report = payload.get("csp-report", {}) + logger.warning( + "CSP Violation: %s blocked %s on %s", + report.get("violated-directive", "unknown"), + report.get("blocked-uri", "unknown"), + report.get("document-uri", "unknown"), + ) + self.write_json( + { + "message": "CSP violation report received", + "details": { + "violated_directive": report.get("violated-directive"), + "blocked_uri": report.get("blocked-uri"), + "source_file": report.get("source-file"), + "line_number": report.get("line-number"), + }, + } + ) + + +class HeadersFrameTestHandler(SecurityHandler): + async def get(self) -> None: + self.set_header("Content-Type", "text/html; charset=utf-8") + self.write( + "Frame Options Test" + "

X-Frame-Options Test

" + "

This page has X-Frame-Options: SAMEORIGIN header.

" + ) + + +class HeadersHSTSHandler(JSONHandler): + async def get(self) -> None: + self.write_json( + { + "message": "HSTS (HTTP Strict Transport Security) is active", + "details": { + "max_age": "31536000 seconds (1 year)", + "include_subdomains": True, + "preload": False, + "description": "Forces HTTPS connections for improved security", + }, + } + ) + + +class HeadersAnalysisHandler(JSONHandler): + async def get(self) -> None: + self.write_json( + { + "message": "Security analysis of current request", + "details": { + "request_headers": { + "user_agent": self.request.headers.get( + "user-agent", "Not provided" + ), + "origin": self.request.headers.get("origin", "Not provided"), + "referer": self.request.headers.get("referer", "Not provided"), + "x_forwarded_for": self.request.headers.get( + "x-forwarded-for", "Not provided" + ), + }, + "security_features": [ + "Content-Type sniffing protection", + "Clickjacking protection", + "XSS filtering", + "HTTPS enforcement", + "Content restrictions", + "Referrer policy control", + "Feature permissions control", + "Custom security headers", + ], + "recommendations": [ + "Always use HTTPS in production", + "Regularly review and tighten CSP directives", + "Monitor CSP violation reports", + "Consider HSTS preload for production domains", + ], + }, + } + ) + + +class ContentNoBotsHandler(JSONHandler): + @guard_decorator.block_user_agents(["bot", "crawler", "spider", "scraper"]) + async def get(self) -> None: + self.write_json({"message": "Human users only - bots blocked"}) + + +class ContentJSONOnlyHandler(JSONHandler): + @guard_decorator.content_type_filter(["application/json"]) + async def post(self) -> None: + try: + data = json.loads(self.request.body or b"{}") + except json.JSONDecodeError: + data = {} + self.write_json({"message": "JSON content received", "details": {"data": data}}) + + +class ContentSizeLimitHandler(JSONHandler): + @guard_decorator.max_request_size(1024 * 100) + async def post(self) -> None: + try: + data = json.loads(self.request.body or b"{}") + except json.JSONDecodeError: + data = {} + self.write_json( + { + "message": "Data received within size limit", + "details": {"size_limit": "100KB", "data": data}, + } + ) + + +class ContentReferrerHandler(JSONHandler): + @guard_decorator.require_referrer( + ["https://example.com", "https://app.example.com"] + ) + async def get(self) -> None: + referrer = self.request.headers.get("referer", "No referrer") + self.write_json( + {"message": "Valid referrer", "details": {"referrer": referrer}} + ) + + +async def suspicious_user_agent_validator( + request: GuardRequest, +) -> GuardResponse | None: + user_agent = request.headers.get("user-agent", "").lower() + if "suspicious-pattern" in user_agent: + return TornadoGuardResponse( + status_code=403, + headers={"Content-Type": "application/json; charset=utf-8"}, + body=_json_dumps({"detail": "Suspicious user agent detected"}).encode( + "utf-8" + ), + ) + return None + + +class ContentCustomValidationHandler(JSONHandler): + @guard_decorator.custom_validation(suspicious_user_agent_validator) + async def get(self) -> None: + self.write_json( + { + "message": "Custom validation passed", + "details": {"validator": "suspicious_user_agent_validator"}, + } + ) + + +class AdvancedBusinessHoursHandler(JSONHandler): + @guard_decorator.time_window(start_time="09:00", end_time="17:00", timezone="UTC") + async def get(self) -> None: + self.write_json( + { + "message": "Access granted during business hours", + "details": {"hours": "09:00-17:00 UTC"}, + } + ) + + +class AdvancedWeekendHandler(JSONHandler): + @guard_decorator.time_window(start_time="00:00", end_time="23:59", timezone="UTC") + async def get(self) -> None: + self.write_json( + { + "message": "Weekend access endpoint", + "details": {"note": "Implement weekend check in time_window"}, + } + ) + + +class AdvancedHoneypotHandler(JSONHandler): + @guard_decorator.honeypot_detection( + ["honeypot_field", "trap_input", "hidden_field"] + ) + async def post(self) -> None: + self.write_json( + { + "message": "Human user verified", + "details": {"honeypot_status": "clean"}, + } + ) + + +class AdvancedSuspiciousHandler(JSONHandler): + @guard_decorator.suspicious_detection(enabled=True) + async def get(self) -> None: + query = self.get_query_argument("query", default=None) + self.write_json( + { + "message": "No suspicious patterns detected", + "details": {"query": query}, + } + ) + + +class AdminUnbanHandler(JSONHandler): + @guard_decorator.require_ip(whitelist=["127.0.0.1"]) + async def post(self) -> None: + try: + data = json.loads(self.request.body or b"{}") + except json.JSONDecodeError: + data = {} + ip = data.get("ip", "") + logger.info(f"Unbanning IP: {ip}") + self.write_json( + { + "message": f"IP {ip} has been unbanned", + "details": {"action": "unban", "ip": ip}, + } + ) + + +class AdminStatsHandler(JSONHandler): + @guard_decorator.require_ip(whitelist=["127.0.0.1"]) + async def get(self) -> None: + self.write_json( + { + "total_requests": 1500, + "blocked_requests": 75, + "banned_ips": ["192.168.1.100", "10.0.0.50"], + "rate_limited_ips": {"192.168.1.200": 5, "172.16.0.10": 3}, + "suspicious_activities": [ + { + "ip": "192.168.1.100", + "reason": "SQL injection attempt", + "timestamp": datetime.now(timezone.utc), + }, + { + "ip": "10.0.0.50", + "reason": "Rapid requests", + "timestamp": datetime.now(timezone.utc), + }, + ], + "active_rules": { + "rate_limit": 30, + "rate_window": 60, + "auto_ban_threshold": 5, + "blocked_clouds": ["AWS", "GCP", "Azure"], + }, + } + ) + + +class AdminClearCacheHandler(JSONHandler): + @guard_decorator.require_ip(whitelist=["127.0.0.1"]) + async def post(self) -> None: + self.write_json( + { + "message": "Security caches cleared", + "details": { + "cleared": ["rate_limit_cache", "ip_ban_cache", "geo_cache"] + }, + } + ) + + +class AdminEmergencyModeHandler(JSONHandler): + @guard_decorator.require_ip(whitelist=["127.0.0.1"]) + async def put(self) -> None: + try: + data = json.loads(self.request.body or b"{}") + except json.JSONDecodeError: + data = {} + enable = bool(data.get("enable", False)) + mode = "enabled" if enable else "disabled" + self.write_json( + { + "message": f"Emergency mode {mode}", + "details": { + "emergency_mode": enable, + "timestamp": datetime.now(timezone.utc), + }, + } + ) + + +class AdminCloudStatusHandler(JSONHandler): + @guard_decorator.require_ip(whitelist=["127.0.0.1"]) + async def get(self) -> None: + last_updated: dict[str, str | None] = {} + for provider, dt in cloud_handler.last_updated.items(): + last_updated[provider] = dt.isoformat() if dt else None + self.write_json( + { + "message": "Cloud provider IP range status", + "details": { + "refresh_interval": security_config.cloud_ip_refresh_interval, + "providers": last_updated, + }, + } + ) + + +class TestXSSHandler(JSONHandler): + async def post(self) -> None: + payload = self.request.body.decode("utf-8", errors="replace") + self.write_json( + { + "message": "XSS test payload processed", + "details": {"payload": payload, "detected": False}, + } + ) + + +class TestSQLHandler(JSONHandler): + async def get(self) -> None: + query = self.get_query_argument("query", default="") + self.write_json( + { + "message": "SQL injection test processed", + "details": {"query": query, "detected": False}, + } + ) + + +class TestPathTraversalHandler(JSONHandler): + async def get(self, file_path: str) -> None: + self.write_json( + { + "message": "Path traversal test", + "details": {"path": file_path, "detected": False}, + } + ) + + +class TestCommandInjectionHandler(JSONHandler): + async def post(self) -> None: + command = self.request.body.decode("utf-8", errors="replace") + self.write_json( + { + "message": "Command injection test processed", + "details": {"command": command, "detected": False}, + } + ) + + +class TestMixedAttackHandler(JSONHandler): + async def post(self) -> None: + try: + payload = json.loads(self.request.body or b"{}") + except json.JSONDecodeError: + payload = {} + self.write_json( + { + "message": "Mixed attack test processed", + "details": { + "xss_test": payload.get("input"), + "sql_test": payload.get("query"), + "path_test": payload.get("path"), + "cmd_test": payload.get("cmd"), + "honeypot": payload.get("honeypot_field"), + }, + } + ) + + +class EchoWebSocketHandler(tornado.websocket.WebSocketHandler): + def check_origin(self, origin: str) -> bool: + return True + + async def on_message(self, message: str | bytes) -> None: + text = message.decode("utf-8") if isinstance(message, bytes) else message + await self.write_message(f"Echo: {text}") + if text == "status": + await self.write_message( + json.dumps( + { + "type": "status", + "timestamp": datetime.now(timezone.utc).isoformat(), + "security": "active", + } + ) + ) + + def on_close(self) -> None: + logger.info("WebSocket disconnected") + + +def build_application() -> tornado.web.Application: + handlers: list[tuple[str, type[tornado.web.RequestHandler]]] = [ + (r"/", RootHandler), + (r"/basic/?", BasicRootHandler), + (r"/basic/ip", BasicIPHandler), + (r"/basic/health", BasicHealthHandler), + (r"/basic/echo", BasicEchoHandler), + (r"/access/ip-whitelist", AccessIPWhitelistHandler), + (r"/access/ip-blacklist", AccessIPBlacklistHandler), + (r"/access/country-block", AccessCountryBlockHandler), + (r"/access/country-allow", AccessCountryAllowHandler), + (r"/access/no-cloud", AccessNoCloudHandler), + (r"/access/no-aws", AccessNoAWSHandler), + (r"/access/bypass-demo", AccessBypassHandler), + (r"/auth/https-only", AuthHttpsHandler), + (r"/auth/bearer-auth", AuthBearerHandler), + (r"/auth/api-key", AuthAPIKeyHandler), + (r"/auth/custom-headers", AuthCustomHeadersHandler), + (r"/rate/custom-limit", RateCustomLimitHandler), + (r"/rate/strict-limit", RateStrictLimitHandler), + (r"/rate/geo-rate-limit", RateGeoHandler), + (r"/behavior/usage-monitor", BehaviorUsageHandler), + (r"/behavior/return-monitor/(\d+)", BehaviorReturnMonitorHandler), + (r"/behavior/suspicious-frequency", BehaviorSuspiciousFrequencyHandler), + (r"/behavior/behavior-rules", BehaviorComplexHandler), + (r"/headers/?", HeadersInfoHandler), + (r"/headers/test-page", HeadersTestPageHandler), + (r"/headers/csp-report", HeadersCspReportHandler), + (r"/headers/frame-test", HeadersFrameTestHandler), + (r"/headers/hsts-info", HeadersHSTSHandler), + (r"/headers/security-analysis", HeadersAnalysisHandler), + (r"/content/no-bots", ContentNoBotsHandler), + (r"/content/json-only", ContentJSONOnlyHandler), + (r"/content/size-limit", ContentSizeLimitHandler), + (r"/content/referrer-check", ContentReferrerHandler), + (r"/content/custom-validation", ContentCustomValidationHandler), + (r"/advanced/business-hours", AdvancedBusinessHoursHandler), + (r"/advanced/weekend-only", AdvancedWeekendHandler), + (r"/advanced/honeypot", AdvancedHoneypotHandler), + (r"/advanced/suspicious-patterns", AdvancedSuspiciousHandler), + (r"/admin/unban-ip", AdminUnbanHandler), + (r"/admin/stats", AdminStatsHandler), + (r"/admin/clear-cache", AdminClearCacheHandler), + (r"/admin/emergency-mode", AdminEmergencyModeHandler), + (r"/admin/cloud-status", AdminCloudStatusHandler), + (r"/test/xss-test", TestXSSHandler), + (r"/test/sql-injection", TestSQLHandler), + (r"/test/path-traversal/(.*)", TestPathTraversalHandler), + (r"/test/command-injection", TestCommandInjectionHandler), + (r"/test/mixed-attack", TestMixedAttackHandler), + (r"/ws", EchoWebSocketHandler), + ] + 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 Example starting up...") + logger.info( + "Security features enabled: rate_limiting=%s ip_banning=%s " + "penetration_detection=%s redis=%s agent=%s", + security_config.enable_rate_limiting, + security_config.enable_ip_banning, + security_config.enable_penetration_detection, + security_config.enable_redis, + security_config.enable_agent, + ) + await security_middleware.initialize() + app = build_application() + server = tornado.httpserver.HTTPServer(app) + host = os.getenv("HOST", "127.0.0.1") + 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 Example shutting down...") + server.stop() + await security_middleware.reset() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/live_smoke_stack/requirements.txt b/live_smoke_stack/requirements.txt new file mode 100644 index 0000000..11f611a --- /dev/null +++ b/live_smoke_stack/requirements.txt @@ -0,0 +1,4 @@ +tornado +redis +requests +tornadoapi-guard @ file:///app/tornadoapi_guard-1.2.0-py3-none-any.whl diff --git a/live_smoke_stack/tornadoapi_guard-1.2.0-py3-none-any.whl b/live_smoke_stack/tornadoapi_guard-1.2.0-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..eb34b89a0a0d0cce4fd5af2197fb3ab4cf917cbe GIT binary patch literal 15682 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

-wKyDulzl!2ar=nu%AD56 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 Date: Tue, 29 Sep 2026 01:06:16 +0200 Subject: [PATCH 5/9] fix(ci): reference the real wheel filename when staging the smoke stack --- .github/workflows/live-smoke.yml | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/.github/workflows/live-smoke.yml b/.github/workflows/live-smoke.yml index 1d5e11d..fbbe98e 100644 --- a/.github/workflows/live-smoke.yml +++ b/.github/workflows/live-smoke.yml @@ -59,22 +59,17 @@ jobs: # the wheel must be COPYed into the image before pip runs python3 - <<'EOF' import pathlib - req = pathlib.Path("live_smoke_stack/requirements.txt") + 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("tornadoapi-guard @ file:///app/tornadoapi_guard-wheel.whl") + lines.append(f"tornadoapi-guard @ file:///app/{wheel.name}") req.write_text("\n".join(lines) + "\n") - wheel_dir = pathlib.Path("live_smoke_stack") - wheel = next(wheel_dir.glob("tornadoapi_guard-*.whl")) - wheel.rename(wheel_dir / "tornadoapi_guard-wheel.whl") - dockerfile = pathlib.Path("live_smoke_stack/Dockerfile") + dockerfile = stack / "Dockerfile" df = dockerfile.read_text() anchor = "COPY requirements.txt /app/" assert anchor in df - df = df.replace( - anchor, - anchor + "\nCOPY tornadoapi_guard-wheel.whl /app/", - ) - dockerfile.write_text(df) + dockerfile.write_text(df.replace(anchor, anchor + f"\nCOPY {wheel.name} /app/")) EOF - name: Bring the smoke stack up From f7326956d2c1d9d7b1038aa3f6a22f2ff4c2b34f Mon Sep 17 00:00:00 2001 From: Renn F Date: Tue, 29 Sep 2026 01:13:04 +0200 Subject: [PATCH 6/9] fix(ci): wait for app readiness before the smoke assertions --- .github/workflows/live-smoke.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/live-smoke.yml b/.github/workflows/live-smoke.yml index fbbe98e..6b78996 100644 --- a/.github/workflows/live-smoke.yml +++ b/.github/workflows/live-smoke.yml @@ -81,6 +81,18 @@ jobs: 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 From 3a94470f91d2f1aa28c8f483546a5da2a0962d9e Mon Sep 17 00:00:00 2001 From: Renn F Date: Tue, 29 Sep 2026 01:25:52 +0200 Subject: [PATCH 7/9] fix(examples): resolve CodeQL alerts in the new advanced app - write_error uses a static reason-phrase lookup instead of reflecting request-derived text (py/reflective-xss) - Dockerfile: --no-install-recommends, non-root user, HEALTHCHECK - drop the accidentally committed generated live_smoke_stack/ and ignore it --- .gitignore | 4 + examples/advanced_app/Dockerfile | 8 +- examples/advanced_app/main.py | 3 +- live_smoke_stack/Dockerfile | 31 - live_smoke_stack/README.md | 107 -- live_smoke_stack/docker-compose.yml | 30 - live_smoke_stack/main.py | 968 ------------------ live_smoke_stack/requirements.txt | 4 - .../tornadoapi_guard-1.2.0-py3-none-any.whl | Bin 15682 -> 0 bytes 9 files changed, 13 insertions(+), 1142 deletions(-) delete mode 100644 live_smoke_stack/Dockerfile delete mode 100644 live_smoke_stack/README.md delete mode 100644 live_smoke_stack/docker-compose.yml delete mode 100644 live_smoke_stack/main.py delete mode 100644 live_smoke_stack/requirements.txt delete mode 100644 live_smoke_stack/tornadoapi_guard-1.2.0-py3-none-any.whl 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/examples/advanced_app/Dockerfile b/examples/advanced_app/Dockerfile index a51de1d..e20328d 100644 --- a/examples/advanced_app/Dockerfile +++ b/examples/advanced_app/Dockerfile @@ -1,7 +1,7 @@ ARG PYTHON_VERSION=3.12 FROM python:${PYTHON_VERSION}-slim -RUN apt-get update && apt-get install -y \ +RUN apt-get update && apt-get install -y --no-install-recommends \ libsodium23 \ wget \ && rm -rf /var/lib/apt/lists/* @@ -24,4 +24,10 @@ 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 +USER guard + CMD ["python", "main.py"] diff --git a/examples/advanced_app/main.py b/examples/advanced_app/main.py index 08585d1..9aaf55b 100644 --- a/examples/advanced_app/main.py +++ b/examples/advanced_app/main.py @@ -48,7 +48,8 @@ def write_json(self, payload: dict[str, Any], status_code: int = 200) -> None: def write_error(self, status_code: int, **kwargs: Any) -> None: self.set_header("Content-Type", "application/json; charset=utf-8") - reason = self._reason or "error" + # Static reason phrase lookup: never reflect request-derived text + reason = tornado.httputil.responses.get(status_code, "error") self.finish( _json_dumps( { diff --git a/live_smoke_stack/Dockerfile b/live_smoke_stack/Dockerfile deleted file mode 100644 index a55c2c7..0000000 --- a/live_smoke_stack/Dockerfile +++ /dev/null @@ -1,31 +0,0 @@ -ARG PYTHON_VERSION=3.10 -FROM python:${PYTHON_VERSION}-slim - -RUN apt-get update && apt-get install -y \ - libsodium23 \ - wget \ - build-essential \ - libffi-dev \ - curl \ - && 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 requirements.txt /app/ -COPY tornadoapi_guard-1.2.0-py3-none-any.whl /app/ - -RUN pip install --no-cache-dir -r requirements.txt - -COPY . /app/ - -RUN mkdir -p /app/data/ipinfo - -EXPOSE 8000 - -CMD ["python", "main.py"] diff --git a/live_smoke_stack/README.md b/live_smoke_stack/README.md deleted file mode 100644 index af3dde3..0000000 --- a/live_smoke_stack/README.md +++ /dev/null @@ -1,107 +0,0 @@ -TornadoAPI Guard Example App -============================ - -This example demonstrates how to use TornadoAPI Guard inside a Tornado application. - -___ - -Running the example -------------------- - -Using Docker Compose --------------------- - -```bash -# Start the example app and Redis -docker compose up - -# Restart -docker compose restart - -# Stop -docker compose down -``` - -Running locally (no Docker) ---------------------------- - -```bash -cd examples/simple_app -pip install -r requirements.txt -python main.py -``` - -The app listens on `http://127.0.0.1:8000` by default. Override host/port with the `HOST` and `PORT` environment variables. - -___ - -Available endpoints -------------------- - -- `/` — API overview with feature list and route map -- `/basic/` — Basic endpoint (subject to global rate limit) -- `/basic/ip` — Return client IP information -- `/basic/health` — Health check (excluded from all security checks) -- `/basic/echo` — Echo POST body with headers and method -- `/access/ip-whitelist` — Require whitelisted client IP -- `/access/ip-blacklist` — Reject blacklisted client IP -- `/access/country-block` — Block requests from specific countries -- `/access/country-allow` — Allow only specific countries -- `/access/no-cloud` — Block all known cloud provider IPs -- `/access/no-aws` — Block AWS IPs only -- `/access/bypass-demo` — Bypass rate limit + IP/country/cloud checks for this route -- `/auth/https-only` — Require HTTPS -- `/auth/bearer-auth` — Require Bearer token in Authorization header -- `/auth/api-key` — Require `X-API-Key` header -- `/auth/custom-headers` — Require arbitrary custom headers -- `/rate/custom-limit` — 5 requests per 60 seconds -- `/rate/strict-limit` — 1 request per 10 seconds -- `/rate/geo-rate-limit` — Per-country rate limits -- `/behavior/usage-monitor` — Log when >10 calls in 5 minutes -- `/behavior/return-monitor/{status_code}` — Ban IP after 3 HTTP 404s within 60s -- `/behavior/suspicious-frequency` — Throttle high-frequency requests -- `/behavior/behavior-rules` (POST) — Combined frequency + return pattern rules -- `/headers/` — Security headers overview -- `/headers/test-page` — HTML page demonstrating CSP -- `/headers/csp-report` (POST) — CSP violation report receiver -- `/headers/frame-test` — X-Frame-Options demonstration -- `/headers/hsts-info` — HSTS configuration info -- `/headers/security-analysis` — Per-request security analysis -- `/content/no-bots` — Block common bot user agents -- `/content/json-only` (POST) — Require `Content-Type: application/json` -- `/content/size-limit` (POST) — Limit request body to 100 KB -- `/content/referrer-check` — Require allowed `Referer` header -- `/content/custom-validation` — Reject a custom user-agent pattern -- `/advanced/business-hours` — Access only during 09:00–17:00 UTC -- `/advanced/weekend-only` — Demonstrates `time_window` decorator -- `/advanced/honeypot` (POST) — Honeypot bot detection -- `/advanced/suspicious-patterns` — Enhanced suspicious pattern detection -- `/admin/unban-ip` (POST) — Admin: unban an IP (localhost only) -- `/admin/stats` — Admin: security statistics (localhost only) -- `/admin/clear-cache` (POST) — Admin: clear security caches (localhost only) -- `/admin/emergency-mode` (PUT) — Admin: toggle emergency mode (localhost only) -- `/admin/cloud-status` — Admin: cloud provider refresh status (localhost only) -- `/test/xss-test` (POST) — XSS detection test -- `/test/sql-injection` — SQL injection detection test -- `/test/path-traversal/{path}` — Path traversal detection test -- `/test/command-injection` (POST) — Command injection detection test -- `/test/mixed-attack` (POST) — Combined attack vectors -- `/ws` — WebSocket echo endpoint - -___ - -Note on `/docs` ---------------- - -Tornado does not auto-generate an OpenAPI UI. Unlike `fastapi-guard`, there is no `/docs` page in this example. Use the `/` endpoint for the route map, and refer to the MkDocs tutorial under `docs/tutorial/examples/example-app.md` for a walkthrough of each feature. - -___ - -Environment variables ---------------------- - -- `REDIS_URL` — Redis connection string (default `redis://localhost:6379`) -- `REDIS_PREFIX` — Key prefix for Redis entries (default `tornadoapi_guard:`) -- `IPINFO_TOKEN` — Token for IPInfo geolocation (optional) -- `HOST` — Host interface to bind (default `127.0.0.1`) -- `PORT` — Listen port (default `8000`) diff --git a/live_smoke_stack/docker-compose.yml b/live_smoke_stack/docker-compose.yml deleted file mode 100644 index 4001e89..0000000 --- a/live_smoke_stack/docker-compose.yml +++ /dev/null @@ -1,30 +0,0 @@ -services: - tornadoapi-guard-example: - build: - context: . - dockerfile: ./Dockerfile - command: python main.py - ports: - - "8000:8000" - environment: - - REDIS_URL=redis://redis:6379 - - REDIS_PREFIX=${REDIS_PREFIX:-"tornadoapi_guard:"} - - IPINFO_TOKEN=${IPINFO_TOKEN:-"test_token"} - - HOST=0.0.0.0 - - PORT=8000 - volumes: - - ./:/app:z - depends_on: - redis: - condition: service_started - - redis: - image: redis:alpine - ports: - - "6379:6379" - volumes: - - redis-data:/data - command: redis-server --save 60 1 --loglevel warning - -volumes: - redis-data: diff --git a/live_smoke_stack/main.py b/live_smoke_stack/main.py deleted file mode 100644 index ece85f0..0000000 --- a/live_smoke_stack/main.py +++ /dev/null @@ -1,968 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -import os -from datetime import datetime, timezone -from ipaddress import ip_address -from typing import Any - -import tornado.httpserver -import tornado.ioloop -import tornado.web -import tornado.websocket -from guard_core.protocols.request_protocol import GuardRequest -from guard_core.protocols.response_protocol import GuardResponse - -from tornadoapi_guard import ( - BehaviorRule, - SecurityConfig, - SecurityDecorator, - SecurityHandler, - SecurityMiddleware, - TornadoGuardResponse, - cloud_handler, -) - -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) -> Any: - 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") - reason = self._reason or "error" - self.finish( - _json_dumps( - { - "detail": reason, - "error_code": f"HTTP_{status_code}", - "timestamp": datetime.now(timezone.utc).isoformat(), - } - ) - ) - - -async def custom_request_check(request: GuardRequest) -> GuardResponse | None: - debug = request.query_params.get("debug") - if debug == "true": - logger.warning(f"Blocked debug request from {request.client_host or 'unknown'}") - 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 - - -async def custom_response_modifier(response: GuardResponse) -> GuardResponse: - response.headers["X-Content-Type-Options"] = "nosniff" - response.headers["X-Frame-Options"] = "DENY" - response.headers["X-XSS-Protection"] = "1; mode=block" - response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" - return response - - -_REDIS_URL = os.getenv("REDIS_URL", "") -_ENABLE_REDIS = bool(_REDIS_URL) - - -security_config = SecurityConfig( - # 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, - trust_x_forwarded_proto=True, - block_cloud_providers={"AWS", "GCP", "Azure"}, - blocked_user_agents=["badbot", "evil-crawler", "sqlmap"], - enable_rate_limiting=True, - rate_limit=30, - rate_limit_window=60, - enable_ip_banning=True, - 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, - redis_url=_REDIS_URL or "redis://localhost:6379", - redis_prefix=os.getenv("REDIS_PREFIX", "tornadoapi_guard:"), - enforce_https=False, - custom_request_check=custom_request_check, - custom_response_modifier=custom_response_modifier, - security_headers={ - "enabled": True, - "csp": { - "default-src": ["'self'"], - "script-src": ["'self'", "'strict-dynamic'"], - "style-src": ["'self'", "'unsafe-inline'"], - "img-src": ["'self'", "data:", "https:"], - "font-src": ["'self'", "https://fonts.gstatic.com"], - "connect-src": ["'self'", "ws://localhost:8000"], - }, - "hsts": { - "max_age": 31536000, - "include_subdomains": True, - "preload": False, - }, - "frame_options": "SAMEORIGIN", - "referrer_policy": "strict-origin-when-cross-origin", - "permissions_policy": ( - "accelerometer=(), camera=(), geolocation=(), " - "gyroscope=(), magnetometer=(), microphone=(), " - "payment=(), usb=()" - ), - "custom": { - "X-App-Name": "TornadoAPI-Guard-Example", - "X-Security-Contact": "security@example.com", - }, - }, - enable_cors=True, - cors_allow_origins=["http://localhost:3000", "https://example.com"], - cors_allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], - cors_allow_headers=["*"], - cors_allow_credentials=True, - cors_expose_headers=["X-Total-Count"], - cors_max_age=3600, - log_request_level="INFO", - log_suspicious_level="WARNING", - custom_log_file="security.log", - exclude_paths=[ - "/favicon.ico", - "/health", - ], - passive_mode=False, -) - - -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 Comprehensive Example API", - "details": { - "version": "2.0.0", - "features": [ - "IP filtering", - "Country blocking", - "Cloud provider blocking", - "Rate limiting", - "Security headers", - "Behavioral analysis", - "Content filtering", - "Authentication", - "Advanced security features", - ], - "routes": { - "/basic": "Basic security features", - "/access": "Access control demonstrations", - "/auth": "Authentication examples", - "/rate": "Rate limiting examples", - "/behavior": "Behavioral analysis", - "/headers": "Security headers demonstration", - "/content": "Content filtering", - "/advanced": "Advanced features", - "/admin": "Admin utilities", - "/test": "Security testing", - "/ws": "WebSocket endpoint", - }, - }, - } - ) - - -class BasicRootHandler(JSONHandler): - async def get(self) -> None: - self.write_json({"message": "Basic features endpoint"}) - - -class BasicIPHandler(JSONHandler): - async def get(self) -> None: - client = self.request.remote_ip or "unknown" - try: - client = str(ip_address(client)) - except ValueError: - pass - self.write_json( - { - "ip": client, - "country": "US", - "city": "Example City", - "region": "Example Region", - "is_vpn": False, - "is_cloud": False, - } - ) - - -class BasicHealthHandler(JSONHandler): - async def get(self) -> None: - self.write_json( - { - "status": "healthy", - "timestamp": datetime.now(timezone.utc), - } - ) - - -class BasicEchoHandler(JSONHandler): - async def post(self) -> None: - try: - data = json.loads(self.request.body or b"{}") - except json.JSONDecodeError: - data = {"raw": self.request.body.decode("utf-8", errors="replace")} - self.write_json( - { - "message": "Echo response", - "details": { - "data": data, - "headers": dict(self.request.headers), - "method": self.request.method, - "url": self.request.full_url(), - }, - } - ) - - -class AccessIPWhitelistHandler(JSONHandler): - @guard_decorator.require_ip(whitelist=["127.0.0.1", "10.0.0.0/8"]) - async def get(self) -> None: - self.write_json({"message": "Access granted from whitelisted IP"}) - - -class AccessIPBlacklistHandler(JSONHandler): - @guard_decorator.require_ip(blacklist=["192.168.1.0/24", "172.16.0.0/12"]) - async def get(self) -> None: - self.write_json({"message": "Access granted - you're not blacklisted"}) - - -class AccessCountryBlockHandler(JSONHandler): - @guard_decorator.block_countries(["CN", "RU", "KP"]) - async def get(self) -> None: - self.write_json({"message": "Access granted - your country is not blocked"}) - - -class AccessCountryAllowHandler(JSONHandler): - @guard_decorator.allow_countries(["US", "CA", "GB", "AU"]) - async def get(self) -> None: - self.write_json({"message": "Access granted from allowed country"}) - - -class AccessNoCloudHandler(JSONHandler): - @guard_decorator.block_clouds() - async def get(self) -> None: - self.write_json({"message": "Access granted - not from cloud provider"}) - - -class AccessNoAWSHandler(JSONHandler): - @guard_decorator.block_clouds(["AWS"]) - async def get(self) -> None: - self.write_json({"message": "Access granted - not from AWS"}) - - -class AccessBypassHandler(JSONHandler): - @guard_decorator.bypass(["rate_limit", "ip"]) - async def get(self) -> None: - self.write_json( - { - "message": ( - "This endpoint bypasses rate limiting and IP/country/cloud checks" - ), - "details": {"bypassed_checks": ["rate_limit", "ip"]}, - } - ) - - -class AuthHttpsHandler(JSONHandler): - @guard_decorator.require_https() - async def get(self) -> None: - self.write_json( - { - "message": "HTTPS connection verified", - "details": {"protocol": self.request.protocol}, - } - ) - - -class AuthBearerHandler(JSONHandler): - @guard_decorator.require_auth(type="bearer") - async def get(self) -> None: - self.write_json( - { - "authenticated": True, - "user": "example_user", - "method": "bearer", - "permissions": ["read", "write"], - } - ) - - -class AuthAPIKeyHandler(JSONHandler): - @guard_decorator.api_key_auth(header_name="X-API-Key") - async def get(self) -> None: - self.write_json( - { - "authenticated": True, - "user": "api_user", - "method": "api_key", - "permissions": ["read"], - } - ) - - -class AuthCustomHeadersHandler(JSONHandler): - @guard_decorator.require_headers( - {"X-Custom-Header": "required-value", "X-Client-ID": "required-value"} - ) - async def get(self) -> None: - self.write_json( - { - "message": "Required headers verified", - "details": {"headers": dict(self.request.headers)}, - } - ) - - -class RateCustomLimitHandler(JSONHandler): - @guard_decorator.rate_limit(requests=5, window=60) - async def get(self) -> None: - self.write_json( - { - "message": "Custom rate limit endpoint", - "details": {"limit": "5 requests per 60 seconds"}, - } - ) - - -class RateStrictLimitHandler(JSONHandler): - @guard_decorator.rate_limit(requests=1, window=10) - async def get(self) -> None: - self.write_json( - { - "message": "Strict rate limit endpoint", - "details": {"limit": "1 request per 10 seconds"}, - } - ) - - -class RateGeoHandler(JSONHandler): - @guard_decorator.geo_rate_limit( - { - "US": (100, 60), - "CN": (10, 60), - "RU": (20, 60), - "*": (50, 60), - } - ) - async def get(self) -> None: - self.write_json( - { - "message": "Geographic rate limiting applied", - "details": {"description": "Rate limits vary by country"}, - } - ) - - -class BehaviorUsageHandler(JSONHandler): - @guard_decorator.usage_monitor(max_calls=10, window=300, action="log") - async def get(self) -> None: - self.write_json( - { - "message": "Usage monitoring active", - "details": {"monitoring": "10 calls per 5 minutes"}, - } - ) - - -class BehaviorReturnMonitorHandler(JSONHandler): - @guard_decorator.return_monitor( - pattern="404", max_occurrences=3, window=60, action="ban" - ) - async def get(self, status_code: str) -> None: - code = int(status_code) - if code == 404: - self.set_status(404) - self.write_json({"detail": "Not found"}, status_code=404) - return - self.write_json({"message": f"Status code: {code}"}) - - -class BehaviorSuspiciousFrequencyHandler(JSONHandler): - @guard_decorator.suspicious_frequency( - max_frequency=0.5, window=10, action="throttle" - ) - async def get(self) -> None: - self.write_json( - { - "message": "Frequency monitoring active", - "details": {"max_frequency": "1 request per 2 seconds"}, - } - ) - - -class BehaviorComplexHandler(JSONHandler): - @guard_decorator.behavior_analysis( - [ - BehaviorRule( - rule_type="frequency", threshold=10, window=60, action="throttle" - ), - BehaviorRule( - rule_type="return_pattern", - pattern="404", - threshold=5, - window=60, - action="ban", - ), - ] - ) - async def post(self) -> None: - self.write_json( - { - "message": "Complex behavior analysis active", - "details": {"rules": ["frequency", "return_pattern"]}, - } - ) - - -class HeadersInfoHandler(JSONHandler): - async def get(self) -> None: - self.write_json( - { - "message": "All responses include comprehensive security headers", - "details": { - "headers": [ - "X-Content-Type-Options: nosniff", - "X-Frame-Options: SAMEORIGIN", - "X-XSS-Protection: 1; mode=block", - "Strict-Transport-Security: max-age=31536000", - "Content-Security-Policy: default-src 'self'", - "Referrer-Policy: strict-origin-when-cross-origin", - "Permissions-Policy: accelerometer=(), camera=(), ...", - "X-App-Name: TornadoAPI-Guard-Example", - "X-Security-Contact: security@example.com", - ], - "note": "Check browser developer tools to see all headers", - }, - } - ) - - -_TEST_PAGE_HTML = """ - - - - Security Headers Demo - - -

TornadoAPI Guard Security Headers Demo

-

Inspect the Network tab in DevTools to see applied security headers.

- - -""" - - -class HeadersTestPageHandler(SecurityHandler): - async def get(self) -> None: - self.set_header("Content-Type", "text/html; charset=utf-8") - self.write(_TEST_PAGE_HTML) - - -class HeadersCspReportHandler(JSONHandler): - async def post(self) -> None: - try: - payload = json.loads(self.request.body or b"{}") - except json.JSONDecodeError: - payload = {} - report = payload.get("csp-report", {}) - logger.warning( - "CSP Violation: %s blocked %s on %s", - report.get("violated-directive", "unknown"), - report.get("blocked-uri", "unknown"), - report.get("document-uri", "unknown"), - ) - self.write_json( - { - "message": "CSP violation report received", - "details": { - "violated_directive": report.get("violated-directive"), - "blocked_uri": report.get("blocked-uri"), - "source_file": report.get("source-file"), - "line_number": report.get("line-number"), - }, - } - ) - - -class HeadersFrameTestHandler(SecurityHandler): - async def get(self) -> None: - self.set_header("Content-Type", "text/html; charset=utf-8") - self.write( - "Frame Options Test" - "

X-Frame-Options Test

" - "

This page has X-Frame-Options: SAMEORIGIN header.

" - ) - - -class HeadersHSTSHandler(JSONHandler): - async def get(self) -> None: - self.write_json( - { - "message": "HSTS (HTTP Strict Transport Security) is active", - "details": { - "max_age": "31536000 seconds (1 year)", - "include_subdomains": True, - "preload": False, - "description": "Forces HTTPS connections for improved security", - }, - } - ) - - -class HeadersAnalysisHandler(JSONHandler): - async def get(self) -> None: - self.write_json( - { - "message": "Security analysis of current request", - "details": { - "request_headers": { - "user_agent": self.request.headers.get( - "user-agent", "Not provided" - ), - "origin": self.request.headers.get("origin", "Not provided"), - "referer": self.request.headers.get("referer", "Not provided"), - "x_forwarded_for": self.request.headers.get( - "x-forwarded-for", "Not provided" - ), - }, - "security_features": [ - "Content-Type sniffing protection", - "Clickjacking protection", - "XSS filtering", - "HTTPS enforcement", - "Content restrictions", - "Referrer policy control", - "Feature permissions control", - "Custom security headers", - ], - "recommendations": [ - "Always use HTTPS in production", - "Regularly review and tighten CSP directives", - "Monitor CSP violation reports", - "Consider HSTS preload for production domains", - ], - }, - } - ) - - -class ContentNoBotsHandler(JSONHandler): - @guard_decorator.block_user_agents(["bot", "crawler", "spider", "scraper"]) - async def get(self) -> None: - self.write_json({"message": "Human users only - bots blocked"}) - - -class ContentJSONOnlyHandler(JSONHandler): - @guard_decorator.content_type_filter(["application/json"]) - async def post(self) -> None: - try: - data = json.loads(self.request.body or b"{}") - except json.JSONDecodeError: - data = {} - self.write_json({"message": "JSON content received", "details": {"data": data}}) - - -class ContentSizeLimitHandler(JSONHandler): - @guard_decorator.max_request_size(1024 * 100) - async def post(self) -> None: - try: - data = json.loads(self.request.body or b"{}") - except json.JSONDecodeError: - data = {} - self.write_json( - { - "message": "Data received within size limit", - "details": {"size_limit": "100KB", "data": data}, - } - ) - - -class ContentReferrerHandler(JSONHandler): - @guard_decorator.require_referrer( - ["https://example.com", "https://app.example.com"] - ) - async def get(self) -> None: - referrer = self.request.headers.get("referer", "No referrer") - self.write_json( - {"message": "Valid referrer", "details": {"referrer": referrer}} - ) - - -async def suspicious_user_agent_validator( - request: GuardRequest, -) -> GuardResponse | None: - user_agent = request.headers.get("user-agent", "").lower() - if "suspicious-pattern" in user_agent: - return TornadoGuardResponse( - status_code=403, - headers={"Content-Type": "application/json; charset=utf-8"}, - body=_json_dumps({"detail": "Suspicious user agent detected"}).encode( - "utf-8" - ), - ) - return None - - -class ContentCustomValidationHandler(JSONHandler): - @guard_decorator.custom_validation(suspicious_user_agent_validator) - async def get(self) -> None: - self.write_json( - { - "message": "Custom validation passed", - "details": {"validator": "suspicious_user_agent_validator"}, - } - ) - - -class AdvancedBusinessHoursHandler(JSONHandler): - @guard_decorator.time_window(start_time="09:00", end_time="17:00", timezone="UTC") - async def get(self) -> None: - self.write_json( - { - "message": "Access granted during business hours", - "details": {"hours": "09:00-17:00 UTC"}, - } - ) - - -class AdvancedWeekendHandler(JSONHandler): - @guard_decorator.time_window(start_time="00:00", end_time="23:59", timezone="UTC") - async def get(self) -> None: - self.write_json( - { - "message": "Weekend access endpoint", - "details": {"note": "Implement weekend check in time_window"}, - } - ) - - -class AdvancedHoneypotHandler(JSONHandler): - @guard_decorator.honeypot_detection( - ["honeypot_field", "trap_input", "hidden_field"] - ) - async def post(self) -> None: - self.write_json( - { - "message": "Human user verified", - "details": {"honeypot_status": "clean"}, - } - ) - - -class AdvancedSuspiciousHandler(JSONHandler): - @guard_decorator.suspicious_detection(enabled=True) - async def get(self) -> None: - query = self.get_query_argument("query", default=None) - self.write_json( - { - "message": "No suspicious patterns detected", - "details": {"query": query}, - } - ) - - -class AdminUnbanHandler(JSONHandler): - @guard_decorator.require_ip(whitelist=["127.0.0.1"]) - async def post(self) -> None: - try: - data = json.loads(self.request.body or b"{}") - except json.JSONDecodeError: - data = {} - ip = data.get("ip", "") - logger.info(f"Unbanning IP: {ip}") - self.write_json( - { - "message": f"IP {ip} has been unbanned", - "details": {"action": "unban", "ip": ip}, - } - ) - - -class AdminStatsHandler(JSONHandler): - @guard_decorator.require_ip(whitelist=["127.0.0.1"]) - async def get(self) -> None: - self.write_json( - { - "total_requests": 1500, - "blocked_requests": 75, - "banned_ips": ["192.168.1.100", "10.0.0.50"], - "rate_limited_ips": {"192.168.1.200": 5, "172.16.0.10": 3}, - "suspicious_activities": [ - { - "ip": "192.168.1.100", - "reason": "SQL injection attempt", - "timestamp": datetime.now(timezone.utc), - }, - { - "ip": "10.0.0.50", - "reason": "Rapid requests", - "timestamp": datetime.now(timezone.utc), - }, - ], - "active_rules": { - "rate_limit": 30, - "rate_window": 60, - "auto_ban_threshold": 5, - "blocked_clouds": ["AWS", "GCP", "Azure"], - }, - } - ) - - -class AdminClearCacheHandler(JSONHandler): - @guard_decorator.require_ip(whitelist=["127.0.0.1"]) - async def post(self) -> None: - self.write_json( - { - "message": "Security caches cleared", - "details": { - "cleared": ["rate_limit_cache", "ip_ban_cache", "geo_cache"] - }, - } - ) - - -class AdminEmergencyModeHandler(JSONHandler): - @guard_decorator.require_ip(whitelist=["127.0.0.1"]) - async def put(self) -> None: - try: - data = json.loads(self.request.body or b"{}") - except json.JSONDecodeError: - data = {} - enable = bool(data.get("enable", False)) - mode = "enabled" if enable else "disabled" - self.write_json( - { - "message": f"Emergency mode {mode}", - "details": { - "emergency_mode": enable, - "timestamp": datetime.now(timezone.utc), - }, - } - ) - - -class AdminCloudStatusHandler(JSONHandler): - @guard_decorator.require_ip(whitelist=["127.0.0.1"]) - async def get(self) -> None: - last_updated: dict[str, str | None] = {} - for provider, dt in cloud_handler.last_updated.items(): - last_updated[provider] = dt.isoformat() if dt else None - self.write_json( - { - "message": "Cloud provider IP range status", - "details": { - "refresh_interval": security_config.cloud_ip_refresh_interval, - "providers": last_updated, - }, - } - ) - - -class TestXSSHandler(JSONHandler): - async def post(self) -> None: - payload = self.request.body.decode("utf-8", errors="replace") - self.write_json( - { - "message": "XSS test payload processed", - "details": {"payload": payload, "detected": False}, - } - ) - - -class TestSQLHandler(JSONHandler): - async def get(self) -> None: - query = self.get_query_argument("query", default="") - self.write_json( - { - "message": "SQL injection test processed", - "details": {"query": query, "detected": False}, - } - ) - - -class TestPathTraversalHandler(JSONHandler): - async def get(self, file_path: str) -> None: - self.write_json( - { - "message": "Path traversal test", - "details": {"path": file_path, "detected": False}, - } - ) - - -class TestCommandInjectionHandler(JSONHandler): - async def post(self) -> None: - command = self.request.body.decode("utf-8", errors="replace") - self.write_json( - { - "message": "Command injection test processed", - "details": {"command": command, "detected": False}, - } - ) - - -class TestMixedAttackHandler(JSONHandler): - async def post(self) -> None: - try: - payload = json.loads(self.request.body or b"{}") - except json.JSONDecodeError: - payload = {} - self.write_json( - { - "message": "Mixed attack test processed", - "details": { - "xss_test": payload.get("input"), - "sql_test": payload.get("query"), - "path_test": payload.get("path"), - "cmd_test": payload.get("cmd"), - "honeypot": payload.get("honeypot_field"), - }, - } - ) - - -class EchoWebSocketHandler(tornado.websocket.WebSocketHandler): - def check_origin(self, origin: str) -> bool: - return True - - async def on_message(self, message: str | bytes) -> None: - text = message.decode("utf-8") if isinstance(message, bytes) else message - await self.write_message(f"Echo: {text}") - if text == "status": - await self.write_message( - json.dumps( - { - "type": "status", - "timestamp": datetime.now(timezone.utc).isoformat(), - "security": "active", - } - ) - ) - - def on_close(self) -> None: - logger.info("WebSocket disconnected") - - -def build_application() -> tornado.web.Application: - handlers: list[tuple[str, type[tornado.web.RequestHandler]]] = [ - (r"/", RootHandler), - (r"/basic/?", BasicRootHandler), - (r"/basic/ip", BasicIPHandler), - (r"/basic/health", BasicHealthHandler), - (r"/basic/echo", BasicEchoHandler), - (r"/access/ip-whitelist", AccessIPWhitelistHandler), - (r"/access/ip-blacklist", AccessIPBlacklistHandler), - (r"/access/country-block", AccessCountryBlockHandler), - (r"/access/country-allow", AccessCountryAllowHandler), - (r"/access/no-cloud", AccessNoCloudHandler), - (r"/access/no-aws", AccessNoAWSHandler), - (r"/access/bypass-demo", AccessBypassHandler), - (r"/auth/https-only", AuthHttpsHandler), - (r"/auth/bearer-auth", AuthBearerHandler), - (r"/auth/api-key", AuthAPIKeyHandler), - (r"/auth/custom-headers", AuthCustomHeadersHandler), - (r"/rate/custom-limit", RateCustomLimitHandler), - (r"/rate/strict-limit", RateStrictLimitHandler), - (r"/rate/geo-rate-limit", RateGeoHandler), - (r"/behavior/usage-monitor", BehaviorUsageHandler), - (r"/behavior/return-monitor/(\d+)", BehaviorReturnMonitorHandler), - (r"/behavior/suspicious-frequency", BehaviorSuspiciousFrequencyHandler), - (r"/behavior/behavior-rules", BehaviorComplexHandler), - (r"/headers/?", HeadersInfoHandler), - (r"/headers/test-page", HeadersTestPageHandler), - (r"/headers/csp-report", HeadersCspReportHandler), - (r"/headers/frame-test", HeadersFrameTestHandler), - (r"/headers/hsts-info", HeadersHSTSHandler), - (r"/headers/security-analysis", HeadersAnalysisHandler), - (r"/content/no-bots", ContentNoBotsHandler), - (r"/content/json-only", ContentJSONOnlyHandler), - (r"/content/size-limit", ContentSizeLimitHandler), - (r"/content/referrer-check", ContentReferrerHandler), - (r"/content/custom-validation", ContentCustomValidationHandler), - (r"/advanced/business-hours", AdvancedBusinessHoursHandler), - (r"/advanced/weekend-only", AdvancedWeekendHandler), - (r"/advanced/honeypot", AdvancedHoneypotHandler), - (r"/advanced/suspicious-patterns", AdvancedSuspiciousHandler), - (r"/admin/unban-ip", AdminUnbanHandler), - (r"/admin/stats", AdminStatsHandler), - (r"/admin/clear-cache", AdminClearCacheHandler), - (r"/admin/emergency-mode", AdminEmergencyModeHandler), - (r"/admin/cloud-status", AdminCloudStatusHandler), - (r"/test/xss-test", TestXSSHandler), - (r"/test/sql-injection", TestSQLHandler), - (r"/test/path-traversal/(.*)", TestPathTraversalHandler), - (r"/test/command-injection", TestCommandInjectionHandler), - (r"/test/mixed-attack", TestMixedAttackHandler), - (r"/ws", EchoWebSocketHandler), - ] - 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 Example starting up...") - logger.info( - "Security features enabled: rate_limiting=%s ip_banning=%s " - "penetration_detection=%s redis=%s agent=%s", - security_config.enable_rate_limiting, - security_config.enable_ip_banning, - security_config.enable_penetration_detection, - security_config.enable_redis, - security_config.enable_agent, - ) - await security_middleware.initialize() - app = build_application() - server = tornado.httpserver.HTTPServer(app) - host = os.getenv("HOST", "127.0.0.1") - 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 Example shutting down...") - server.stop() - await security_middleware.reset() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/live_smoke_stack/requirements.txt b/live_smoke_stack/requirements.txt deleted file mode 100644 index 11f611a..0000000 --- a/live_smoke_stack/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -tornado -redis -requests -tornadoapi-guard @ file:///app/tornadoapi_guard-1.2.0-py3-none-any.whl diff --git a/live_smoke_stack/tornadoapi_guard-1.2.0-py3-none-any.whl b/live_smoke_stack/tornadoapi_guard-1.2.0-py3-none-any.whl deleted file mode 100644 index eb34b89a0a0d0cce4fd5af2197fb3ab4cf917cbe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15682 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

-wKyDulzl!2ar=nu%AD56 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 Date: Tue, 29 Sep 2026 01:27:14 +0200 Subject: [PATCH 8/9] fix(examples): give the runtime user ownership of the app directory --- examples/advanced_app/Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/advanced_app/Dockerfile b/examples/advanced_app/Dockerfile index e20328d..8a3fab3 100644 --- a/examples/advanced_app/Dockerfile +++ b/examples/advanced_app/Dockerfile @@ -27,7 +27,9 @@ 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 +RUN useradd --create-home --uid 10001 guard \ + && mkdir -p /app/data/ipinfo \ + && chown -R guard:guard /app USER guard CMD ["python", "main.py"] From c37b186b889167c9a2a445da299d21f596cf7fd7 Mon Sep 17 00:00:00 2001 From: Renn F Date: Tue, 29 Sep 2026 01:51:04 +0200 Subject: [PATCH 9/9] fix(examples): stop reflecting the client IP in the admin check response --- examples/advanced_app/main.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/advanced_app/main.py b/examples/advanced_app/main.py index 9aaf55b..8b3635b 100644 --- a/examples/advanced_app/main.py +++ b/examples/advanced_app/main.py @@ -144,11 +144,12 @@ async def get(self) -> None: class AdminCheckHandler(JSONHandler): @guard_decorator.require_headers({"X-Admin-Token": _ADMIN_TOKEN}) async def get(self) -> None: - client = self.request.remote_ip or "unknown" + # 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": {"client": client, "timestamp": datetime.now(timezone.utc)}, + "details": {"timestamp": datetime.now(timezone.utc)}, } )