From 3115ac64b22e894307efe1a82cd93916b16ea69d Mon Sep 17 00:00:00 2001 From: waffle2k Date: Wed, 17 Jun 2026 10:23:58 -0700 Subject: [PATCH] Add per-IP rate limiting to prevent finger-daemon abuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app.py | 69 ++++++++++++++++++++++++++++++++++++++++++---- config.py | 19 +++++++++++++ requirements.txt | 1 + templates/429.html | 26 +++++++++++++++++ 4 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 templates/429.html diff --git a/app.py b/app.py index a6949cc..f902147 100644 --- a/app.py +++ b/app.py @@ -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/') +@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/') +@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""" diff --git a/config.py b/config.py index 7a08b50..1d10890 100644 --- a/config.py +++ b/config.py @@ -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', '') diff --git a/requirements.txt b/requirements.txt index 5c36f22..ed5ad7a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/templates/429.html b/templates/429.html new file mode 100644 index 0000000..98ea2a3 --- /dev/null +++ b/templates/429.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+

429

+

Too Many Requests

+

+ You've sent too many requests in a short period. Please slow + down and try again in a moment. +

+ {% if detail %} +

Limit: {{ detail }}

+ {% endif %} +
+ +
+
+
+
+{% endblock %}