More hardening and migration from Drone to Woodpecker
This commit is contained in:
82
.drone.yml
82
.drone.yml
@@ -1,82 +0,0 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: Build and Deploy Pipeline
|
||||
|
||||
workspace:
|
||||
path: /drone/mail-autoconfig
|
||||
|
||||
steps:
|
||||
- name: Build and Publish Docker Image
|
||||
image: plugins/docker
|
||||
settings:
|
||||
auto_tag: true
|
||||
repo: git.mifi.dev/mifi-holdings/mail-autoconfig
|
||||
registry: git.mifi.dev
|
||||
username: <token>
|
||||
password:
|
||||
from_secret: gitea_package_token
|
||||
secrets: [gitea_package_token]
|
||||
|
||||
- name: Send Deploy Status Notification
|
||||
image: plugins/webhook
|
||||
settings:
|
||||
urls: https://lab.mifi.dev/hooks/9p65zpagctgkmndo8nwwm4199r
|
||||
content_type: application/json
|
||||
template: |
|
||||
{
|
||||
"icon_url":"https://emojipedia-us.s3.dualstack.us-west-1.amazonaws.com/thumbs/120/apple/198/freezing-face_1f976.png",
|
||||
"text": "[{{ repo.name }} - Build # {{ build.number }}] Publish {{ build.status }} {{#success build.status}}:tada:{{else}}:poop:{{/success}}",
|
||||
"username":"DroneBot"
|
||||
}
|
||||
when:
|
||||
status:
|
||||
- success
|
||||
- failure
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
event:
|
||||
- push
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: Production Deploy Pipeline
|
||||
|
||||
workspace:
|
||||
path: /drone/mail-autoconfig
|
||||
|
||||
clone:
|
||||
disable: true
|
||||
|
||||
steps:
|
||||
- name: Deploy to Production
|
||||
image: plugins/webhook
|
||||
settings:
|
||||
urls: https://portainer.mifi.dev/api/stacks/webhooks/f7e63722-cafd-4970-8729-9f1936c6a070
|
||||
|
||||
- name: Send Production Deploy Status
|
||||
image: plugins/webhook
|
||||
settings:
|
||||
urls: https://lab.mifi.dev/hooks/9p65zpagctgkmndo8nwwm4199r
|
||||
content_type: application/json
|
||||
template: |
|
||||
{
|
||||
"icon_url":"https://emojipedia-us.s3.dualstack.us-west-1.amazonaws.com/thumbs/120/apple/198/freezing-face_1f976.png",
|
||||
"text": "[{{ repo.name }} - Build # {{ build.number }}] Production Deploy {{ build.status }} {{#success build.status}}:tada:{{else}}:poop:{{/success}}",
|
||||
"username":"DroneBot"
|
||||
}
|
||||
when:
|
||||
status:
|
||||
- success
|
||||
- failure
|
||||
|
||||
depends_on:
|
||||
- Build and Deploy Pipeline
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- promote
|
||||
target:
|
||||
- production
|
||||
33
.woodpecker/build.yaml
Normal file
33
.woodpecker/build.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
# Build and Publish Docker image (runs on push to main)
|
||||
# Secrets required in Woodpecker: gitea_registry_username, gitea_package_token, discord_webhook_url
|
||||
# Project must be set to "Trusted" in Woodpecker for the Docker build step (privileged).
|
||||
when:
|
||||
branch: main
|
||||
event: push
|
||||
|
||||
steps:
|
||||
- name: Build and Publish Docker Image
|
||||
image: woodpeckerci/plugin-docker-buildx:2
|
||||
privileged: true
|
||||
settings:
|
||||
repo: git.mifi.dev/mifi-holdings/mail-autoconfig
|
||||
registry: git.mifi.dev
|
||||
auto_tag: true
|
||||
username:
|
||||
from_secret: gitea_registry_username
|
||||
password:
|
||||
from_secret: gitea_package_token
|
||||
|
||||
- name: Send Deploy Status Notification
|
||||
image: curlimages/curl
|
||||
environment:
|
||||
DISCORD_WEBHOOK_URL:
|
||||
from_secret: discord_webhook_url
|
||||
commands:
|
||||
- |
|
||||
EMOJI="💩"
|
||||
[ "$CI_PIPELINE_STATUS" = "success" ] && EMOJI="🎉"
|
||||
BODY=$(printf '{"username":"WoodpeckerBot","content":"[%s - Build #%s] Publish %s %s"}' "$CI_REPO" "$CI_PIPELINE_NUMBER" "$CI_PIPELINE_STATUS" "$EMOJI")
|
||||
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" "$DISCORD_WEBHOOK_URL"
|
||||
when:
|
||||
- status: [ success, failure ]
|
||||
31
.woodpecker/production.yaml
Normal file
31
.woodpecker/production.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
# Production Deploy (run via Woodpecker UI: trigger deployment to "production")
|
||||
# No clone: runs without repo checkout; triggers Portainer stack webhook.
|
||||
# Secrets required: portainer_webhook_url (Portainer stack webhook URL), discord_webhook_url (Discord incoming webhook).
|
||||
skip_clone: true
|
||||
|
||||
when:
|
||||
event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "production"'
|
||||
|
||||
steps:
|
||||
- name: Deploy to Production
|
||||
image: curlimages/curl
|
||||
environment:
|
||||
PORTAINER_WEBHOOK_URL:
|
||||
from_secret: portainer_webhook_url
|
||||
commands:
|
||||
- curl -sS -X POST "$PORTAINER_WEBHOOK_URL"
|
||||
|
||||
- name: Send Production Deploy Status
|
||||
image: curlimages/curl
|
||||
environment:
|
||||
DISCORD_WEBHOOK_URL:
|
||||
from_secret: discord_webhook_url
|
||||
commands:
|
||||
- |
|
||||
EMOJI="💩"
|
||||
[ "$CI_PIPELINE_STATUS" = "success" ] && EMOJI="🎉"
|
||||
BODY=$(printf '{"username":"WoodpeckerBot","content":"[%s - Build #%s] Production Deploy %s %s"}' "$CI_REPO" "$CI_PIPELINE_NUMBER" "$CI_PIPELINE_STATUS" "$EMOJI")
|
||||
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" "$DISCORD_WEBHOOK_URL"
|
||||
when:
|
||||
- status: [ success, failure ]
|
||||
@@ -9,8 +9,9 @@ WORKDIR /app
|
||||
COPY app.py ./
|
||||
COPY templates/ ./templates/
|
||||
|
||||
# Install dependencies as root
|
||||
RUN pip install --no-cache-dir Flask Jinja2 gunicorn
|
||||
# Install dependencies as root (versions pinned in requirements.txt)
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Create necessary directories and set permissions
|
||||
RUN mkdir -p /tmp && chown -R appuser:appuser /app /tmp
|
||||
@@ -21,5 +22,5 @@ USER appuser
|
||||
# Expose port 8080 (internal)
|
||||
EXPOSE 8080
|
||||
|
||||
# Bind to localhost only for security
|
||||
CMD ["gunicorn", "-b", "127.0.0.1:8080", "--workers", "2", "--worker-class", "sync", "--worker-connections", "1000", "--max-requests", "1000", "--max-requests-jitter", "100", "--timeout", "30", "--keep-alive", "2", "app:app"]
|
||||
# Bind to 0.0.0.0 so Traefik (separate container) can reach us; exposure is limited to Docker network only (no host/internet direct access)
|
||||
CMD ["gunicorn", "-b", "0.0.0.0:8080", "--workers", "2", "--worker-class", "sync", "--worker-connections", "1000", "--max-requests", "1000", "--max-requests-jitter", "100", "--timeout", "30", "--keep-alive", "2", "--limit-request-line", "4094", "--limit-request-fields", "100", "--worker-tmp-dir", "/tmp", "app:app"]
|
||||
|
||||
124
README.md
124
README.md
@@ -0,0 +1,124 @@
|
||||
# Mail Autoconfig
|
||||
|
||||
A single HTTP service that provides **Thunderbird autoconfig** and **Outlook autodiscover** for multiple domains. Email clients discover IMAP/SMTP settings by requesting well-known URLs; this app serves the correct XML based on the request host (e.g. `autoconfig.example.com` or `autodiscover.example.com`).
|
||||
|
||||
## Features
|
||||
|
||||
- **Thunderbird (Mozilla)** — Serves `config-v1.1.xml` at `/mail/config-v1.1.xml` when the client requests `https://autoconfig.<domain>/mail/config-v1.1.xml`.
|
||||
- **Outlook (Microsoft)** — Serves Autodiscover XML at `/Autodiscover/Autodiscover.xml` when the client requests `https://autodiscover.<domain>/Autodiscover/Autodiscover.xml` (GET or POST).
|
||||
- **Multi-domain** — One deployment serves many domains; the `Host` header selects the domain. Each domain must be in the allowlist.
|
||||
- **Security** — Host allowlist, domain sanitization, rate limiting, security headers, and a hardened Docker setup (see [Security](#security)).
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Thunderbird** looks up the autoconfig URL for the user’s domain (e.g. `user@example.com` → `https://autoconfig.example.com/mail/config-v1.1.xml`). It requests that URL; the app returns XML with IMAP/SMTP host, ports, and auth.
|
||||
2. **Outlook** does the same for autodiscover (e.g. `https://autodiscover.example.com/Autodiscover/Autodiscover.xml`). The app returns Microsoft’s Autodiscover XML with server settings.
|
||||
|
||||
The app derives the “domain” from the `Host` header (e.g. `autoconfig.example.com` → `example.com`). The domain is validated against an allowlist and sanitized; it is only used in the XML for display/identification. **IMAP and SMTP server hostnames and ports are currently fixed in the templates** (e.g. `mail.mifi.holdings`, 993/587). To use different mail servers per domain, you would extend the templates or config.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Endpoint | Methods | Description | Rate limit |
|
||||
|----------|---------|-------------|------------|
|
||||
| `/` | GET | Landing page with links to autoconfig/autodiscover URLs for the current host | 50/hour |
|
||||
| `/ping` | GET | Health check (returns plain text) | 10/minute |
|
||||
| `/mail/config-v1.1.xml` | GET | Thunderbird autoconfig XML | 20/hour |
|
||||
| `/Autodiscover/Autodiscover.xml` | GET, POST | Outlook autodiscover XML | 20/hour |
|
||||
|
||||
All endpoints require a `Host` header that matches the allowlist (e.g. `autoconfig.example.com`, `autodiscover.example.com`). Unlisted hosts receive `403 Forbidden`.
|
||||
|
||||
## Supported domains
|
||||
|
||||
Domains are configured in the `ALLOWED_HOSTS` set in `app.py`. Each domain must have both forms:
|
||||
|
||||
- `autoconfig.<domain>`
|
||||
- `autodiscover.<domain>`
|
||||
|
||||
To add a domain, add both hostnames to `ALLOWED_HOSTS` and, if you use Traefik, add matching router/service labels in `docker-compose.yml` (see [Deployment](#deployment)).
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Python** 3.11+ (for local run)
|
||||
- **Dependencies**: Flask, Jinja2, Gunicorn (see `requirements.txt`)
|
||||
- **Production**: Docker; reverse proxy (e.g. Traefik) for TLS and routing
|
||||
|
||||
## Installation
|
||||
|
||||
### Docker (recommended)
|
||||
|
||||
Build and run with Docker Compose. The app listens on port 8080 inside the container and is intended to sit behind a reverse proxy (e.g. Traefik) that terminates TLS and routes by host.
|
||||
|
||||
```bash
|
||||
docker build -t mail-autoconfig .
|
||||
docker run --rm -p 8080:8080 mail-autoconfig
|
||||
```
|
||||
|
||||
For production you typically use `docker-compose.yml`, which wires the service into a Traefik network and applies security and resource limits.
|
||||
|
||||
### Docker Compose (production)
|
||||
|
||||
1. Ensure the `marina-net` (or your Traefik network) exists:
|
||||
`docker network create marina-net`
|
||||
2. Update `docker-compose.yml` if your image name or network differs.
|
||||
3. Run:
|
||||
`docker compose up -d`
|
||||
|
||||
The Compose file configures Traefik routers so that each allowed host (e.g. `autoconfig.mifi.holdings`) is routed to this service on port 8080. TLS is handled by Traefik (e.g. Let’s Encrypt).
|
||||
|
||||
### Local development
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # or .venv\Scripts\activate on Windows
|
||||
pip install -r requirements.txt
|
||||
flask --app app run --port 5000
|
||||
```
|
||||
|
||||
Then open e.g. `http://localhost:5000/` with a `Host` header set to an allowed host (e.g. `autoconfig.mifi.holdings`), or use a hosts file and `Host: autoconfig.mifi.holdings`. The app binds to all interfaces; behind a proxy it expects `X-Forwarded-For` and `X-Forwarded-Proto` from a trusted proxy (see [Security](#security)).
|
||||
|
||||
## Configuration
|
||||
|
||||
- **Allowed hosts** — Edit `ALLOWED_HOSTS` in `app.py` and add corresponding Traefik labels in `docker-compose.yml` for each host.
|
||||
- **Mail server** — IMAP/SMTP hostnames and ports are in the Jinja2 templates under `templates/` (`config-v1.1.xml.j2`, `Autodiscover.xml.j2`). Change those to point to your mail server(s).
|
||||
- **Rate limits** — Defaults are in `app.py` (e.g. 50 req/h for `/`, 20 req/h for config endpoints). Adjust the `@rate_limit` decorator arguments if needed.
|
||||
- **Traefik** — Ensure Traefik sets the real client IP (e.g. overwrites or sets `X-Forwarded-For`) so rate limiting and logging use the correct IP.
|
||||
|
||||
## Security
|
||||
|
||||
The service is hardened for production: host validation, domain sanitization, rate limiting, request size limits, security headers (CSP, HSTS, etc.), and a read-only, non-root container with minimal capabilities. Details and audit history are in:
|
||||
|
||||
- [docs/SECURITY.md](docs/SECURITY.md) — Hardening summary and deployment notes
|
||||
- [docs/SECURITY-AUDIT.md](docs/SECURITY-AUDIT.md) — Full audit and remediation log
|
||||
|
||||
**Important:** Only attach trusted containers to the same Docker network as this service. Ensure the reverse proxy overwrites `X-Forwarded-For` with the real client IP.
|
||||
|
||||
## CI/CD (Woodpecker)
|
||||
|
||||
Pipelines are in [.woodpecker/](.woodpecker/):
|
||||
|
||||
- **build** — On push to `main`: build and push Docker image to `git.mifi.dev/mifi-holdings/mail-autoconfig`, then notify.
|
||||
- **production** — On deployment to `production`: trigger Portainer stack webhook, then notify.
|
||||
|
||||
See [docs/CI-CD.md](docs/CI-CD.md) for Woodpecker setup and migration from Drone.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
.
|
||||
├── app.py # Flask app, routes, security (host check, rate limit, sanitization)
|
||||
├── templates/ # Jinja2 templates for Thunderbird and Outlook XML
|
||||
│ ├── config-v1.1.xml.j2
|
||||
│ └── Autodiscover.xml.j2
|
||||
├── requirements.txt # Pinned Python dependencies
|
||||
├── Dockerfile # Multi-stage not used; single stage, non-root user
|
||||
├── docker-compose.yml # Service + Traefik labels and security options
|
||||
├── .woodpecker/ # Woodpecker CI workflows
|
||||
└── docs/
|
||||
├── CI-CD.md # CI/CD setup and migration
|
||||
├── SECURITY.md # Security summary
|
||||
└── SECURITY-AUDIT.md
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Not specified in the repo; treat as proprietary unless stated otherwise.
|
||||
|
||||
BIN
__pycache__/app.cpython-310.pyc
Normal file
BIN
__pycache__/app.cpython-310.pyc
Normal file
Binary file not shown.
81
app.py
81
app.py
@@ -1,15 +1,26 @@
|
||||
from flask import Flask, request, Response, jsonify
|
||||
import jinja2
|
||||
import re
|
||||
import html
|
||||
import time
|
||||
from functools import wraps
|
||||
from collections import defaultdict, deque
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
app = Flask(__name__)
|
||||
# Never run in debug in production
|
||||
app.config['DEBUG'] = False
|
||||
app.config['TESTING'] = False
|
||||
# Trust one proxy (e.g. Traefik); ensure proxy overwrites X-Forwarded-For with real client IP
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
|
||||
# Reject request bodies larger than this (bytes); protects when Content-Length is missing/wrong
|
||||
app.config['MAX_CONTENT_LENGTH'] = 4096
|
||||
# Only allow safe HTTP methods (block TRACE, CONNECT, etc.)
|
||||
ALLOWED_HTTP_METHODS = {'GET', 'POST', 'HEAD', 'OPTIONS'}
|
||||
|
||||
env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader('templates'),
|
||||
autoescape=jinja2.select_autoescape(['xml'])
|
||||
# Escape all template output (XML); .j2 extension may not match select_autoescape(['xml'])
|
||||
autoescape=True,
|
||||
)
|
||||
|
||||
# Security configuration
|
||||
@@ -38,30 +49,39 @@ rate_limit_storage = defaultdict(deque)
|
||||
RATE_LIMIT_REQUESTS = 100 # requests per window
|
||||
RATE_LIMIT_WINDOW = 3600 # 1 hour window
|
||||
|
||||
def _prune_stale_rate_limits():
|
||||
"""Remove IPs with no requests in the current window to bound memory (M4)."""
|
||||
if len(rate_limit_storage) <= 1000:
|
||||
return
|
||||
current_time = time.time()
|
||||
stale = [
|
||||
ip for ip, timestamps in rate_limit_storage.items()
|
||||
if not timestamps or timestamps[-1] < current_time - RATE_LIMIT_WINDOW
|
||||
]
|
||||
for ip in stale:
|
||||
del rate_limit_storage[ip]
|
||||
|
||||
|
||||
def rate_limit(max_requests=RATE_LIMIT_REQUESTS, window=RATE_LIMIT_WINDOW):
|
||||
"""Simple rate limiting decorator"""
|
||||
"""Simple rate limiting decorator. Uses request.remote_addr (set by ProxyFix from trusted proxy)."""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Get client IP
|
||||
client_ip = request.headers.get('X-Forwarded-For', request.remote_addr)
|
||||
if client_ip:
|
||||
client_ip = client_ip.split(',')[0].strip()
|
||||
|
||||
client_ip = request.remote_addr or 'unknown'
|
||||
current_time = time.time()
|
||||
|
||||
|
||||
_prune_stale_rate_limits()
|
||||
|
||||
# Clean old requests outside the window
|
||||
while (rate_limit_storage[client_ip] and
|
||||
while (rate_limit_storage[client_ip] and
|
||||
rate_limit_storage[client_ip][0] < current_time - window):
|
||||
rate_limit_storage[client_ip].popleft()
|
||||
|
||||
|
||||
# Check if limit exceeded
|
||||
if len(rate_limit_storage[client_ip]) >= max_requests:
|
||||
return jsonify({'error': 'Rate limit exceeded'}), 429
|
||||
|
||||
# Add current request
|
||||
|
||||
rate_limit_storage[client_ip].append(current_time)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
@@ -83,12 +103,14 @@ def validate_host(f):
|
||||
return decorated_function
|
||||
|
||||
def validate_request_size(max_size=1024):
|
||||
"""Decorator to validate request size"""
|
||||
"""Decorator to validate request size. POST without Content-Length is rejected (H2)."""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
content_length = request.content_length
|
||||
if content_length and content_length > max_size:
|
||||
if request.method == 'POST' and content_length is None:
|
||||
return jsonify({'error': 'Content-Length required for POST'}), 411
|
||||
if content_length is not None and content_length > max_size:
|
||||
return jsonify({'error': 'Request too large'}), 413
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
@@ -101,6 +123,9 @@ def add_security_headers(response):
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
response.headers['Content-Security-Policy'] = "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
||||
response.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
|
||||
# HSTS: enforce HTTPS (Traefik terminates TLS)
|
||||
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains; preload'
|
||||
return response
|
||||
|
||||
def sanitize_domain(domain):
|
||||
@@ -115,6 +140,29 @@ def sanitize_domain(domain):
|
||||
return None
|
||||
return sanitized
|
||||
|
||||
# Reject unsafe HTTP methods before any route logic
|
||||
@app.before_request
|
||||
def reject_unsafe_methods():
|
||||
if request.method not in ALLOWED_HTTP_METHODS:
|
||||
return jsonify({'error': 'Method not allowed'}), 405
|
||||
|
||||
|
||||
# Safe error handlers: no stack traces or internal details
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(e):
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@app.errorhandler(413)
|
||||
def request_entity_too_large(e):
|
||||
return jsonify({'error': 'Request too large'}), 413
|
||||
|
||||
|
||||
# Register security headers middleware
|
||||
@app.after_request
|
||||
def after_request(response):
|
||||
@@ -282,6 +330,7 @@ def index():
|
||||
|
||||
@app.route('/ping')
|
||||
@rate_limit(max_requests=10, window=60) # 10 requests per minute for health check
|
||||
@validate_host
|
||||
def ping():
|
||||
return "✅ Mail Autoconfig Service is running."
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SECURITY: Only attach trusted containers to the traefik network.
|
||||
# This service is reachable only by Traefik (and other containers on traefik).
|
||||
# Do not add untrusted or third-party containers to the traefik network.
|
||||
services:
|
||||
mail-autoconfig:
|
||||
image: git.mifi.dev/mifi-holdings/mail-autoconfig:latest
|
||||
@@ -6,9 +9,12 @@ services:
|
||||
# Security configurations
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
# Isolate from host: no privileged mode, no host network, no host mounts
|
||||
# Limit resources to prevent resource exhaustion attacks
|
||||
deploy:
|
||||
resources:
|
||||
@@ -20,16 +26,16 @@ services:
|
||||
cpus: '0.25'
|
||||
# Update healthcheck to use new port
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/ping')"]
|
||||
test: ["CMD", "python", "-c", "import urllib.request; r = urllib.request.Request('http://localhost:8080/ping', headers={'Host': 'autoconfig.mifi.holdings'}); urllib.request.urlopen(r)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- traefik
|
||||
- marina-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.docker.network=marina-net"
|
||||
|
||||
# mifi.holdings
|
||||
- "traefik.http.routers.mailconfig-mifi-holdings.rule=Host(`autoconfig.mifi.holdings`) || Host(`autodiscover.mifi.holdings`)"
|
||||
@@ -37,7 +43,7 @@ services:
|
||||
- "traefik.http.routers.mailconfig-mifi-holdings.tls=true"
|
||||
- "traefik.http.routers.mailconfig-mifi-holdings.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.mailconfig-mifi-holdings.service=mailconfig-mifi-holdings"
|
||||
- "traefik.http.services.mailconfig-mifi-holdings.loadbalancer.server.port=808080"
|
||||
- "traefik.http.services.mailconfig-mifi-holdings.loadbalancer.server.port=8080"
|
||||
|
||||
# mifi.com.br
|
||||
- "traefik.http.routers.mailconfig-mifi-com-br.rule=Host(`autoconfig.mifi.com.br`) || Host(`autodiscover.mifi.com.br`)"
|
||||
@@ -45,7 +51,7 @@ services:
|
||||
- "traefik.http.routers.mailconfig-mifi-com-br.tls=true"
|
||||
- "traefik.http.routers.mailconfig-mifi-com-br.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.mailconfig-mifi-com-br.service=mailconfig-mifi-com-br"
|
||||
- "traefik.http.services.mailconfig-mifi-com-br.loadbalancer.server.port=808080"
|
||||
- "traefik.http.services.mailconfig-mifi-com-br.loadbalancer.server.port=8080"
|
||||
|
||||
# mifi.dev
|
||||
- "traefik.http.routers.mailconfig-mifi-dev.rule=Host(`autoconfig.mifi.dev`) || Host(`autodiscover.mifi.dev`)"
|
||||
@@ -53,7 +59,7 @@ services:
|
||||
- "traefik.http.routers.mailconfig-mifi-dev.tls=true"
|
||||
- "traefik.http.routers.mailconfig-mifi-dev.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.mailconfig-mifi-dev.service=mailconfig-mifi-dev"
|
||||
- "traefik.http.services.mailconfig-mifi-dev.loadbalancer.server.port=808080"
|
||||
- "traefik.http.services.mailconfig-mifi-dev.loadbalancer.server.port=8080"
|
||||
|
||||
# mifi.ventures
|
||||
- "traefik.http.routers.mailconfig-mifi-ventures.rule=Host(`autoconfig.mifi.ventures`) || Host(`autodiscover.mifi.ventures`)"
|
||||
@@ -61,7 +67,7 @@ services:
|
||||
- "traefik.http.routers.mailconfig-mifi-ventures.tls=true"
|
||||
- "traefik.http.routers.mailconfig-mifi-ventures.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.mailconfig-mifi-ventures.service=mailconfig-mifi-ventures"
|
||||
- "traefik.http.services.mailconfig-mifi-ventures.loadbalancer.server.port=808080"
|
||||
- "traefik.http.services.mailconfig-mifi-ventures.loadbalancer.server.port=8080"
|
||||
|
||||
# mifi.vix.br
|
||||
- "traefik.http.routers.mailconfig-mifi-vix-br.rule=Host(`autoconfig.mifi.vix.br`) || Host(`autodiscover.mifi.vix.br`)"
|
||||
@@ -69,7 +75,7 @@ services:
|
||||
- "traefik.http.routers.mailconfig-mifi-vix-br.tls=true"
|
||||
- "traefik.http.routers.mailconfig-mifi-vix-br.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.mailconfig-mifi-vix-br.service=mailconfig-mifi-vix-br"
|
||||
- "traefik.http.services.mailconfig-mifi-vix-br.loadbalancer.server.port=808080"
|
||||
- "traefik.http.services.mailconfig-mifi-vix-br.loadbalancer.server.port=8080"
|
||||
|
||||
# mifi.me
|
||||
- "traefik.http.routers.mailconfig-mifi-me.rule=Host(`autoconfig.mifi.me`) || Host(`autodiscover.mifi.me`)"
|
||||
@@ -77,7 +83,7 @@ services:
|
||||
- "traefik.http.routers.mailconfig-mifi-me.tls=true"
|
||||
- "traefik.http.routers.mailconfig-mifi-me.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.mailconfig-mifi-me.service=mailconfig-mifi-me"
|
||||
- "traefik.http.services.mailconfig-mifi-me.loadbalancer.server.port=808080"
|
||||
- "traefik.http.services.mailconfig-mifi-me.loadbalancer.server.port=8080"
|
||||
|
||||
# blackice.vix.br
|
||||
- "traefik.http.routers.mailconfig-blackice-vix-br.rule=Host(`autoconfig.blackice.vix.br`) || Host(`autodiscover.blackice.vix.br`)"
|
||||
@@ -160,5 +166,5 @@ services:
|
||||
- "traefik.http.services.mailconfig-dining-it-com.loadbalancer.server.port=8080"
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
marina-net:
|
||||
external: true
|
||||
|
||||
39
docs/CI-CD.md
Normal file
39
docs/CI-CD.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# CI/CD — Woodpecker
|
||||
|
||||
This repo uses [Woodpecker CI](https://woodpecker-ci.org/) for build and deploy. Pipelines were migrated from Drone.
|
||||
|
||||
## Workflows
|
||||
|
||||
| Workflow | Trigger | Description |
|
||||
|------------|----------------------|-------------|
|
||||
| **build** | Push to `main` | Build and push Docker image to `git.mifi.dev/mifi-holdings/mail-autoconfig` (with `auto_tag`), then send status to lab webhook. |
|
||||
| **production** | Deployment to `production` | No clone; POST to Portainer stack webhook, then send status to lab webhook. |
|
||||
|
||||
Workflows are defined in [.woodpecker/build.yaml](../.woodpecker/build.yaml) and [.woodpecker/production.yaml](../.woodpecker/production.yaml).
|
||||
|
||||
## Woodpecker setup
|
||||
|
||||
1. **Add the repo** in Woodpecker and point it at your forge (Gitea/GitHub/etc.).
|
||||
|
||||
2. **Secrets** (repository secrets):
|
||||
- **Build workflow:** `gitea_registry_username`, `gitea_package_token` — Registry credentials for `git.mifi.dev`.
|
||||
- **Production workflow:** `portainer_webhook_url` — Portainer stack webhook URL (do not commit; anyone with this URL can trigger a redeploy). `discord_webhook_url` — Discord incoming webhook for deploy notifications.
|
||||
|
||||
3. **Trusted project**: In the repo’s Woodpecker project settings, enable **Trusted** so the Docker build step can run with `privileged: true`.
|
||||
|
||||
4. **Production deploy**: To deploy to production, trigger a **deployment** in Woodpecker with target **production** (e.g. from the pipeline UI after a successful build).
|
||||
|
||||
## Migration from Drone
|
||||
|
||||
| Drone | Woodpecker |
|
||||
|------------------------------|------------|
|
||||
| `.drone.yml` (two pipelines) | `.woodpecker/build.yaml` + `.woodpecker/production.yaml` |
|
||||
| `plugins/docker` | `woodpeckerci/plugin-docker-buildx` |
|
||||
| `plugins/webhook` | `curlimages/curl` with inline commands |
|
||||
| `promote` → target `production` | Deployment event with `CI_PIPELINE_DEPLOY_TARGET == "production"` |
|
||||
| `from_secret: gitea_package_token` | Same secret name in Woodpecker; add `gitea_registry_username` for registry login |
|
||||
|
||||
- **Build pipeline**: Runs on push to `main`; builds and pushes the image with `auto_tag` (e.g. `latest`, branch, semver on tags).
|
||||
- **Production pipeline**: No clone; runs when you trigger a deployment to **production**; calls Portainer webhook and lab webhook.
|
||||
|
||||
You can remove [.drone.yml](../.drone.yml) after switching to Woodpecker, or keep it for reference.
|
||||
236
docs/SECURITY-AUDIT.md
Normal file
236
docs/SECURITY-AUDIT.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# Security Audit Report — Mail Autoconfig
|
||||
|
||||
**Audit date:** 2025-02-01
|
||||
**Scope:** Full application and Docker deployment hardening; prevention of host/container compromise.
|
||||
|
||||
**Re-audit (Linode / past compromise):** 2025-02-01 — Additional hardening applied after reported server takeovers; dependency CVEs and bind-address issues addressed.
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The application already has solid security measures (host validation, domain sanitization, rate limiting, non-root container, read-only filesystem). This audit identified **one critical deployment bug**, several **medium-priority** hardening gaps, and **low-priority** improvements. Addressing the critical and medium items will significantly reduce risk of abuse and container/host compromise.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
### C1. Docker Compose — Wrong Backend Port (Traefik)
|
||||
|
||||
**Location:** `docker-compose.yml`
|
||||
**Issue:** Several Traefik service labels use `loadbalancer.server.port=808080` instead of `8080`. The app listens on `127.0.0.1:8080` (Dockerfile). With port `808080`, Traefik cannot reach the app; those hosts will get connection errors.
|
||||
|
||||
**Affected labels (port 808080):**
|
||||
- mailconfig-mifi-holdings
|
||||
- mailconfig-mifi-com-br
|
||||
- mailconfig-mifi-dev
|
||||
- mailconfig-mifi-ventures
|
||||
- mailconfig-mifi-vix-br
|
||||
- mailconfig-mifi-me
|
||||
|
||||
**Impact:** Service broken for those domains; no direct security exploit, but must be fixed for correct and consistent deployment.
|
||||
|
||||
**Remediation:** Change all `server.port=808080` to `server.port=8080` in `docker-compose.yml`.
|
||||
|
||||
---
|
||||
|
||||
## High
|
||||
|
||||
### H1. X-Forwarded-For Spoofing / Rate Limit Bypass
|
||||
|
||||
**Location:** `app.py` — `rate_limit()`
|
||||
**Issue:** Client IP is taken from `X-Forwarded-For` (first value). If Traefik does not overwrite this header with the real client IP, any client can send arbitrary `X-Forwarded-For` values. That allows:
|
||||
- Bypassing rate limits by rotating spoofed IPs.
|
||||
- Concentrating rate-limit “cost” on a victim IP (abuse/DoS).
|
||||
|
||||
**Remediation:**
|
||||
1. Prefer **one** trusted proxy header (e.g. `X-Real-IP` or the rightmost/last `X-Forwarded-For` after your proxy) and configure Traefik to set it from the real client.
|
||||
2. If you must use `X-Forwarded-For`, ensure Traefik overwrites it (or appends the real client) and that the app only trusts one proxy hop (e.g. via Werkzeug `ProxyFix` with `x_for=1`), then use `request.remote_addr` for rate limiting so the app uses the proxy’s notion of client IP, not raw headers.
|
||||
3. Document the chosen header and proxy configuration so future changes don’t re-open spoofing.
|
||||
|
||||
### H2. Request Body Size When Content-Length Is Missing
|
||||
|
||||
**Location:** `app.py` — `validate_request_size()`
|
||||
**Issue:** Only `request.content_length` is checked. If the client omits `Content-Length` (or uses chunked encoding), the check is skipped. A malicious client could send a very large body and increase memory/CPU usage (DoS).
|
||||
|
||||
**Remediation:**
|
||||
- For POST (e.g. Autodiscover): require `Content-Length` and reject (e.g. 411 or 400) when missing, or enforce a hard body read limit in WSGI/Gunicorn.
|
||||
- Optionally use a small `max_content_length` on the Flask app and/or Gunicorn body size limits so oversized bodies are rejected even when `Content-Length` is missing or wrong.
|
||||
|
||||
### H3. Jinja2 Autoescape May Not Apply to `.j2` Templates
|
||||
|
||||
**Location:** `app.py` — `jinja2.Environment(autoescape=jinja2.select_autoescape(['xml']))`
|
||||
**Issue:** `select_autoescape(['xml'])` typically matches template **filename extension**. Templates are named `config-v1.1.xml.j2` and `Autodiscover.xml.j2`; the extension used by Jinja2 may be `.j2`, not `.xml`, so XML autoescape might not be enabled. Output is then safe only because `sanitize_domain()` restricts to `[a-zA-Z0-9.-]`.
|
||||
|
||||
**Remediation:** Rely on explicit escaping for defense in depth: use `autoescape=True` for this environment, or a custom predicate that returns `True` for these template names (e.g. names containing `xml` or ending in `.xml.j2`). Ensures XML-sensitive characters are escaped if sanitization is ever relaxed or bypassed.
|
||||
|
||||
---
|
||||
|
||||
## Medium
|
||||
|
||||
### M1. /ping Endpoint Does Not Validate Host
|
||||
|
||||
**Location:** `app.py` — `ping()`
|
||||
**Issue:** `/ping` has no `@validate_host`. Any `Host` can be used. Impact is low (only reveals that the service is up), but it weakens defense in depth and allows probing behind the proxy.
|
||||
|
||||
**Remediation:** Add `@validate_host` to `/ping`, or restrict `/ping` to internal use (e.g. only when request comes from loopback or from Traefik health checks) and document that it must not be exposed to the internet.
|
||||
|
||||
### M2. No Explicit Capability Dropping in Container
|
||||
|
||||
**Location:** `docker-compose.yml`
|
||||
**Issue:** `security_opt: no-new-privileges:true` is set, but capabilities are not explicitly dropped. Default Docker capabilities (e.g. NET_RAW, SETPCAP) are still available; dropping unneeded ones reduces attack surface.
|
||||
|
||||
**Remediation:** Add:
|
||||
```yaml
|
||||
cap_drop:
|
||||
- ALL
|
||||
```
|
||||
If the process or healthcheck needs specific capabilities (unlikely for this app), add them back with `cap_add` only as needed.
|
||||
|
||||
### M3. Dependency Versions Not Pinned
|
||||
|
||||
**Location:** `Dockerfile` — `pip install Flask Jinja2 gunicorn`
|
||||
**Issue:** No `requirements.txt` with versions. Builds can pull different versions over time, leading to supply-chain and compatibility risk and making security patches harder to track.
|
||||
|
||||
**Remediation:** Add `requirements.txt` with pinned versions (e.g. `Flask==3.x.x`, `Jinja2==3.x.x`, `gunicorn==21.x.x`), use `pip install -r requirements.txt` in the Dockerfile, and review/update versions in a controlled way.
|
||||
|
||||
### M4. Rate Limit Storage Unbounded Growth
|
||||
|
||||
**Location:** `app.py` — `defaultdict(deque)`
|
||||
**Issue:** Old entries are removed per-IP when that IP makes a new request, but IPs that stop requesting are never cleaned. Under heavy scanning (many distinct IPs), memory can grow without bound.
|
||||
|
||||
**Remediation:** Use a TTL-based structure (e.g. Redis with expiry), or a background task / periodic cleanup that drops keys older than `RATE_LIMIT_WINDOW`. For in-memory, a single global cleanup on each request (e.g. prune keys whose last request is older than the window) limits growth.
|
||||
|
||||
---
|
||||
|
||||
## Low
|
||||
|
||||
### L1. Unused Import
|
||||
|
||||
**Location:** `app.py` — `import html`
|
||||
**Issue:** `html` is never used. Minor hygiene and static-analysis noise.
|
||||
|
||||
**Remediation:** Remove `import html`.
|
||||
|
||||
### L2. Host Header in HTML Links
|
||||
|
||||
**Location:** `app.py` — `index()`
|
||||
**Status:** `host` is taken from the request but only after `@validate_host`, so it is one of `ALLOWED_HOSTS`. Using it in links is safe. No change required; noted for completeness.
|
||||
|
||||
### L3. Flask Secret Key
|
||||
|
||||
**Location:** `app.py`
|
||||
**Status:** No sessions or signed cookies are used. No `SECRET_KEY` is required for current functionality. If you add session or cookie-based features later, set a strong `app.secret_key` (from env, not hardcoded).
|
||||
|
||||
---
|
||||
|
||||
## Remediation Plan
|
||||
|
||||
| Priority | ID | Action | Status |
|
||||
|----------|------|--------|--------|
|
||||
| Critical | C1 | Fix `docker-compose.yml`: change all `server.port=808080` to `8080`. | **Done** |
|
||||
| High | H1 | Use trusted proxy (ProxyFix) and rate-limit by `request.remote_addr`; document proxy config. | **Done** |
|
||||
| High | H2 | Enforce body size: require Content-Length for POST; set `MAX_CONTENT_LENGTH`. | **Done** |
|
||||
| High | H3 | Set Jinja2 `autoescape=True` so XML templates are always escaped. | **Done** |
|
||||
| Medium | M1 | Add `@validate_host` to `/ping`; healthcheck must send allowed Host. | **Done** |
|
||||
| Medium | M2 | Add `cap_drop: [ALL]` in `docker-compose.yml`. | **Done** |
|
||||
| Medium | M3 | Add `requirements.txt` with pinned versions; use it in Dockerfile. | **Done** |
|
||||
| Medium | M4 | Add periodic cleanup of stale rate-limit keys when storage > 1000. | **Done** |
|
||||
| Low | L1 | Remove unused `import html`. | **Done** |
|
||||
| Optional | O1 | Gunicorn `--limit-request-line` / `--limit-request-fields`. | **Done** |
|
||||
| Optional | O2 | Add `Permissions-Policy` header. | **Done** |
|
||||
|
||||
**Deployment note (H1):** Ensure Traefik overwrites `X-Forwarded-For` with the real client IP (or prepends it). With `ProxyFix(x_for=1)`, the app trusts the leftmost value as the client IP; if the proxy does not set it, clients could spoof it.
|
||||
|
||||
---
|
||||
|
||||
## Already Strong
|
||||
|
||||
- **Host validation:** Strict allowlist and `@validate_host` on sensitive routes.
|
||||
- **Domain sanitization:** `sanitize_domain()` restricts to `[a-zA-Z0-9.-]` and length; used before any template render.
|
||||
- **Request size limits:** Per-route limits (with the Content-Length gap noted above).
|
||||
- **Security headers:** X-Content-Type-Options, X-Frame-Options, CSP, etc.
|
||||
- **Container:** Non-root user, bind to 127.0.0.1, read-only filesystem, tmpfs for /tmp, resource limits, `no-new-privileges`.
|
||||
- **No user-controlled paths or commands:** No path traversal or command injection surfaces found.
|
||||
|
||||
Implementing the critical and high items, then the medium items, will bring the service to a hardened state suitable for production and minimize risk to the host the Docker container runs on.
|
||||
|
||||
---
|
||||
|
||||
## Verification (Current Codebase)
|
||||
|
||||
A follow-up review confirms the following in the current implementation:
|
||||
|
||||
| ID | Finding | Verified |
|
||||
|----|---------|----------|
|
||||
| C1 | All Traefik `loadbalancer.server.port` values are `8080` in `docker-compose.yml`. | ✅ |
|
||||
| H1 | `ProxyFix(app.wsgi_app, x_for=1, x_proto=1)` and rate limit uses `request.remote_addr`. | ✅ |
|
||||
| H2 | POST without `Content-Length` returns 411; `MAX_CONTENT_LENGTH = 4096` set. | ✅ |
|
||||
| H3 | Jinja2 `Environment(autoescape=True)`. | ✅ |
|
||||
| M1 | `/ping` has `@validate_host`. | ✅ |
|
||||
| M2 | `cap_drop: [ALL]` in `docker-compose.yml`. | ✅ |
|
||||
| M3 | `requirements.txt` with pinned Flask, Jinja2, gunicorn; Dockerfile uses `pip install -r requirements.txt`. | ✅ |
|
||||
| M4 | `_prune_stale_rate_limits()` when storage > 1000; called on each request. | ✅ |
|
||||
| L1 | No `import html` in `app.py`. | ✅ |
|
||||
|
||||
No path traversal, command execution, `send_file`, or user-controlled file/path usage. Autodiscover POST body is never read or parsed, so malicious XML does not reach the app. Domain and Host are either allowlisted or sanitized before use in templates or HTML.
|
||||
|
||||
---
|
||||
|
||||
## Container Escape / Host Compromise Resistance
|
||||
|
||||
The deployment is structured to limit impact of a compromised app or container:
|
||||
|
||||
1. **Non-root**: Process runs as `appuser`; no root inside container.
|
||||
2. **Capabilities**: `cap_drop: ALL`; no `CAP_SYS_ADMIN`, `CAP_NET_RAW`, etc., reducing kernel-level escape options.
|
||||
3. **Privilege escalation**: `no-new-privileges:true` prevents gaining new privileges via setuid or similar.
|
||||
4. **Filesystem**: `read_only: true` with `tmpfs` only for `/tmp`; no persistent writable host paths; no volume mounts from host.
|
||||
5. **Network**: App binds to `127.0.0.1:8080`; only Traefik on the same Docker network can reach it; no `network_mode: host`.
|
||||
6. **Resources**: Memory and CPU limits reduce resource-exhaustion and some DoS impact.
|
||||
7. **No host access**: No Docker socket, no host PID/filesystem mounts, no `privileged` mode.
|
||||
|
||||
An attacker who gains code execution in the app can only affect the container and its tmpfs; they cannot access the host filesystem, other containers, or the host network without a separate host or orchestration vulnerability.
|
||||
|
||||
---
|
||||
|
||||
## Additional Optional Hardening
|
||||
|
||||
| ID | Suggestion | Priority | Status |
|
||||
|----|------------|----------|--------|
|
||||
| O1 | **Gunicorn request limits** | Low | **Done** — `--limit-request-line 4094` and `--limit-request-fields 100` in Dockerfile CMD. |
|
||||
| O2 | **Permissions-Policy header** | Low | **Done** — Added in `add_security_headers()`. |
|
||||
| O3 | **Dependency updates** | Operational | Run `pip-audit` or Dependabot periodically; refresh pinned versions in `requirements.txt` after testing. |
|
||||
|
||||
O1 and O2 are implemented; O3 is an ongoing operational practice.
|
||||
|
||||
---
|
||||
|
||||
## Linode / Past Compromise — Additional Hardening (2025-02-01)
|
||||
|
||||
After reported takeovers of the Linode server, a second pass identified and fixed the following.
|
||||
|
||||
### Critical Fixes Applied
|
||||
|
||||
| ID | Issue | Fix |
|
||||
|----|--------|-----|
|
||||
| **L1** | **Bind 127.0.0.1 made app unreachable by Traefik** | Traefik runs in a *separate* container. Binding to `127.0.0.1:8080` inside this container means only processes *inside this container* can connect; Traefik cannot. Service was either broken or was previously bound to `0.0.0.0`. **Fix:** Bind to `0.0.0.0:8080`. Exposure is limited to the Docker network only (Traefik and any other container on that network). The host and internet cannot connect directly to this port. |
|
||||
| **L2** | **Jinja2 3.1.4 had known CVEs** | CVE-2024-56201 (sandbox breakout), CVE-2024-56326, CVE-2025-27516 (|attr filter). Even though this app only passes sanitized `DOMAIN` and fixed template names, defense in depth requires a patched library. **Fix:** Upgraded to Jinja2 3.1.6 in `requirements.txt`. |
|
||||
| **L3** | **No HSTS** | Browsers could be downgraded to HTTP without a strict directive. **Fix:** Added `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload` in `add_security_headers()`. |
|
||||
| **L4** | **Unsafe HTTP methods not rejected** | TRACE, CONNECT, etc. could be used for probing or abuse. **Fix:** `@app.before_request` rejects any method not in `GET, POST, HEAD, OPTIONS` with 405. |
|
||||
| **L5** | **Default error pages could leak info** | Flask’s default 404/500 might expose stack traces or paths in some configs. **Fix:** Custom `@app.errorhandler(404|500|413)` returning minimal JSON only. |
|
||||
| **L6** | **Explicit DEBUG/TESTING off** | Ensures debug mode is never enabled by env or mistake. **Fix:** `app.config['DEBUG'] = False` and `app.config['TESTING'] = False`. |
|
||||
| **L7** | **Gunicorn worker temp dir** | With read-only rootfs, workers need a writable temp dir. **Fix:** `--worker-tmp-dir /tmp` in Dockerfile CMD (tmpfs). |
|
||||
| **L8** | **Network isolation documentation** | Untrusted containers on the same Docker network could reach this service. **Fix:** Comment in `docker-compose.yml`: only attach trusted containers to the `traefik` network. |
|
||||
|
||||
### If Your Linode Was Compromised — Checklist
|
||||
|
||||
1. **Assume full compromise:** Rotate all secrets (SSH keys, API keys, DB passwords, Traefik certs, any env vars). Revoke and reissue.
|
||||
2. **Reimage the host** if you cannot guarantee persistence was removed (kernel modules, cron, authorized_keys, other backdoors).
|
||||
3. **Audit every service on the host:** SSH (disable password auth, use keys only), Traefik, Docker socket access, any other containers or processes. Ensure no container runs `privileged` or mounts Docker socket unless strictly required.
|
||||
4. **Firewall:** On the Linode, allow only required ports (e.g. 22, 80, 443). Block direct access to Docker daemon and to backend ports from the internet.
|
||||
5. **Docker network:** Only attach trusted containers (e.g. Traefik) to the `traefik` network. Do not run untrusted or third-party containers on that network.
|
||||
6. **Dependencies:** Run `pip-audit` (or equivalent) in CI and after any `requirements.txt` change. Rebuild and redeploy this image after upgrading Jinja2 and any other pinned deps.
|
||||
7. **This image:** Rebuild from current Dockerfile and `requirements.txt` (Jinja2 3.1.6, bind 0.0.0.0, all hardening above). Do not reuse old images that may have had vulnerable Jinja2 or different config.
|
||||
8. **Traefik:** Ensure it overwrites/sets `X-Forwarded-For` (or equivalent) with the real client IP so this app’s rate limiting and logging see the correct IP.
|
||||
|
||||
Implementing the critical and high items, then the medium items, and the Linode-specific fixes above, brings the service to a fully hardened state and reduces risk of host takeover from this container.
|
||||
@@ -96,6 +96,10 @@
|
||||
- Log any invalid domain attempts
|
||||
- Monitor resource usage within limits
|
||||
|
||||
## Security Audit (2025-02-01)
|
||||
|
||||
A full security audit was performed; findings and remediations are documented in **SECURITY-AUDIT.md**. Critical and high-priority items have been addressed in code and deployment. **Important:** Ensure your reverse proxy (e.g. Traefik) overwrites `X-Forwarded-For` with the real client IP so rate limiting and logging use the correct client.
|
||||
|
||||
## Additional Security Considerations
|
||||
|
||||
For production deployment, consider:
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
# Pinned versions for reproducible builds and security (M3)
|
||||
# Jinja2 3.1.6+ required: 3.1.4 had CVE-2024-56201, CVE-2024-56326, CVE-2025-27516
|
||||
Flask==3.0.3
|
||||
Jinja2==3.1.6
|
||||
gunicorn==23.0.0
|
||||
Reference in New Issue
Block a user