Harden app for production behind nginx Proxy Manager.

Remove path-based import, add rate limits and upload caps, security headers, proxy trust, bundled Chart.js, non-root Docker, and NPM deployment docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-19 16:23:27 +02:00
parent f2c564e69a
commit 58b9e0bb0a
8 changed files with 232 additions and 45 deletions
+12 -4
View File
@@ -4,18 +4,26 @@ WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DATA_DIR=/data
DATA_DIR=/data \
TRUST_PROXY=1 \
DISABLE_LOCAL_VIEWER=1 \
PREFERRED_URL_SCHEME=https \
MAX_UPLOAD_MB=10
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py db.py parser.py categories.py validation.py viewers.py ./
COPY app.py db.py parser.py categories.py validation.py viewers.py security.py ./
COPY templates/ templates/
COPY static/ static/
RUN mkdir -p /data/viewers /data/uploads
RUN mkdir -p /data/viewers /data/uploads \
&& useradd --create-home --uid 1000 --shell /usr/sbin/nologin appuser \
&& chown -R appuser:appuser /app /data
USER appuser
VOLUME ["/data"]
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "app:app"]
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "120", "app:app"]
+78 -10
View File
@@ -9,7 +9,7 @@ A web viewer for backups of the Android game **Idle Fantasy**. Parses `fantasyid
- **SQLite history** — import multiple backups, compare snapshots, coins/level charts
- **Import** via CLI or browser upload
- **Multi-user** without login — each player gets their own viewer via a secret link
- **Docker** — ready to run on a server
- **Docker** — ready to run behind nginx Proxy Manager
- **i18n** — English as default/fallback, German optional; automatic browser language or manual selection in the sidebar
## Requirements
@@ -37,15 +37,17 @@ The browser opens automatically at `http://127.0.0.1:5000/v/local/`.
### Docker (host for other players)
Designed to run **behind nginx Proxy Manager** — the container port is not published publicly by default.
```powershell
docker compose up -d --build
```
The viewer is then available at `http://localhost:5000`:
1. Landing page**Create my viewer**
2. Save the personal link (bookmark) — **without the link, data cannot be recovered** (no login)
3. Import backups in the browser
1. Attach the `viewer` service to your NPM Docker network (see `docker-compose.yml` comments).
2. In NPM: new Proxy Host → forward to `viewer:5000`, enable SSL.
3. Open your public URL**Create my viewer**
4. Save the personal link (bookmark) — **without the link, data cannot be recovered** (no login)
5. Import backups in the browser
Data is stored in the Docker volume `viewer-data` (`/data/viewers/<id>.db`).
@@ -57,7 +59,12 @@ docker compose logs -f
docker compose down
```
The `DATA_DIR` environment variable (default in Docker: `/data`) sets the storage location.
For local Docker testing without NPM, temporarily add to `docker-compose.yml`:
```yaml
ports:
- "5000:5000"
```
### More options
@@ -92,7 +99,66 @@ Each viewer has its own SQLite database at `data/viewers/<viewer_id>.db`.
The `viewer_id` is a random URL-safe token. Anyone with the link has access — there is no password and no recovery if the link is lost.
Local CLI usage defaults to the `local` viewer (`/v/local/`).
Local CLI usage defaults to the `local` viewer (`/v/local/`). In Docker/production, `/v/local/` is disabled (`DISABLE_LOCAL_VIEWER=1`).
## Security
The app uses **secret-link access** (no accounts). Suitable for sharing with trusted players when deployed behind HTTPS.
### Built-in protections
| Measure | Default (Docker) |
|---------|------------------|
| Upload size limit | 10 MB (`MAX_UPLOAD_MB`) |
| Viewer creation rate limit | 5 / minute per IP |
| Import rate limit | 20 / hour per IP |
| Path-based import | **Removed** (upload only) |
| `/v/local/` in production | Disabled |
| Reverse-proxy headers | `TRUST_PROXY=1` (ProxyFix) |
| HTTPS links | `PREFERRED_URL_SCHEME=https` |
| Security headers | CSP, `X-Frame-Options`, `nosniff`, `Referrer-Policy` |
| Chart.js | Bundled locally (no CDN) |
| Container user | Non-root (`appuser`, uid 1000) |
### Environment variables
| Variable | Default | Description |
|----------|---------|-------------|
| `DATA_DIR` | `./data` | SQLite and upload storage |
| `TRUST_PROXY` | `1` in Docker | Trust `X-Forwarded-*` from nginx |
| `PREFERRED_URL_SCHEME` | `https` in Docker | Scheme for generated viewer URLs |
| `DISABLE_LOCAL_VIEWER` | `1` in Docker | Block predictable `/v/local/` |
| `MAX_UPLOAD_MB` | `10` | Max upload body size |
| `RATE_LIMIT_VIEWER_CREATE` | `5 per minute` | Limit for `POST /api/viewers` |
| `RATE_LIMIT_IMPORT` | `20 per hour` | Limit for `POST .../import` |
### nginx Proxy Manager
Recommended NPM settings:
- **SSL** with Force SSL
- **Block Common Exploits** enabled
- Forward headers: `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Real-IP` (default)
- Optional **Advanced** config:
```nginx
client_max_body_size 10m;
limit_req_zone $binary_remote_addr zone=viewer_create:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=viewer_api:10m rate=30r/s;
location /api/viewers {
limit_req zone=viewer_create burst=2 nodelay;
proxy_pass http://viewer:5000;
}
location / {
limit_req zone=viewer_api burst=50 nodelay;
proxy_pass http://viewer:5000;
}
```
Do **not** expose port `5000` publicly — only NPM should reach the container.
## Language / i18n
@@ -107,6 +173,7 @@ Local CLI usage defaults to the `local` viewer (`/v/local/`).
```
idle-fantasy-viewer/
├── app.py # Flask server and CLI
├── security.py # Rate limits, headers, proxy trust
├── viewers.py # Viewer IDs and isolation
├── parser.py # Parse and normalize saves
├── categories.py # Item categories (heuristics)
@@ -115,6 +182,7 @@ idle-fantasy-viewer/
├── docker-compose.yml
├── requirements.txt
├── static/
│ ├── vendor/ # chart.umd.min.js (bundled)
│ ├── i18n.js # Locale loading, t(), en fallback
│ ├── locales/ # en.json, de.json
│ ├── landing.js # Landing page
@@ -128,12 +196,12 @@ idle-fantasy-viewer/
| Endpoint | Description |
|----------|-------------|
| `GET /` | Landing page |
| `POST /api/viewers` | Create a new viewer |
| `POST /api/viewers` | Create a new viewer (rate limited) |
| `GET /v/<id>/api/snapshot/latest` | Latest save for the viewer |
| `GET /v/<id>/api/snapshots` | All snapshots |
| `GET /v/<id>/api/snapshots/<older>/diff/<newer>` | Compare two snapshots |
| `GET /v/<id>/api/timeline` | Time series for charts |
| `POST /v/<id>/api/import` | JSON upload |
| `POST /v/<id>/api/import` | JSON file upload (`.json` only, rate limited) |
## Save format
+43 -28
View File
@@ -23,6 +23,14 @@ from db import (
get_connection,
timeline,
)
from security import (
IMPORT_LIMIT,
VIEWER_CREATE_LIMIT,
configure_app,
external_base_url,
limiter,
local_viewer_disabled,
)
from viewers import (
LOCAL_VIEWER_ID,
create_viewer,
@@ -32,6 +40,7 @@ from viewers import (
)
app = Flask(__name__)
configure_app(app)
DATA_DIR = Path(os.environ.get("DATA_DIR", Path(__file__).parent / "data"))
DB_PATH = DEFAULT_DB
@@ -42,11 +51,12 @@ def get_data_dir() -> Path:
def _viewer_url(viewer_id: str) -> str:
base = request.host_url.rstrip("/")
return f"{base}/v/{viewer_id}/"
return f"{external_base_url()}/v/{viewer_id}/"
def _resolve_viewer_db(viewer_id: str) -> Path:
if viewer_id == LOCAL_VIEWER_ID and local_viewer_disabled():
abort(404)
if not is_valid_viewer_id(viewer_id):
abort(404)
db_path = viewer_db_path(viewer_id, get_data_dir())
@@ -93,8 +103,8 @@ def api_diff(viewer_id: str, older_id: int, newer_id: int):
db_path = _resolve_viewer_db(viewer_id)
try:
return jsonify(diff_snapshots(older_id, newer_id, db_path=db_path))
except ValueError as e:
return jsonify({"error": str(e)}), 404
except ValueError:
return jsonify({"error": "Snapshot not found"}), 404
@viewer_bp.route("/api/timeline")
@@ -104,34 +114,28 @@ def api_timeline(viewer_id: str):
@viewer_bp.route("/api/import", methods=["POST"])
@limiter.limit(IMPORT_LIMIT)
def api_import(viewer_id: str):
db_path = _resolve_viewer_db(viewer_id)
if "file" in request.files:
f = request.files["file"]
if not f.filename:
return jsonify({"error": "No file selected"}), 400
upload_dir = get_data_dir() / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
safe_name = secure_filename(f.filename) or "upload.json"
tmp = upload_dir / f"_upload_{viewer_id}_{safe_name}"
f.save(tmp)
try:
result = import_save(tmp, db_path=db_path)
finally:
tmp.unlink(missing_ok=True)
if result.get("error"):
return jsonify(result), 422
return jsonify(result)
if "file" not in request.files:
return jsonify({"error": "No file uploaded"}), 400
f = request.files["file"]
if not f.filename:
return jsonify({"error": "No file selected"}), 400
body = request.get_json(silent=True) or {}
path = body.get("path")
if not path:
return jsonify({"error": "Provide file upload or JSON body with path"}), 400
path = Path(path)
if not path.exists():
return jsonify({"error": f"File not found: {path}"}), 404
result = import_save(path, db_path=db_path)
upload_dir = get_data_dir() / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
safe_name = secure_filename(f.filename) or "upload.json"
if not safe_name.lower().endswith(".json"):
return jsonify({"error": "Only .json files are accepted"}), 400
tmp = upload_dir / f"_upload_{viewer_id}_{safe_name}"
f.save(tmp)
try:
result = import_save(tmp, db_path=db_path)
finally:
tmp.unlink(missing_ok=True)
if result.get("error"):
return jsonify(result), 422
return jsonify(result)
@@ -146,6 +150,7 @@ def landing():
@app.post("/api/viewers")
@limiter.limit(VIEWER_CREATE_LIMIT)
def api_create_viewer():
viewer_id = create_viewer(get_data_dir())
return jsonify({
@@ -154,6 +159,16 @@ def api_create_viewer():
}), 201
@app.errorhandler(413)
def request_too_large(_exc):
return jsonify({"error": "Upload too large"}), 413
@app.errorhandler(429)
def rate_limit_exceeded(_exc):
return jsonify({"error": "Too many requests"}), 429
def _print_import_report(result: dict) -> None:
report = result.get("import_report") or []
if not report:
+15 -2
View File
@@ -1,13 +1,26 @@
services:
viewer:
build: .
ports:
- "5000:5000"
expose:
- "5000"
environment:
DATA_DIR: /data
TRUST_PROXY: "1"
DISABLE_LOCAL_VIEWER: "1"
PREFERRED_URL_SCHEME: https
MAX_UPLOAD_MB: "10"
RATE_LIMIT_VIEWER_CREATE: "5 per minute"
RATE_LIMIT_IMPORT: "20 per hour"
volumes:
- viewer-data:/data
restart: unless-stopped
# Attach to your nginx Proxy Manager network (uncomment and set name):
# networks:
# - npm
volumes:
viewer-data:
# networks:
# npm:
# external: true
+1
View File
@@ -1,2 +1,3 @@
flask>=3.0
flask-limiter>=3.8
gunicorn>=22.0
+62
View File
@@ -0,0 +1,62 @@
"""Application security: limits, proxy trust, headers."""
from __future__ import annotations
import os
from flask import Flask, request
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from werkzeug.middleware.proxy_fix import ProxyFix
limiter = Limiter(key_func=get_remote_address, storage_uri="memory://")
DEFAULT_MAX_UPLOAD_MB = 10
VIEWER_CREATE_LIMIT = os.environ.get("RATE_LIMIT_VIEWER_CREATE", "5 per minute")
IMPORT_LIMIT = os.environ.get("RATE_LIMIT_IMPORT", "20 per hour")
def local_viewer_disabled() -> bool:
return os.environ.get("DISABLE_LOCAL_VIEWER", "").lower() in ("1", "true", "yes")
def trust_proxy_enabled() -> bool:
return os.environ.get("TRUST_PROXY", "").lower() in ("1", "true", "yes")
def configure_app(flask_app: Flask) -> None:
max_mb = int(os.environ.get("MAX_UPLOAD_MB", DEFAULT_MAX_UPLOAD_MB))
flask_app.config["MAX_CONTENT_LENGTH"] = max_mb * 1024 * 1024
if trust_proxy_enabled():
flask_app.wsgi_app = ProxyFix(
flask_app.wsgi_app,
x_for=1,
x_proto=1,
x_host=1,
)
limiter.init_app(flask_app)
@flask_app.after_request
def _security_headers(response):
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"connect-src 'self'; "
"frame-ancestors 'self'"
)
return response
def external_base_url() -> str:
"""Build public base URL (respects reverse proxy and PREFERRED_URL_SCHEME)."""
preferred = os.environ.get("PREFERRED_URL_SCHEME", "").strip()
if preferred:
return f"{preferred}://{request.host}"
return request.host_url.rstrip("/")
+20
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Idle Fantasy Viewer</title>
<link rel="stylesheet" href="/static/style.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" defer></script>
<script src="/static/vendor/chart.umd.min.js" defer></script>
<script src="/static/i18n.js" defer></script>
<script>window.VIEWER_ID = {{ viewer_id|tojson }};</script>
<script src="/static/app.js" defer></script>