From ac072820e7dfdc24e8fde3c5e90b570efc9d57a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Nov=C3=BD?= Date: Thu, 9 Apr 2026 10:54:09 +0200 Subject: [PATCH 1/7] feat(security): add Flask-Limiter dependency and ProxyFix support --- otterwiki/server.py | 18 ++++++++++++++++++ pyproject.toml | 1 + 2 files changed, 19 insertions(+) diff --git a/otterwiki/server.py b/otterwiki/server.py index 62b41a05..17be8156 100644 --- a/otterwiki/server.py +++ b/otterwiki/server.py @@ -74,6 +74,8 @@ RENDERER_HTML_ALLOWLIST="", ADMIN_USER_EMAIL="", SECURITY_HEADERS=True, + PROXY_FIX_X_FOR=0, + RATELIMIT_ENABLED=True, ) app.config.from_envvar("OTTERWIKI_SETTINGS", silent=True) @@ -96,6 +98,13 @@ else: app.config[key] = os.environ[key] +# ProxyFix for reverse proxy deployments +proxy_fix_x_for = int(app.config.get("PROXY_FIX_X_FOR", 0)) +if proxy_fix_x_for > 0: + from werkzeug.middleware.proxy_fix import ProxyFix + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=proxy_fix_x_for) + app.logger.info(f"server: ProxyFix enabled with x_for={proxy_fix_x_for}") + # configure logging app.logger.setLevel(app.config["LOG_LEVEL"]) logging.getLogger('werkzeug').setLevel(app.config["LOG_LEVEL_WERKZEUG"]) @@ -215,6 +224,15 @@ def update_app_config(): db.create_all() update_app_config() +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +limiter = Limiter( + get_remote_address, + app=app, + storage_uri="memory://", + enabled=app.config.get("RATELIMIT_ENABLED", True), +) + # # a renderer configured with the app.config diff --git a/pyproject.toml b/pyproject.toml index 477b2222..3dfb6e84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "pluggy==1.5.0", "regex==2026.2.28", "feedgen==1.0.0", + "Flask-Limiter>=3.12", ] keywords = ["wiki", "git", "markdown"] classifiers = [ From 94fdcf865cbbd3483c612abddebab1dd3ac226d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Nov=C3=BD?= Date: Thu, 9 Apr 2026 07:25:41 +0000 Subject: [PATCH 2/7] feat(security): add brute-force rate limiting to login and lost_password (decorators + handler) --- otterwiki/server.py | 17 ++++++++++++++++- otterwiki/views.py | 22 +++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/otterwiki/server.py b/otterwiki/server.py index 17be8156..190ff304 100644 --- a/otterwiki/server.py +++ b/otterwiki/server.py @@ -6,7 +6,7 @@ import sys import logging -from flask import Flask +from flask import Flask, request, redirect, url_for from flask_mail import Mail from flask_sqlalchemy import SQLAlchemy @@ -102,6 +102,7 @@ proxy_fix_x_for = int(app.config.get("PROXY_FIX_X_FOR", 0)) if proxy_fix_x_for > 0: from werkzeug.middleware.proxy_fix import ProxyFix + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=proxy_fix_x_for) app.logger.info(f"server: ProxyFix enabled with x_for={proxy_fix_x_for}") @@ -226,6 +227,7 @@ def update_app_config(): from flask_limiter import Limiter from flask_limiter.util import get_remote_address + limiter = Limiter( get_remote_address, app=app, @@ -234,6 +236,19 @@ def update_app_config(): ) +@app.errorhandler(429) +def ratelimit_handler(e): + from otterwiki.helper import toast + + app.logger.warning( + f"Rate limit exceeded: {request.remote_addr} on {request.path}" + ) + toast("Too many attempts. Please try again later.", "error") + if "lost_password" in request.path: + return redirect(url_for("lost_password")), 429 + return redirect(url_for("login")), 429 + + # # a renderer configured with the app.config # diff --git a/otterwiki/views.py b/otterwiki/views.py index fca92185..1073088b 100644 --- a/otterwiki/views.py +++ b/otterwiki/views.py @@ -16,7 +16,7 @@ jsonify, g, ) -from otterwiki.server import app, githttpserver +from otterwiki.server import app, githttpserver, limiter from otterwiki.wiki import ( Page, Changelog, @@ -355,7 +355,24 @@ def create(): # # user login/logout/settings # +def _login_rate_limit_key(): + """Rate limit key combining IP and email for login brute-force protection.""" + email = request.form.get("email", "").lower().strip() + if email: + return f"{request.remote_addr}:{email}" + return request.remote_addr + + +def _lost_password_rate_limit_key(): + """Rate limit key combining IP and email for lost password protection.""" + email = request.form.get("email", "").lower().strip() + if email: + return f"{request.remote_addr}:{email}" + return request.remote_addr + + @app.route("/-/login", methods=["POST", "GET"]) +@limiter.limit("10/minute", key_func=_login_rate_limit_key, methods=["POST"]) def login(): email = request.cookies.get("email") if request.method == "GET": @@ -388,6 +405,9 @@ def logout(): @app.route("/-/lost_password", methods=["POST", "GET"]) +@limiter.limit( + "5/minute", key_func=_lost_password_rate_limit_key, methods=["POST"] +) def lost_password(): if request.method == "GET": return otterwiki.auth.lost_password_form() From 26bf148e0194414ecc547ae6e3237339e9b768fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Nov=C3=BD?= Date: Thu, 9 Apr 2026 07:35:48 +0000 Subject: [PATCH 3/7] feat(security): add brute-force rate limiting tests and conftest fixture update --- tests/conftest.py | 1 + tests/test_auth.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 761689f0..9af1a4bd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,7 @@ def create_app(tmpdir): "SITE_NAME = 'TEST WIKI'\n", "DEBUG = True\n", # enable test and debug settings "TESTING = True\n", + "RATELIMIT_ENABLED = False\n", "MAIL_SUPPRESS_SEND = True\n", "SECRET_KEY = 'Testing Testing Testing'\n", ] diff --git a/tests/test_auth.py b/tests/test_auth.py index 3ac9894f..f43529cc 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1164,3 +1164,83 @@ def test_login_next_full_flow(app_with_user): # step 4: follow redirect to the original page rv = test_client.get(rv.headers["Location"]) assert rv.status_code == 200 + + +# +# rate limiting tests +# +@pytest.fixture +def ratelimit_client(app_with_user): + """Test client with rate limiting enabled for testing rate limit behavior.""" + from otterwiki.server import limiter + + app_with_user.config["RATELIMIT_ENABLED"] = True + old_got_first_request = app_with_user._got_first_request + app_with_user._got_first_request = False + limiter.init_app(app_with_user) + app_with_user._got_first_request = old_got_first_request + limiter.reset() + client = app_with_user.test_client() + yield client + limiter.reset() + limiter.enabled = False + app_with_user.config["RATELIMIT_ENABLED"] = False + app_with_user.config["RATELIMIT_ENABLED"] = False + + +def test_login_ratelimit(ratelimit_client): + """POST /-/login is rate limited to 10/minute; 11th request returns 429.""" + for i in range(10): + result = ratelimit_client.post( + "/-/login", + data={"email": "mail@example.org", "password": "wrongpassword"}, + follow_redirects=False, + ) + assert result.status_code != 429, f"Request {i + 1} was rate limited" + result = ratelimit_client.post( + "/-/login", + data={"email": "mail@example.org", "password": "wrongpassword"}, + follow_redirects=False, + ) + assert result.status_code == 429 + + +def test_lost_password_ratelimit(ratelimit_client): + """POST /-/lost_password is rate limited to 5/minute; 6th request returns 429.""" + for i in range(5): + result = ratelimit_client.post( + "/-/lost_password", + data={"email": "mail@example.org"}, + follow_redirects=False, + ) + assert result.status_code != 429, f"Request {i + 1} was rate limited" + result = ratelimit_client.post( + "/-/lost_password", + data={"email": "mail@example.org"}, + follow_redirects=False, + ) + assert result.status_code == 429 + + +def test_login_get_not_ratelimited(ratelimit_client): + """GET /-/login is never rate limited (only POST is).""" + for i in range(50): + result = ratelimit_client.get("/-/login") + assert result.status_code in ( + 200, + 302, + ), f"GET request {i + 1} returned unexpected status {result.status_code}" + + +def test_ratelimit_disabled_in_tests(app_with_user): + """With RATELIMIT_ENABLED=False (test default), rate limits are not enforced.""" + client = app_with_user.test_client() + for i in range(20): + result = client.post( + "/-/login", + data={"email": "mail@example.org", "password": "wrongpassword"}, + follow_redirects=False, + ) + assert ( + result.status_code != 429 + ), f"Request {i + 1} was rate limited despite RATELIMIT_ENABLED=False" From 16b0b363afccf43c3b908515993a17a5762de3db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Nov=C3=BD?= Date: Thu, 9 Apr 2026 08:00:06 +0000 Subject: [PATCH 4/7] feat(security): make rate limit thresholds configurable via RATELIMIT_LOGIN and RATELIMIT_LOST_PASSWORD --- otterwiki/server.py | 2 ++ otterwiki/views.py | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/otterwiki/server.py b/otterwiki/server.py index 190ff304..1dd04e33 100644 --- a/otterwiki/server.py +++ b/otterwiki/server.py @@ -76,6 +76,8 @@ SECURITY_HEADERS=True, PROXY_FIX_X_FOR=0, RATELIMIT_ENABLED=True, + RATELIMIT_LOGIN="10/minute", + RATELIMIT_LOST_PASSWORD="5/minute", ) app.config.from_envvar("OTTERWIKI_SETTINGS", silent=True) diff --git a/otterwiki/views.py b/otterwiki/views.py index 1073088b..1ebeb84b 100644 --- a/otterwiki/views.py +++ b/otterwiki/views.py @@ -372,7 +372,11 @@ def _lost_password_rate_limit_key(): @app.route("/-/login", methods=["POST", "GET"]) -@limiter.limit("10/minute", key_func=_login_rate_limit_key, methods=["POST"]) +@limiter.limit( + lambda: app.config.get("RATELIMIT_LOGIN", "10/minute"), + key_func=_login_rate_limit_key, + methods=["POST"], +) def login(): email = request.cookies.get("email") if request.method == "GET": @@ -406,7 +410,9 @@ def logout(): @app.route("/-/lost_password", methods=["POST", "GET"]) @limiter.limit( - "5/minute", key_func=_lost_password_rate_limit_key, methods=["POST"] + lambda: app.config.get("RATELIMIT_LOST_PASSWORD", "5/minute"), + key_func=_lost_password_rate_limit_key, + methods=["POST"], ) def lost_password(): if request.method == "GET": From 9f61fdefe1107ea7bafbb4a0459523e6d1c7a46e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Nov=C3=BD?= Date: Thu, 9 Apr 2026 08:47:33 +0000 Subject: [PATCH 5/7] fix(auth): render form directly with 429 status when rate limit is hit --- otterwiki/server.py | 10 +++++++--- tests/test_auth.py | 6 ++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/otterwiki/server.py b/otterwiki/server.py index 1dd04e33..91fd29c1 100644 --- a/otterwiki/server.py +++ b/otterwiki/server.py @@ -6,7 +6,7 @@ import sys import logging -from flask import Flask, request, redirect, url_for +from flask import Flask, request from flask_mail import Mail from flask_sqlalchemy import SQLAlchemy @@ -240,6 +240,7 @@ def update_app_config(): @app.errorhandler(429) def ratelimit_handler(e): + from flask import render_template from otterwiki.helper import toast app.logger.warning( @@ -247,8 +248,11 @@ def ratelimit_handler(e): ) toast("Too many attempts. Please try again later.", "error") if "lost_password" in request.path: - return redirect(url_for("lost_password")), 429 - return redirect(url_for("login")), 429 + return ( + render_template("lost_password.html", title="Lost password"), + 429, + ) + return render_template("login.html", title="Login"), 429 # diff --git a/tests/test_auth.py b/tests/test_auth.py index f43529cc..35ea9283 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1189,7 +1189,7 @@ def ratelimit_client(app_with_user): def test_login_ratelimit(ratelimit_client): - """POST /-/login is rate limited to 10/minute; 11th request returns 429.""" + """POST /-/login is rate limited to 10/minute; 11th request returns 429 with error message.""" for i in range(10): result = ratelimit_client.post( "/-/login", @@ -1203,10 +1203,11 @@ def test_login_ratelimit(ratelimit_client): follow_redirects=False, ) assert result.status_code == 429 + assert b"Too many attempts" in result.data def test_lost_password_ratelimit(ratelimit_client): - """POST /-/lost_password is rate limited to 5/minute; 6th request returns 429.""" + """POST /-/lost_password is rate limited to 5/minute; 6th request returns 429 with error message.""" for i in range(5): result = ratelimit_client.post( "/-/lost_password", @@ -1220,6 +1221,7 @@ def test_lost_password_ratelimit(ratelimit_client): follow_redirects=False, ) assert result.status_code == 429 + assert b"Too many attempts" in result.data def test_login_get_not_ratelimited(ratelimit_client): From 362469bc41afaa4c48da3d66c520899b3fd4c4c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Nov=C3=BD?= Date: Thu, 9 Apr 2026 10:57:21 +0200 Subject: [PATCH 6/7] fix(deps): Lock Flask-Limiter version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e7d13c1d..2dac78fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "pluggy==1.5.0", "regex==2026.2.28", "feedgen==1.0.0", - "Flask-Limiter>=3.12", + "Flask-Limiter==3.12", "flask-wtf==1.2.2", ] keywords = ["wiki", "git", "markdown"] From e2bcd90c9e4bce9e86816c474197c99945f2b264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Nov=C3=BD?= Date: Thu, 9 Apr 2026 12:56:23 +0200 Subject: [PATCH 7/7] chore(deps): bump Flask-Limiter from 3.12 to 4.1.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2dac78fd..ffbebc97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "pluggy==1.5.0", "regex==2026.2.28", "feedgen==1.0.0", - "Flask-Limiter==3.12", + "Flask-Limiter==4.1.1", "flask-wtf==1.2.2", ] keywords = ["wiki", "git", "markdown"]