Skip to content
41 changes: 40 additions & 1 deletion otterwiki/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import sys
import logging

from flask import Flask
from flask import Flask, request
from flask_mail import Mail
from flask_sqlalchemy import SQLAlchemy
from flask_wtf.csrf import CSRFProtect
Expand Down Expand Up @@ -78,6 +78,10 @@
SECURITY_HEADERS=True,
WTF_CSRF_ENABLED=True,
WTF_CSRF_TIME_LIMIT=86400,
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)

Expand All @@ -100,6 +104,14 @@
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"])
Expand Down Expand Up @@ -222,6 +234,33 @@ 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),
)


@app.errorhandler(429)
def ratelimit_handler(e):
from flask import render_template
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 (
render_template("lost_password.html", title="Lost password"),
429,
)
return render_template("login.html", title="Login"), 429


#
# a renderer configured with the app.config
Expand Down
28 changes: 27 additions & 1 deletion otterwiki/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -357,7 +357,28 @@ 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(
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":
Expand Down Expand Up @@ -390,6 +411,11 @@ def logout():


@app.route("/-/lost_password", methods=["POST", "GET"])
@limiter.limit(
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":
return otterwiki.auth.lost_password_form()
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies = [
"pluggy==1.5.0",
"regex==2026.2.28",
"feedgen==1.0.0",
"Flask-Limiter==4.1.1",
"flask-wtf==1.2.2",
]
keywords = ["wiki", "git", "markdown"]
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,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",
]
Expand Down
82 changes: 82 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -1167,3 +1167,85 @@ 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 with error message."""
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
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 with error message."""
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
assert b"Too many attempts" in result.data


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"