diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6b7c257 --- /dev/null +++ b/Makefile @@ -0,0 +1,85 @@ +# FlaskPaste Development Makefile +# Usage: make + +.PHONY: help install dev lint format security test check clean + +PYTHON := python3 +VENV := ./venv +PIP := $(VENV)/bin/pip +PYTEST := $(VENV)/bin/pytest +RUFF := $(VENV)/bin/ruff +MYPY := $(VENV)/bin/mypy +BANDIT := $(VENV)/bin/bandit +PIP_AUDIT := $(VENV)/bin/pip-audit + +# Default target +help: + @echo "FlaskPaste Development Commands" + @echo "────────────────────────────────" + @echo " make install Install production dependencies" + @echo " make dev Install dev dependencies" + @echo " make lint Run ruff linter" + @echo " make format Format code with ruff" + @echo " make types Run mypy type checker" + @echo " make security Run bandit + pip-audit" + @echo " make test Run pytest" + @echo " make check Run all checks (lint + security + test)" + @echo " make clean Remove cache files" + +# Setup +install: + $(PIP) install -r requirements.txt + +dev: + $(PIP) install -r requirements.txt -r requirements-dev.txt + +# Code quality +lint: + $(RUFF) check app/ tests/ fpaste + +format: + $(RUFF) format app/ tests/ fpaste + $(RUFF) check --fix app/ tests/ fpaste + +types: + $(MYPY) app/ --ignore-missing-imports + +# Security +security: security-code security-deps + +security-code: + @echo "── Bandit (code security) ──" + $(BANDIT) -r app/ -ll -q + +security-deps: + @echo "── pip-audit (dependency vulnerabilities) ──" + $(PIP_AUDIT) --strict --progress-spinner=off || true + +# Testing +test: + $(PYTEST) tests/ -v --tb=short + +test-cov: + $(PYTEST) tests/ -v --tb=short --cov=app --cov-report=term-missing + +# Combined checks +check: lint types security test + @echo "" + @echo "✓ All checks passed" + +# CI target (non-interactive, strict) +ci: + $(RUFF) check app/ tests/ fpaste + $(MYPY) app/ --ignore-missing-imports + $(BANDIT) -r app/ -ll -q + $(PIP_AUDIT) --strict --progress-spinner=off + $(PYTEST) tests/ -v --tb=short + +# Cleanup +clean: + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + find . -type d -name .pytest_cache -exec rm -rf {} + 2>/dev/null || true + find . -type d -name .mypy_cache -exec rm -rf {} + 2>/dev/null || true + find . -type d -name .ruff_cache -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete 2>/dev/null || true + rm -rf .coverage htmlcov/ 2>/dev/null || true diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..85ff1bb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,265 @@ +# Security + +FlaskPaste is designed with security as a primary concern. This document describes security features, best practices, and responsible disclosure procedures. + +## Security Features + +### Authentication + +**Client Certificate Authentication (mTLS)** + +FlaskPaste supports mutual TLS authentication via reverse proxy: + +1. Reverse proxy terminates TLS and validates client certificates +2. Proxy extracts SHA1 fingerprint and passes via `X-SSL-Client-SHA1` header +3. FlaskPaste uses fingerprint for ownership tracking and authorization + +```nginx +# nginx example +location / { + proxy_pass http://127.0.0.1:5000; + proxy_set_header X-SSL-Client-SHA1 $ssl_client_fingerprint; +} +``` + +**Proxy Trust Validation** + +Defense-in-depth against header spoofing: + +```bash +export FLASKPASTE_PROXY_SECRET="shared-secret" +``` + +The proxy must send `X-Proxy-Secret` header; requests without valid secret are treated as anonymous. + +### Password Protection + +Pastes can be password-protected using PBKDF2-HMAC-SHA256: + +- 600,000 iterations (OWASP 2023 recommendation) +- 32-byte random salt per password +- Constant-time comparison prevents timing attacks +- Passwords never logged or stored in plaintext + +```bash +# Create protected paste +curl -H "X-Paste-Password: secret" --data-binary @file.txt https://paste.example.com/ + +# Access protected paste +curl -H "X-Paste-Password: secret" https://paste.example.com/abc123/raw +``` + +### End-to-End Encryption + +CLI supports client-side AES-256-GCM encryption: + +- Key generated locally, never sent to server +- Key appended to URL as fragment (`#key`) which browsers never transmit +- Server stores only opaque ciphertext +- Zero-knowledge: server cannot read content + +```bash +./fpaste create -e secret.txt +# Returns: https://paste.example.com/abc123# +``` + +### Abuse Prevention + +**Content-Hash Deduplication** + +Prevents spam flooding by throttling repeated identical submissions: + +```bash +FLASKPASTE_DEDUP_WINDOW=3600 # 1 hour window +FLASKPASTE_DEDUP_MAX=3 # Max 3 identical submissions +``` + +**Proof-of-Work** + +Computational puzzle prevents automated spam: + +```bash +FLASKPASTE_POW_DIFFICULTY=20 # Leading zero bits required +FLASKPASTE_POW_TTL=300 # Challenge validity (seconds) +``` + +**Entropy Enforcement** + +Optional minimum entropy requirement to enforce encrypted uploads: + +```bash +FLASKPASTE_MIN_ENTROPY=6.0 # Bits per byte (encrypted ~7.5-8.0) +FLASKPASTE_MIN_ENTROPY_SIZE=256 # Only check content >= this size +``` + +### Security Headers + +All responses include: + +| Header | Value | +|--------|-------| +| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | +| `X-Content-Type-Options` | `nosniff` | +| `X-Frame-Options` | `DENY` | +| `X-XSS-Protection` | `1; mode=block` | +| `Content-Security-Policy` | `default-src 'none'` | +| `Referrer-Policy` | `no-referrer` | +| `Permissions-Policy` | `geolocation=(), microphone=(), camera=()` | +| `Cache-Control` | `no-store, no-cache, must-revalidate` | +| `Pragma` | `no-cache` | + +### Request Tracing + +All requests receive `X-Request-ID` header for log correlation and debugging. Pass your own ID to trace requests through reverse proxies. + +## Input Validation + +### Paste IDs + +- Hexadecimal only (`[a-f0-9]+`) +- Configurable length (default 12 characters) +- Validated on all endpoints + +### MIME Types + +- Magic byte detection for binary formats +- Sanitized against injection +- Only safe characters allowed: `[a-z0-9!#$&\-^_.+]` + +### Size Limits + +```bash +FLASKPASTE_MAX_ANON=3145728 # 3 MiB for anonymous +FLASKPASTE_MAX_AUTH=52428800 # 50 MiB for authenticated +``` + +### Password Limits + +- Maximum 1024 characters (prevents DoS via hashing) +- Unicode supported +- Special characters allowed + +## SQL Injection Prevention + +All database queries use parameterized statements: + +```python +# Correct - parameterized +db.execute("SELECT * FROM pastes WHERE id = ?", (paste_id,)) + +# Never - string concatenation +db.execute(f"SELECT * FROM pastes WHERE id = '{paste_id}'") +``` + +## Database Security + +- SQLite with WAL mode for better concurrency +- Foreign keys enforced +- Automatic cleanup of expired content +- No sensitive data in plaintext (passwords are hashed) + +## Deployment Recommendations + +### Reverse Proxy + +Always deploy behind a TLS-terminating reverse proxy: + +``` +┌─────────┐ TLS ┌─────────┐ HTTP ┌───────────┐ +│ Client │ ──────────── │ nginx │ ──────────── │ FlaskPaste│ +│ │ mTLS opt │ HAProxy │ local │ │ +└─────────┘ └─────────┘ └───────────┘ +``` + +### Container Security + +```dockerfile +# Run as non-root +USER app + +# Read-only filesystem where possible +# Mount /app/data as writable volume +``` + +### Network Isolation + +- FlaskPaste should only listen on localhost or internal network +- Reverse proxy handles external traffic +- Use firewall rules to restrict direct access + +### Secrets Management + +```bash +# Use strong, unique secrets +FLASKPASTE_PROXY_SECRET="$(openssl rand -hex 32)" +FLASKPASTE_POW_SECRET="$(openssl rand -hex 32)" +``` + +## Security Checklist + +### Before Deployment + +- [ ] Deploy behind TLS-terminating reverse proxy +- [ ] Configure `FLASKPASTE_PROXY_SECRET` if using auth headers +- [ ] Enable proof-of-work if public-facing +- [ ] Set appropriate size limits +- [ ] Configure paste expiry + +### Ongoing + +- [ ] Monitor logs for suspicious activity +- [ ] Keep dependencies updated +- [ ] Review access patterns +- [ ] Rotate secrets periodically +- [ ] Test backup/restore procedures + +## Known Limitations + +1. **No rate limiting per IP** - Delegated to reverse proxy +2. **No user accounts** - PKI handles identity +3. **No audit log** - Standard request logging only +4. **Single-node only** - SQLite limits horizontal scaling + +## Reporting Vulnerabilities + +If you discover a security vulnerability: + +1. **Do not** open a public issue +2. Email security concerns to the repository maintainer +3. Include: + - Description of the vulnerability + - Steps to reproduce + - Potential impact + - Suggested fix (if any) + +We aim to acknowledge reports within 48 hours and provide a fix timeline within 7 days. + +## Security Updates + +Security fixes are released as soon as possible. Subscribe to repository releases for notifications. + +## Threat Model + +### In Scope + +- Unauthorized access to pastes +- Content injection attacks +- Authentication bypass +- Information disclosure +- Denial of service (application-level) + +### Out of Scope + +- Physical access to server +- Compromise of reverse proxy +- Client-side vulnerabilities +- Network-level DoS +- Social engineering + +## Version History + +| Version | Security Changes | +|---------|------------------| +| 1.2.0 | Password protection with PBKDF2, code modernization | +| 1.1.0 | E2E encryption, entropy enforcement, burn-after-read | +| 1.0.0 | Initial release with core security features | diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f01e4d8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,117 @@ +[project] +name = "flaskpaste" +version = "1.2.0" +description = "Secure pastebin with mTLS authentication and E2E encryption" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "flask>=3.0", + "cryptography>=42.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-cov>=4.1", + "ruff>=0.8", + "mypy>=1.8", + "bandit>=1.7", + "pip-audit>=2.6", +] + +# ───────────────────────────────────────────────────────────────────────────── +# Ruff - Fast Python linter and formatter +# ───────────────────────────────────────────────────────────────────────────── +[tool.ruff] +target-version = "py311" +line-length = 100 +exclude = [ + ".git", + ".venv", + "venv", + "__pycache__", + "*.egg-info", +] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "S", # flake8-bandit (security) + "UP", # pyupgrade + "SIM", # flake8-simplify + "TCH", # type-checking imports + "RUF", # ruff-specific +] +ignore = [ + "S101", # assert allowed in tests + "S311", # pseudo-random ok for non-crypto + "B008", # function call in default arg (Flask patterns) +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101", "S105", "S106"] # Allow asserts and hardcoded passwords in tests +"fpaste" = ["S603", "S607"] # Subprocess calls ok in CLI +"app/config.py" = ["S105"] # Test config has hardcoded passwords + +[tool.ruff.lint.isort] +known-first-party = ["app"] + +# ───────────────────────────────────────────────────────────────────────────── +# Mypy - Static type checking +# ───────────────────────────────────────────────────────────────────────────── +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_ignores = true +warn_redundant_casts = true +warn_unused_configs = true +show_error_codes = true +pretty = true + +# Strict mode components (enable incrementally) +check_untyped_defs = true +disallow_untyped_defs = false # Enable later for stricter checking +disallow_incomplete_defs = true + +[[tool.mypy.overrides]] +module = [ + "flask.*", + "cryptography.*", + "werkzeug.*", +] +ignore_missing_imports = true + +# ───────────────────────────────────────────────────────────────────────────── +# Bandit - Security linter +# ───────────────────────────────────────────────────────────────────────────── +[tool.bandit] +exclude_dirs = ["tests", "venv", ".venv"] +skips = ["B101"] # assert_used - ok in production for invariants + +# ───────────────────────────────────────────────────────────────────────────── +# Pytest +# ───────────────────────────────────────────────────────────────────────────── +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = "test_*.py" +python_functions = "test_*" +addopts = "-v --tb=short" + +# ───────────────────────────────────────────────────────────────────────────── +# Coverage +# ───────────────────────────────────────────────────────────────────────────── +[tool.coverage.run] +source = ["app"] +branch = true +omit = ["tests/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", +] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..1c455a3 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,17 @@ +# Development and CI dependencies +# Install: pip install -r requirements-dev.txt + +# Testing +pytest>=8.0 +pytest-cov>=4.1 + +# Code quality +ruff>=0.8 +mypy>=1.8 + +# Security analysis +bandit>=1.7 +pip-audit>=2.6 + +# Type stubs +types-requests>=2.31