add tiered auto-expiry based on auth level
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
24
app/pki.py
24
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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user