Skip to content

Commit 67094ac

Browse files
author
CarensirA
committed
Merge remote-tracking branch 'Resource-mgmt-app/master'
2 parents 77f92eb + f371ecc commit 67094ac

7 files changed

Lines changed: 140 additions & 8 deletions

File tree

.github/workflows/security.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,18 @@ jobs:
2121

2222
- name: Audit production dependencies
2323
run: pip-audit -r requirements.txt --strict
24+
25+
bandit:
26+
runs-on: ubuntu-latest
27+
steps:
28+
- uses: actions/checkout@v4
29+
30+
- uses: actions/setup-python@v5
31+
with:
32+
python-version: "3.13"
33+
34+
- name: Install bandit
35+
run: pip install bandit
36+
37+
- name: Run bandit security scan
38+
run: bandit -r app/ -ll -f screen

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,4 @@ nul
3535
/docs/archive/plan-expense-account-links.md
3636
/docs/plan-outstanding-march2026.md
3737
/docs/scaling-and-monitoring.md
38+
/docs/email-queue-design.md

app/__init__.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,72 @@ def add_security_headers(response):
287287

288288
return response
289289

290+
# --- Scanner / bot flood protection ---
291+
# Two layers:
292+
# 1. Block known scanner paths instantly (no DB, no template).
293+
# 2. Track 404-per-IP in memory; auto-block IPs that trip the threshold.
294+
# This prevents scanner floods from exhausting the DB connection pool.
295+
from collections import defaultdict
296+
import time as _time
297+
298+
_SCANNER_EXTENSIONS = ('.env', '.php', '.git/config', '.git/HEAD', '.asp', '.aspx', '.jsp', '.cgi')
299+
_SCANNER_PATHS = {
300+
'/wp-login.php', '/wp-admin', '/administrator', '/xmlrpc.php',
301+
'/wp-content', '/wp-includes', '/.well-known/security.txt',
302+
}
303+
304+
_BLOCK_THRESHOLD = 10 # 404s from one IP before blocking
305+
_BLOCK_WINDOW = 60 # seconds to track 404 counts
306+
_BLOCK_DURATION = 300 # seconds to block an IP after threshold hit
307+
_ip_404_counts: dict[str, list] = defaultdict(list) # IP -> list of timestamps
308+
_ip_blocked_until: dict[str, float] = {} # IP -> unblock time
309+
310+
@app.before_request
311+
def block_scanners():
312+
ip = request.headers.get('X-Forwarded-For', request.remote_addr)
313+
if ip and ',' in ip:
314+
ip = ip.split(',')[0].strip()
315+
now = _time.monotonic()
316+
317+
# Check if IP is currently blocked
318+
if ip in _ip_blocked_until:
319+
if now < _ip_blocked_until[ip]:
320+
return '', 403
321+
else:
322+
del _ip_blocked_until[ip]
323+
_ip_404_counts.pop(ip, None)
324+
325+
# Fast reject: known scanner paths/extensions
326+
path = request.path.rstrip('/')
327+
if (any(path.endswith(ext) for ext in _SCANNER_EXTENSIONS)
328+
or path in _SCANNER_PATHS):
329+
_record_ip_404(ip, now)
330+
return 'Not Found', 404
331+
332+
def _record_ip_404(ip: str, now: float):
333+
"""Track a 404 hit for an IP and block if threshold exceeded."""
334+
hits = _ip_404_counts[ip]
335+
hits.append(now)
336+
# Trim old entries outside the window
337+
cutoff = now - _BLOCK_WINDOW
338+
_ip_404_counts[ip] = [t for t in hits if t > cutoff]
339+
if len(_ip_404_counts[ip]) >= _BLOCK_THRESHOLD:
340+
_ip_blocked_until[ip] = now + _BLOCK_DURATION
341+
current_app.logger.warning(
342+
f"Blocked IP {ip} for {_BLOCK_DURATION}s after {_BLOCK_THRESHOLD} "
343+
f"scanner hits in {_BLOCK_WINDOW}s"
344+
)
345+
346+
@app.after_request
347+
def track_404_ips(response):
348+
"""Track IPs that generate 404s from normal routes too."""
349+
if response.status_code == 404:
350+
ip = request.headers.get('X-Forwarded-For', request.remote_addr)
351+
if ip and ',' in ip:
352+
ip = ip.split(',')[0].strip()
353+
_record_ip_404(ip, _time.monotonic())
354+
return response
355+
290356
# --- Session Management ---
291357
@app.before_request
292358
def make_session_permanent():
@@ -804,6 +870,36 @@ def _get_approval_groups_for_template():
804870

805871
@app.context_processor
806872
def inject_active_user():
873+
# Skip expensive DB queries if we're handling an error response
874+
# (the scanner early-return already catches most, but this handles
875+
# any other error paths that still render templates)
876+
if getattr(g, '_skip_nav_queries', False):
877+
return {
878+
"csp_nonce": getattr(g, 'csp_nonce', ''),
879+
"active_user": None,
880+
"active_user_id": None,
881+
"active_user_roles": [],
882+
"is_super_admin": False,
883+
"beta_testing_mode": False,
884+
"can_override_role": False,
885+
"role_override": None,
886+
"role_override_approval_group_id": None,
887+
"_get_approval_groups": lambda: [],
888+
"dev_login_enabled": app.config.get("DEV_LOGIN_ENABLED", False),
889+
"google_auth_enabled": app.config.get("GOOGLE_AUTH_ENABLED", False),
890+
"keycloak_auth_enabled": app.config.get("KEYCLOAK_AUTH_ENABLED", False),
891+
"auth_provider": app.config.get("AUTH_PROVIDER"),
892+
"is_impersonating": False,
893+
"real_user_id": None,
894+
"env_banner_enabled": app.config.get("ENV_BANNER_ENABLED", False),
895+
"env_banner_message": app.config.get("ENV_BANNER_MESSAGE", ""),
896+
"nav_is_budget_admin": False,
897+
"nav_approval_groups": [],
898+
"nav_event_cycle": None,
899+
"nav_dept_memberships": [],
900+
"nav_div_memberships": [],
901+
}
902+
807903
u = get_active_user()
808904
roles = active_user_roles()
809905
has_super_admin = _has_super_admin_role()
@@ -966,6 +1062,7 @@ def run_bootstrap_once():
9661062

9671063
@app.errorhandler(400)
9681064
def bad_request_error(error):
1065+
g._skip_nav_queries = True
9691066
return render_template('errors/404.html', error=error), 400
9701067

9711068
@app.errorhandler(403)
@@ -997,25 +1094,34 @@ def unauthorized_error(error):
9971094

9981095
@app.errorhandler(404)
9991096
def not_found_error(error):
1097+
g._skip_nav_queries = True
10001098
return render_template('errors/404.html', error=error), 404
10011099

1100+
@app.errorhandler(405)
1101+
def method_not_allowed_error(error):
1102+
g._skip_nav_queries = True
1103+
return render_template('errors/404.html', error=error), 405
1104+
10021105
@app.errorhandler(500)
10031106
def internal_error(error):
10041107
# Rollback any pending database transaction to avoid connection issues
10051108
db.session.rollback()
1109+
g._skip_nav_queries = True
10061110
return render_template('errors/500.html', error=error), 500
10071111

10081112
@app.errorhandler(OperationalError)
10091113
def database_connection_error(error):
10101114
# Database connection issues (connection refused, timeout, etc.)
10111115
db.session.rollback()
1116+
g._skip_nav_queries = True
10121117
app.logger.error(f"Database connection error: {error}")
10131118
return render_template('errors/503.html', error=error if app.debug else None), 503
10141119

10151120
@app.errorhandler(DatabaseError)
10161121
def database_error(error):
10171122
# Other database errors
10181123
db.session.rollback()
1124+
g._skip_nav_queries = True
10191125
app.logger.error(f"Database error: {error}")
10201126
return render_template('errors/500.html', error=error if app.debug else None), 500
10211127

@@ -1024,6 +1130,7 @@ def database_error(error):
10241130
@app.errorhandler(Exception)
10251131
def unhandled_exception(error):
10261132
db.session.rollback()
1133+
g._skip_nav_queries = True
10271134
app.logger.error(f"Unhandled exception: {error}", exc_info=True)
10281135
return render_template('errors/500.html', error=None), 500
10291136

app/routes/admin/email_templates.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ def test_email_template(template_id: int):
231231
context = get_sample_context()
232232

233233
try:
234-
env = Environment(loader=BaseLoader())
234+
env = Environment(loader=BaseLoader(), autoescape=True)
235235
rendered_subject = env.from_string(subject).render(**context)
236236
rendered_body = env.from_string(body_text).render(**context)
237237
except Exception as e:

app/routes/dev.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,7 @@ def db_info():
573573
total_rows = 0
574574
for table_name in sorted(inspector.get_table_names()):
575575
row_count = db.session.execute(
576-
db.text(f"SELECT COUNT(*) FROM {table_name}")
576+
db.text(f"SELECT COUNT(*) FROM {table_name}") # nosec B608
577577
).scalar()
578578
column_count = len(inspector.get_columns(table_name))
579579
tables_overview.append({
@@ -634,7 +634,7 @@ def db_table_detail(table_name: str):
634634
total_rows = 0
635635
for tbl in sorted(all_tables):
636636
row_count = db.session.execute(
637-
db.text(f"SELECT COUNT(*) FROM {tbl}")
637+
db.text(f"SELECT COUNT(*) FROM {tbl}") # nosec B608
638638
).scalar()
639639
column_count = len(inspector.get_columns(tbl))
640640
tables_overview.append({
@@ -672,13 +672,13 @@ def db_table_detail(table_name: str):
672672
column_info.append(col_data)
673673

674674
row_count = db.session.execute(
675-
db.text(f"SELECT COUNT(*) FROM {table_name}")
675+
db.text(f"SELECT COUNT(*) FROM {table_name}") # nosec B608
676676
).scalar()
677677

678678
# Get sample rows (first 10)
679679
try:
680680
sample_rows = db.session.execute(
681-
db.text(f"SELECT * FROM {table_name} LIMIT 10")
681+
db.text(f"SELECT * FROM {table_name} LIMIT 10") # nosec B608
682682
).fetchall()
683683
sample_columns = [col["name"] for col in columns]
684684
except Exception:

app/services/email.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,15 @@ def send_email(
275275
# Use default credential chain (IAM role, env vars, etc.)
276276
client = boto3.client('ses', region_name=region)
277277

278+
# Append standard footer to all emails
279+
footer = (
280+
"\n\n---\n"
281+
"This is an automated message from the MAGFest Budget System "
282+
"\u2014 replies here disappear into the void! "
283+
"For help, reach out on Slack or email accounting@magfest.org."
284+
)
285+
body_text = body_text + footer
286+
278287
# Build email body - support both HTML and plain text
279288
# If body contains HTML tags, send as HTML with plain text fallback
280289
body_content = {}

app/services/email_templates.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ def render_email_template(
130130

131131
try:
132132
# Create a Jinja2 environment for string template rendering
133-
env = Environment(loader=BaseLoader())
133+
env = Environment(loader=BaseLoader(), autoescape=True)
134134

135135
# Render subject
136136
subject_template = env.from_string(template.subject)
@@ -164,7 +164,7 @@ def validate_jinja2_template(template_str: str) -> tuple[bool, str | None]:
164164
- (False, "error description") if invalid
165165
"""
166166
try:
167-
env = Environment(loader=BaseLoader())
167+
env = Environment(loader=BaseLoader(), autoescape=True)
168168
env.parse(template_str)
169169
return True, None
170170
except TemplateSyntaxError as e:
@@ -212,7 +212,7 @@ def preview_template(template: EmailTemplate) -> RenderedEmail | None:
212212
context = get_sample_context()
213213

214214
try:
215-
env = Environment(loader=BaseLoader())
215+
env = Environment(loader=BaseLoader(), autoescape=True)
216216

217217
subject_template = env.from_string(template.subject)
218218
rendered_subject = subject_template.render(**context)

0 commit comments

Comments
 (0)