From e8a99d5bdd883949b04fefc493e976fbfa4df5c6 Mon Sep 17 00:00:00 2001 From: Username Date: Sun, 21 Dec 2025 21:55:30 +0100 Subject: [PATCH] add tiered auto-expiry based on auth level --- app/api/routes.py | 21 +++++++++++++++++++++ app/config.py | 15 +++++++++++---- app/pki.py | 24 ++++++++++++++++++++++++ tests/test_paste_options.py | 18 ++++++++++++------ 4 files changed, 68 insertions(+), 10 deletions(-) diff --git a/app/api/routes.py b/app/api/routes.py index 8e9e934..afdbee1 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -816,6 +816,23 @@ class IndexView(MethodView): burn_header = request.headers.get("X-Burn-After-Read", "").strip().lower() burn_after_read = burn_header in ("true", "1", "yes") + # Determine default expiry based on authentication level + # Anonymous < Untrusted cert < Trusted cert (registered in PKI) + if owner is None: + # Anonymous user + default_expiry = current_app.config.get("EXPIRY_ANON", 86400) + elif trusted_client: + # Trusted certificate (registered in PKI) + from app.pki import is_trusted_certificate + + if is_trusted_certificate(owner): + default_expiry = current_app.config.get("EXPIRY_TRUSTED", 2592000) + else: + default_expiry = current_app.config.get("EXPIRY_UNTRUSTED", 604800) + else: + # Has cert but not trusted + default_expiry = current_app.config.get("EXPIRY_UNTRUSTED", 604800) + expires_at = None expiry_header = request.headers.get("X-Expiry", "").strip() if expiry_header: @@ -829,6 +846,10 @@ class IndexView(MethodView): except ValueError: pass + # Apply default expiry if none specified (0 = no expiry for trusted) + if expires_at is None and default_expiry > 0: + expires_at = int(time.time()) + default_expiry + password_hash = None password_header = request.headers.get("X-Paste-Password", "") if password_header: diff --git a/app/config.py b/app/config.py index 20e8f36..7cdc351 100644 --- a/app/config.py +++ b/app/config.py @@ -22,10 +22,17 @@ class Config: MAX_PASTE_SIZE_AUTH = int(os.environ.get("FLASKPASTE_MAX_AUTH", 50 * 1024 * 1024)) # 50MiB MAX_CONTENT_LENGTH = MAX_PASTE_SIZE_AUTH # Flask request limit - # Paste expiry (default 5 days) - PASTE_EXPIRY_SECONDS = int(os.environ.get("FLASKPASTE_EXPIRY", 5 * 24 * 60 * 60)) - # Maximum custom expiry (default 30 days, 0 = use default expiry as max) - MAX_EXPIRY_SECONDS = int(os.environ.get("FLASKPASTE_MAX_EXPIRY", 30 * 24 * 60 * 60)) + # Paste expiry (tiered by authentication level) + # Anonymous users: shortest expiry (default 1 day) + EXPIRY_ANON = int(os.environ.get("FLASKPASTE_EXPIRY_ANON", 1 * 24 * 60 * 60)) + # Untrusted certs (authenticated but not registered): medium expiry (default 7 days) + EXPIRY_UNTRUSTED = int(os.environ.get("FLASKPASTE_EXPIRY_UNTRUSTED", 7 * 24 * 60 * 60)) + # Trusted certs (registered in PKI): longest expiry (default 30 days, 0 = no expiry) + EXPIRY_TRUSTED = int(os.environ.get("FLASKPASTE_EXPIRY_TRUSTED", 30 * 24 * 60 * 60)) + # Maximum custom expiry (default 90 days, 0 = unlimited for trusted) + MAX_EXPIRY_SECONDS = int(os.environ.get("FLASKPASTE_MAX_EXPIRY", 90 * 24 * 60 * 60)) + # Legacy alias for backwards compatibility + PASTE_EXPIRY_SECONDS = EXPIRY_ANON # Content deduplication / abuse prevention # Throttle repeated submissions of identical content diff --git a/app/pki.py b/app/pki.py index 3330397..e7cb66e 100644 --- a/app/pki.py +++ b/app/pki.py @@ -1061,6 +1061,30 @@ def is_admin_certificate(fingerprint: str) -> bool: return bool(row and row["is_admin"]) +def is_trusted_certificate(fingerprint: str) -> bool: + """Check if a certificate is trusted (registered in PKI system). + + Trusted certificates are those issued by our PKI system and still valid. + External certificates (valid for auth but not issued by us) are not trusted. + + Args: + fingerprint: SHA1 fingerprint of the certificate + + Returns: + True if the certificate is registered and valid in our PKI + """ + from app.database import get_db + + db = get_db() + row = db.execute( + """SELECT status FROM issued_certificates + WHERE fingerprint_sha1 = ? AND status = 'valid'""", + (fingerprint,), + ).fetchone() + + return row is not None + + def revoke_certificate(serial: str) -> bool: """Revoke a certificate by serial number. diff --git a/tests/test_paste_options.py b/tests/test_paste_options.py index 975150f..13cbe72 100644 --- a/tests/test_paste_options.py +++ b/tests/test_paste_options.py @@ -178,8 +178,8 @@ class TestCustomExpiry: data = response.get_json() assert "expires_at" in data - def test_invalid_expiry_ignored(self, client): - """Invalid X-Expiry values should be ignored.""" + def test_invalid_expiry_uses_default(self, client): + """Invalid X-Expiry values should use default expiry.""" for value in ["invalid", "-100", "0", ""]: response = client.post( "/", @@ -188,14 +188,16 @@ class TestCustomExpiry: ) assert response.status_code == 201 data = response.get_json() - assert "expires_at" not in data, f"Should not have expiry for: {value}" + # Default expiry is applied for anonymous users + assert "expires_at" in data, f"Should have default expiry for: {value}" def test_paste_without_custom_expiry(self, client): - """Paste without X-Expiry should not have expires_at.""" + """Paste without X-Expiry should use default expiry based on auth level.""" response = client.post("/", data=b"content") assert response.status_code == 201 data = response.get_json() - assert "expires_at" not in data + # Default expiry is now applied for all users + assert "expires_at" in data class TestExpiryCleanup: @@ -205,7 +207,11 @@ class TestExpiryCleanup: def app(self): """Create app with very short expiry for testing.""" app = create_app("testing") - app.config["PASTE_EXPIRY_SECONDS"] = 1 # 1 second default + # Set tiered expiry to 1 second for all levels + app.config["EXPIRY_ANON"] = 1 + app.config["EXPIRY_UNTRUSTED"] = 1 + app.config["EXPIRY_TRUSTED"] = 1 + app.config["PASTE_EXPIRY_SECONDS"] = 1 # Legacy app.config["MAX_EXPIRY_SECONDS"] = 10 return app