Add per-IP rate limiting to prevent finger-daemon abuse

The app sits behind nginx, which caches 200s for 30s — so repeated lookups
of the same user are cheap. What bypasses the cache is enumeration of distinct
usernames: each is a unique cache key -> miss -> a fresh finger call to the
mammut daemon, all attributed to admin's single IP (so the daemon cannot ban
the real source). The app only ever receives cache misses, so a per-IP limit
here throttles exactly that uncached path without touching the cached hot path.

- Flask-Limiter keyed per client IP: 30/min on the finger lookup endpoints,
  10/min on /api/upload (auth brute-force), 120/min global default. Index and
  the container healthcheck are exempt. All limits env-tunable (RATELIMIT_*).
- ProxyFix(x_for=1): trust nginx's X-Forwarded-For so the real client IP is
  used for keying and logging. Without it the app only saw the Docker bridge
  gateway (172.20.0.1) and every client shared one bucket.
- 429 handler (JSON for /api, HTML 429.html otherwise) and WARNING logging of
  failed/invalid lookups and limit hits, so enumeration is observable.
This commit is contained in:
pmb
2026-06-17 10:23:58 -07:00
parent ad4b9b9af8
commit 3115ac64b2
4 changed files with 109 additions and 6 deletions
+63 -6
View File
@@ -1,6 +1,9 @@
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash
from flask_httpauth import HTTPBasicAuth
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from werkzeug.utils import secure_filename
from werkzeug.middleware.proxy_fix import ProxyFix
import os
import subprocess
import shlex
@@ -10,9 +13,33 @@ from config import Config
app = Flask(__name__)
app.config.from_object(Config)
# Trust the front-end nginx's X-Forwarded-For so the real client IP is used for
# logging and rate limiting. Without this the app only ever sees the Docker
# bridge gateway (172.20.0.1), so every client on the internet would share one
# rate-limit bucket and log line.
app.wsgi_app = ProxyFix(
app.wsgi_app,
x_for=app.config['PROXY_FORWARDED_COUNT'],
x_proto=1,
x_host=1,
)
# Initialize HTTP Basic Auth
auth = HTTPBasicAuth()
# Per-client-IP rate limiting. The app only ever receives nginx cache *misses*
# (upstream caches 200s for 30s), so these limits throttle exactly the uncached
# finger-daemon lookups (e.g. username enumeration) without touching the cached
# hot path that legitimate Fediverse previews ride on.
limiter = Limiter(
key_func=get_remote_address,
app=app,
default_limits=[l.strip() for l in app.config['RATELIMIT_DEFAULT'].split(';') if l.strip()],
storage_uri=app.config['RATELIMIT_STORAGE_URI'],
enabled=app.config['RATELIMIT_ENABLED'],
headers_enabled=True,
)
@auth.verify_password
def verify_password(username, password):
"""Verify basic authentication credentials against multiple users"""
@@ -111,17 +138,19 @@ def run_finger_command(cmd):
return False, f"An error occurred: {str(e)}", True
@app.route('/')
@limiter.exempt
def index():
"""Home page route"""
"""Home page route (exempt from limits; also serves the container healthcheck)"""
return render_template('index.html', title='Home')
@app.route('/finger', methods=['GET', 'POST'])
@limiter.limit(lambda: app.config['RATELIMIT_FINGER'])
def finger():
"""Finger command route"""
result = None
error = None
username = request.args.get('user', '') or request.form.get('username', '')
if request.method == 'POST' or username:
# Sanitize username input
if username:
@@ -134,7 +163,7 @@ def finger():
else:
# Execute finger command without user (show all logged in users)
cmd = ['finger']
if not error:
# Execute the command with robust encoding handling
success, output, is_error = run_finger_command(cmd)
@@ -142,10 +171,16 @@ def finger():
result = output
else:
error = output
app.logger.warning("finger lookup failed for %r from %s: %s",
username, get_remote_address(), output.strip()[:200])
elif username:
app.logger.warning("rejected invalid finger username %r from %s",
username, get_remote_address())
return render_template('finger.html', title='Finger', result=result, error=error, username=username)
@app.route('/finger/<path:username>')
@limiter.limit(lambda: app.config['RATELIMIT_FINGER'])
def finger_direct(username):
"""Direct finger command route with username in URL"""
result = None
@@ -156,21 +191,26 @@ def finger_direct(username):
# Allow alphanumeric characters, dots, hyphens, underscores, and @ symbol for email-style usernames
if not all(c.isalnum() or c in '.-_@' for c in username):
error = "Invalid username format. Only alphanumeric characters, dots, hyphens, underscores, and @ symbol are allowed."
app.logger.warning("rejected invalid finger username %r from %s",
username, get_remote_address())
else:
# Execute finger command with specific user
cmd = ['finger', username]
# Execute the command with robust encoding handling
success, output, is_error = run_finger_command(cmd)
if success:
result = output
else:
error = output
app.logger.warning("finger lookup failed for %r from %s: %s",
username, get_remote_address(), output.strip()[:200])
return render_template('finger.html', title=f'Finger - {username}', result=result, error=error, username=username)
@app.route('/api/finger')
@app.route('/api/finger/<path:username>')
@limiter.limit(lambda: app.config['RATELIMIT_FINGER'])
def api_finger(username=None):
"""JSON API endpoint for finger queries"""
if username is None:
@@ -206,6 +246,7 @@ def api_info():
})
@app.route('/api/upload', methods=['POST'])
@limiter.limit(lambda: app.config['RATELIMIT_UPLOAD'])
@auth.login_required
def upload_file():
"""File upload endpoint with basic authentication and SCP transfer"""
@@ -341,6 +382,22 @@ def upload_file():
'status': 'error'
}), 500
@app.errorhandler(429)
def ratelimit_handler(error):
"""Handle rate-limit (429) responses; JSON for the API, HTML otherwise"""
app.logger.warning(
"Rate limit exceeded for %s on %s (%s)",
get_remote_address(), request.path, error.description,
)
if request.path.startswith('/api/'):
return jsonify({
'error': 'Rate limit exceeded',
'detail': str(error.description),
'status': 'error',
}), 429
return render_template('429.html', title='Too Many Requests',
detail=error.description), 429
@app.errorhandler(404)
def not_found_error(error):
"""Handle 404 errors"""
+19
View File
@@ -29,6 +29,25 @@ class Config:
REMOTE_PRIVATE_KEY = os.environ.get('REMOTE_PRIVATE_KEY') # Path to SSH private key file
SCP_ENABLED = os.environ.get('SCP_ENABLED', 'false').lower() == 'true'
# Rate limiting (Flask-Limiter), keyed per client IP.
# The app only ever receives nginx cache *misses* (upstream caches 200s for
# 30s), so these limits throttle exactly the uncached finger-daemon lookups
# (e.g. username enumeration) without affecting the cached hot path.
# Limit strings are ';'-separated, e.g. "30 per minute;600 per hour".
RATELIMIT_ENABLED = os.environ.get('RATELIMIT_ENABLED', 'true').lower() == 'true'
RATELIMIT_STORAGE_URI = os.environ.get('RATELIMIT_STORAGE_URI') or 'memory://'
# Global default applied to every route.
RATELIMIT_DEFAULT = os.environ.get('RATELIMIT_DEFAULT') or '120 per minute;2000 per hour'
# Stricter limit for endpoints that shell out to the finger daemon.
RATELIMIT_FINGER = os.environ.get('RATELIMIT_FINGER') or '30 per minute;600 per hour'
# Limit for the authenticated upload endpoint (anti brute-force / spam).
RATELIMIT_UPLOAD = os.environ.get('RATELIMIT_UPLOAD') or '10 per minute;60 per hour'
# Number of trusted reverse-proxy hops in front of the app. nginx on the
# same host appends one X-Forwarded-For entry, so the default is 1. Without
# this the app only sees the Docker bridge gateway and every client would
# share a single rate-limit bucket.
PROXY_FORWARDED_COUNT = int(os.environ.get('PROXY_FORWARDED_COUNT', 1))
# Basic authentication credentials - multiple users support
# Environment variable format: "user1:pass1,user2:pass2"
BASIC_AUTH_USERS_STR = os.environ.get('BASIC_AUTH_USERS', '')
+1
View File
@@ -6,3 +6,4 @@ itsdangerous==2.1.2
click==8.1.7
blinker==1.6.3
Flask-HTTPAuth==4.8.0
Flask-Limiter==3.5.1
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block content %}
<div class="row">
<div class="col-lg-6 mx-auto text-center">
<div class="card">
<div class="card-body">
<h1 class="display-1 text-muted">429</h1>
<h2 class="mb-3">Too Many Requests</h2>
<p class="lead">
You've sent too many requests in a short period. Please slow
down and try again in a moment.
</p>
{% if detail %}
<p class="text-muted"><small>Limit: {{ detail }}</small></p>
{% endif %}
<hr class="my-4">
<div class="d-grid gap-2 d-md-block">
<a href="{{ url_for('index') }}" class="btn btn-primary">Go Home</a>
<a href="javascript:history.back()" class="btn btn-outline-secondary">Go Back</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}