flaskpaste: initial commit with security hardening
Features: - REST API for text/binary pastes with MIME detection - Client certificate auth via X-SSL-Client-SHA1 header - SQLite with WAL mode for concurrent access - Automatic paste expiry with LRU cleanup Security: - HSTS, CSP, X-Frame-Options, X-Content-Type-Options - Cache-Control: no-store for sensitive responses - X-Request-ID tracing for log correlation - X-Proxy-Secret validation for defense-in-depth - Parameterized queries, input validation - Size limits (3 MiB anon, 50 MiB auth) Includes /health endpoint, container support, and 70 tests.
This commit is contained in:
96
app/database.py
Normal file
96
app/database.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Database connection and schema management."""
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from flask import current_app, g
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS pastes (
|
||||
id TEXT PRIMARY KEY,
|
||||
content BLOB NOT NULL,
|
||||
mime_type TEXT NOT NULL DEFAULT 'text/plain',
|
||||
owner TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_accessed INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pastes_created_at ON pastes(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_pastes_owner ON pastes(owner);
|
||||
CREATE INDEX IF NOT EXISTS idx_pastes_last_accessed ON pastes(last_accessed);
|
||||
"""
|
||||
|
||||
# Hold reference for in-memory shared cache databases
|
||||
_memory_db_holder = None
|
||||
|
||||
|
||||
def _get_connection_string(db_path) -> tuple[str, dict]:
|
||||
"""Get connection string and kwargs for sqlite3.connect."""
|
||||
if isinstance(db_path, Path):
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return str(db_path), {}
|
||||
if db_path == ":memory:":
|
||||
return "file::memory:?cache=shared", {"uri": True}
|
||||
return db_path, {}
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
"""Get database connection for current request context."""
|
||||
if "db" not in g:
|
||||
db_path = current_app.config["DATABASE"]
|
||||
conn_str, kwargs = _get_connection_string(db_path)
|
||||
g.db = sqlite3.connect(conn_str, **kwargs)
|
||||
g.db.row_factory = sqlite3.Row
|
||||
g.db.execute("PRAGMA foreign_keys = ON")
|
||||
if isinstance(db_path, Path):
|
||||
g.db.execute("PRAGMA journal_mode = WAL")
|
||||
return g.db
|
||||
|
||||
|
||||
def close_db(exception=None) -> None:
|
||||
"""Close database connection at end of request."""
|
||||
db = g.pop("db", None)
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Initialize database schema."""
|
||||
global _memory_db_holder
|
||||
|
||||
db_path = current_app.config["DATABASE"]
|
||||
conn_str, kwargs = _get_connection_string(db_path)
|
||||
|
||||
# For in-memory databases, keep a connection alive
|
||||
if db_path == ":memory:":
|
||||
_memory_db_holder = sqlite3.connect(conn_str, **kwargs)
|
||||
_memory_db_holder.executescript(SCHEMA)
|
||||
_memory_db_holder.commit()
|
||||
else:
|
||||
db = get_db()
|
||||
db.executescript(SCHEMA)
|
||||
db.commit()
|
||||
|
||||
|
||||
def cleanup_expired_pastes() -> int:
|
||||
"""Delete pastes that haven't been accessed within expiry period.
|
||||
|
||||
Returns number of deleted pastes.
|
||||
"""
|
||||
expiry_seconds = current_app.config["PASTE_EXPIRY_SECONDS"]
|
||||
cutoff = int(time.time()) - expiry_seconds
|
||||
|
||||
db = get_db()
|
||||
cursor = db.execute("DELETE FROM pastes WHERE last_accessed < ?", (cutoff,))
|
||||
db.commit()
|
||||
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
def init_app(app) -> None:
|
||||
"""Register database functions with Flask app."""
|
||||
app.teardown_appcontext(close_db)
|
||||
|
||||
with app.app_context():
|
||||
init_db()
|
||||
Reference in New Issue
Block a user