Skip to content

Commit

Permalink
Add CSP header and receiver
Browse files Browse the repository at this point in the history
  • Loading branch information
UlrichB22 committed Feb 11, 2025
1 parent 9cddd74 commit 358d5a7
Show file tree
Hide file tree
Showing 6 changed files with 94 additions and 5 deletions.
19 changes: 19 additions & 0 deletions docs/admin/configure.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,25 @@ items with the same names::
return CompositeDicts(ConfigDicts(dicts),
WikiDicts())


Content security policy (CSP)
=============================

MoinMoin offers a basic functionality for setting CSP headers and logging CSP reports
from client browsers. The behavior can be configured with the options
“content_security_policy” and “content_security_policy_report_only”.

If one of these options is set to "", the corresponding header is not set.
In the default configuration, no policy is set or enforced, but a header is added
to report CSP violations in the log. To debug the settings, we recommend using the
developer tools in your browser.

With the option “content_security_policy_limit_per_day”, admins can limit the number
of reports in the log per day to avoid log overflow.

The CSP configuration depends on the individual wiki landscape and the capabilities
of web browsers vary. For details see https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP.

Storage
=======
MoinMoin supports storage backends as different ways of storing wiki items.
Expand Down
7 changes: 5 additions & 2 deletions src/moin/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Copyright: 2002-2011 MoinMoin:ThomasWaldmann
# Copyright: 2008 MoinMoin:FlorianKrupicka
# Copyright: 2010 MoinMoin:DiogenesAugusto
# Copyright: 2023-2024 MoinMoin:UlrichB
# Copyright: 2023-2025 MoinMoin:UlrichB
# License: GNU GPL v2 (or any later version), see LICENSE.txt for details.

"""
Expand Down Expand Up @@ -190,6 +190,9 @@ class ItemNameConverter(PathConverter):
app.jinja_env.loader = ChoiceLoader([FileSystemLoader(app.cfg.template_dirs), app.jinja_env.loader])
app.register_error_handler(403, themed_error)
clock.stop("create_app flask-theme")
# create global counter to limit content security policy reports, prevent spam
app.csp_count = 0
app.csp_last_date = ""
clock.stop("create_app total")
del clock
return app
Expand Down Expand Up @@ -294,7 +297,7 @@ def before_wiki():
"""
Setup environment for wiki requests, start timers.
"""
if request and is_static_content(request.path):
if request and (is_static_content(request.path) or request.path == "+cspreport/log"):
logging.debug(f"skipping before_wiki for {request.path}")
return

Expand Down
9 changes: 7 additions & 2 deletions src/moin/apps/admin/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# Copyright: 2009 MoinMoin:EugeneSyromyatnikov
# Copyright: 2010 MoinMoin:DiogenesAugusto
# Copyright: 2010 MoinMoin:ReimarBauer
# Copyright: 2024 MoinMoin:UlrichB
# Copyright: 2024-2025 MoinMoin:UlrichB
# License: GNU GPL v2 (or any later version), see LICENSE.txt for details.

"""
Expand All @@ -27,7 +27,7 @@
from moin.i18n import _, L_
from moin.themes import render_template, get_editor_info
from moin.apps.admin import admin
from moin.apps.frontend.views import _using_moin_auth
from moin.apps.frontend.views import _using_moin_auth, add_csp_headers
from moin import user
from moin.constants.keys import (
NAME,
Expand Down Expand Up @@ -621,3 +621,8 @@ def modify_acl(item_name):
"error",
)
return redirect(url_for(".item_acl_report"))


@admin.after_request
def add_security_headers(resp):
return add_csp_headers(resp)
50 changes: 49 additions & 1 deletion src/moin/apps/frontend/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# Copyright: 2010 MoinMoin:DiogenesAugusto
# Copyright: 2001 Richard Jones <[email protected]>
# Copyright: 2001 Juergen Hermann <[email protected]>
# Copyright: 2023-2024 MoinMoin:UlrichB
# Copyright: 2023-2025 MoinMoin:UlrichB
# License: GNU GPL v2 (or any later version), see LICENSE.txt for details.

"""
Expand Down Expand Up @@ -293,6 +293,39 @@ def lookup():
return Response(html, status)


@frontend.route("/+cspreport/log", methods=["POST"])
def cspreport():
"""
content security policy report receiver
"""
if request.content_type not in ["application/csp-report"]:
abort(400, f"Invalid content type '{request.content_type}'.")
if not limit_csp_reports():
try:
csp_report = json.loads(request.data.decode("UTF-8"))["csp-report"]
logging.warning(f"{request.remote_addr} {request.content_type}: {csp_report}")
except json.JSONDecodeError as e:
logging.error(f"Got CSP report with invalid JSON syntax: {e}")
return Response("", 204)


def limit_csp_reports():
"""
Check number of reports logged today, if limit is set and reached return True
"""
if app.cfg.content_security_policy_limit_per_day > 0:
app.csp_count += 1
current_date = datetime.now().strftime("%Y%m%d")
if app.csp_last_date != current_date: # reset counter on a new day
app.csp_last_date = current_date
app.csp_count = 1
if app.csp_count == app.cfg.content_security_policy_limit_per_day:
logging.warning("Last csp report today, skipping further reports, limit reached.")
if app.csp_count <= app.cfg.content_security_policy_limit_per_day:
return False
return True


def _compute_item_transclusions(item_name):
"""Compute which items are transcluded into item <item_name>.
Expand Down Expand Up @@ -3225,6 +3258,21 @@ def new():
raise NotImplementedError


@frontend.after_request
def add_security_headers(resp):
return add_csp_headers(resp)


def add_csp_headers(resp):
if app.cfg.content_security_policy:
resp.headers["Content-Security-Policy"] = app.cfg.content_security_policy
if app.cfg.content_security_policy_report_only:
resp.headers["Content-Security-Policy-Report-Only"] = (
f"{app.cfg.content_security_policy_report_only} report-uri {url_for('frontend.cspreport')}; "
)
return resp


@frontend.errorhandler(404)
def page_not_found(e):
return render_template("404.html", path=request.path, item_name=e.description), 404
7 changes: 7 additions & 0 deletions src/moin/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,13 @@ def __init__(self, exprstr):
[],
"List of available content types for new items. Default: [] (all types enabled).",
),
("content_security_policy", "", "Content security policy."),
(
"content_security_policy_report_only",
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self';",
"Content security policy in report-only mode.",
),
("content_security_policy_limit_per_day", 100, "Limit of reports logged per day."),
),
),
}
Expand Down
7 changes: 7 additions & 0 deletions src/moin/config/wikiconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ class Config(DefaultConfig):
# for users who self-register
user_email_verification = False

# content_security_policy = "" # Content security policy, setting will be enforced
# if value is empty, CSP header will not be set at all
# Content security policy in report-only mode, the report_uri directive will be added automatically
# content_security_policy_report_only = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self';"
# see https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP for configuration details
# content_security_policy_limit_per_day = 100 # Limit of reports logged per day, 0 equals unlimited

# Define the super user who will have access to administrative functions like user registration,
# password reset, disabling users, etc.
acl_functions = "YOUR-SUPER-USER-NAME:superuser"
Expand Down

0 comments on commit 358d5a7

Please sign in to comment.