Skip to content

Commit 7b3127e

Browse files
authored
Merge pull request #3 from cubinet-code/feature/security-improvements-issue-1
Implement CSP nonces and configurable password validation
2 parents 9c2c9e7 + 3288315 commit 7b3127e

File tree

7 files changed

+100
-37
lines changed

7 files changed

+100
-37
lines changed

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ export FORWARDED_ALLOW_IPS="127.0.0.1,10.0.0.1" # Multiple IPs separated by com
247247

248248
**⚠️ IMPORTANT SECURITY CONSIDERATION**
249249

250-
The application uses the `X-Forwarded-For` header to determine client IP addresses, which are used for RADIUS authentication and potentially firewall rules. By default, Gunicorn accepts this header from any source, which can be exploited for IP spoofing attacks.
250+
The application uses the `X-Forwarded-For` header to determine client IP addresses, which are used for RADIUS authentication and potentially firewall rules. By default, Gunicorn only accepts this header from localhost (127.0.0.1,::1) and strips it from other sources for security, but this can be configured.
251251

252252
**Configuration Options:**
253253

@@ -281,22 +281,22 @@ The application includes comprehensive security measures:
281281
**Input Validation:**
282282
- Username length limited to 63 characters (RADIUS standard)
283283
- Password length limited to 128 characters (RADIUS standard)
284-
- Character validation: only alphanumeric, @, ., _, - allowed in usernames
284+
- Character validation: configurable pattern via `RADIUS_CHAR_PATTERN` in config.py
285+
- Default pattern includes: alphanumeric, ! # $ % & ' ( ) * + , - . / : ; = ? @ _ { }
285286
- Returns HTTP 400 Bad Request for invalid inputs instead of 500 errors
286287

287288
**CSRF Protection:**
288289
- All forms protected with CSRF tokens using Flask-WTF
289290
- Prevents Cross-Site Request Forgery attacks
290291
- Automatically enabled in production, disabled in test environment
291292

292-
**Security Headers:**
293-
- Content Security Policy (CSP) to prevent XSS attacks
293+
**Security Headers (via Flask-Talisman):**
294+
- Content Security Policy (CSP) with nonce-based inline script protection
294295
- X-Frame-Options: DENY to prevent clickjacking
295296
- X-Content-Type-Options: nosniff to prevent MIME sniffing
296-
- X-XSS-Protection: 1; mode=block for additional XSS protection
297297
- Referrer-Policy: strict-origin-when-cross-origin
298-
- Permissions-Policy to restrict dangerous features
299298
- HSTS header for HTTPS connections (max-age=31536000; includeSubDomains)
299+
- Removed deprecated X-XSS-Protection header (CSP provides better protection)
300300

301301
**Session Security:**
302302
- Server-side sessions using CacheLib filesystem backend

config.example.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
# with a Session-Timeout(27) or Idle-Timeout(28) attribute
2020
DEFAULT_RADIUS_SESSION_DURATION = 60 * 60 * 4 # 4 hours
2121

22+
# Username character validation pattern for RADIUS compatibility
23+
# Default includes alphanumeric and common symbols safe for Cisco systems
24+
# You can customize this pattern based on your RADIUS server requirements
25+
RADIUS_CHAR_PATTERN = r'^[a-zA-Z0-9!#$%&\'()*+,./:;=?@_{-]+$'
26+
2227
#
2328
# Web Server Parameters
2429
#

portal.py

Lines changed: 25 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from wtforms import StringField, PasswordField, SubmitField, IntegerField
1515
from wtforms.validators import DataRequired, Length, Regexp
1616
from cachelib import FileSystemCache
17+
from flask_talisman import Talisman
1718
from pyrad.client import Client
1819
from pyrad.dictionary import Dictionary
1920
import pyrad.packet
@@ -39,8 +40,8 @@
3940
# Input validation constants
4041
MAX_USERNAME_LENGTH = 63 # RADIUS standard
4142
MAX_PASSWORD_LENGTH = 128 # RADIUS standard
42-
# RADIUS-compatible character pattern (alphanumeric + common symbols)
43-
RADIUS_CHAR_PATTERN = re.compile(r'^[a-zA-Z0-9@._-]+$')
43+
# RADIUS-compatible character pattern (configurable via config.py)
44+
RADIUS_CHAR_PATTERN = re.compile(app.config.get('RADIUS_CHAR_PATTERN', r'^[a-zA-Z0-9@._-]+$'))
4445

4546
# Configure CacheLib session backend (replaces deprecated filesystem backend)
4647
app.config['SESSION_CACHELIB'] = FileSystemCache(cache_dir='flask_session', threshold=500)
@@ -51,6 +52,25 @@
5152
csrf = CSRFProtect(app)
5253
# Initialize the Flask Session
5354
Session(app)
55+
# Initialize Flask-Talisman for security headers including CSP
56+
csp = {
57+
'default-src': "'self'",
58+
'script-src': "'self'",
59+
'style-src': "'self' 'unsafe-inline'", # Bootstrap requires inline styles
60+
'img-src': "'self' data:",
61+
'font-src': "'self'",
62+
'connect-src': "'self'",
63+
'frame-ancestors': "'none'"
64+
}
65+
talisman = Talisman(
66+
app,
67+
content_security_policy=csp,
68+
content_security_policy_nonce_in=['script-src'],
69+
strict_transport_security=True,
70+
strict_transport_security_max_age=31536000,
71+
strict_transport_security_include_subdomains=True,
72+
force_https=False # Allow HTTP for development
73+
)
5474
# Initialize the Background Session Scheduler
5575
background_scheduler = BackgroundScheduler()
5676
background_scheduler.start()
@@ -77,40 +97,15 @@
7797
srv = srv_primary
7898

7999

80-
@app.after_request
81-
def add_security_headers(response):
82-
"""Add security headers to all responses."""
83-
# Content Security Policy
84-
response.headers['Content-Security-Policy'] = (
85-
"default-src 'self'; "
86-
"script-src 'self' 'unsafe-inline'; "
87-
"style-src 'self' 'unsafe-inline'; "
88-
"img-src 'self' data:; "
89-
"font-src 'self'; "
90-
"connect-src 'self'; "
91-
"frame-ancestors 'none'"
92-
)
93-
94-
# Security headers
95-
response.headers['X-Content-Type-Options'] = 'nosniff'
96-
response.headers['X-Frame-Options'] = 'DENY'
97-
response.headers['X-XSS-Protection'] = '1; mode=block'
98-
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
99-
response.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
100-
101-
# HSTS header (only if serving HTTPS)
102-
if request.is_secure:
103-
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
104-
105-
return response
100+
# Security headers are now handled by Flask-Talisman
106101

107102

108103
class LoginForm(FlaskForm):
109104
"""CSRF-protected login form with validation."""
110105
username = StringField('Username', validators=[
111106
DataRequired(),
112107
Length(max=MAX_USERNAME_LENGTH, message=f'Username too long (max {MAX_USERNAME_LENGTH} characters)'),
113-
Regexp(RADIUS_CHAR_PATTERN, message='Username contains invalid characters (only alphanumeric, @, ., _, - allowed)')
108+
Regexp(RADIUS_CHAR_PATTERN, message='Username contains invalid characters')
114109
])
115110
password = PasswordField('Password', validators=[
116111
DataRequired(),
@@ -138,7 +133,7 @@ def validate_input(username, password):
138133
if len(username) > MAX_USERNAME_LENGTH:
139134
errors.append(f"Username too long (max {MAX_USERNAME_LENGTH} characters)")
140135
if not RADIUS_CHAR_PATTERN.match(username):
141-
errors.append("Username contains invalid characters (only alphanumeric, @, ., _, - allowed)")
136+
errors.append("Username contains invalid characters")
142137

143138
# Check password length
144139
if len(password) > MAX_PASSWORD_LENGTH:

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ gunicorn~=23.0.0
77
Flask-WTF~=1.2.1
88
WTForms~=3.2.1
99
cachelib~=0.13.0
10+
flask-talisman~=1.1.0

templates/index.html.jinja

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@
9797

9898
{% block scripts %}
9999
{{ bootstrap.load_js() }}
100-
<script type="text/javascript">var ts = {{ (ts * 1000)|int }};</script>
100+
<script type="text/javascript" nonce="{{ csp_nonce() }}">var ts = {{ (ts * 1000)|int }};</script>
101101
<script type="text/javascript" src="{{ url_for('static',filename='app.js') }}"></script>
102102
{% endblock %}
103103
</main>

tests/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,8 @@ def setup_test_config(monkeypatch):
1919
portal.app.config["DEFAULT_RADIUS_SESSION_DURATION"] = 15
2020
# Disable CSRF for testing
2121
portal.app.config["WTF_CSRF_ENABLED"] = False
22+
# Set expanded character pattern for testing
23+
portal.app.config["RADIUS_CHAR_PATTERN"] = r'^[a-zA-Z0-9!#$%&\'()*+,./:;=?@_{-]+$'
24+
# Re-compile the regex pattern with the new config
25+
import re
26+
portal.RADIUS_CHAR_PATTERN = re.compile(portal.app.config["RADIUS_CHAR_PATTERN"])

tests/test_portal.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,60 @@ def test_portal_session(client):
105105
)
106106

107107
assert session.get("username") is None
108+
109+
110+
def test_csp_nonce_in_response(client):
111+
"""Test that CSP nonce is present in script tags"""
112+
response = client.get("/")
113+
assert response.status_code == 200
114+
115+
# Check that the inline script has a nonce attribute
116+
assert b'nonce="' in response.data
117+
assert b'<script type="text/javascript" nonce="' in response.data
118+
119+
# Extract nonce from HTML
120+
nonce_pattern = rb'<script type="text/javascript" nonce="([^"]+)">'
121+
match = re.search(nonce_pattern, response.data)
122+
assert match is not None
123+
nonce = match.group(1).decode('utf-8')
124+
125+
# Nonce should be non-empty and of reasonable length
126+
assert len(nonce) > 10
127+
128+
129+
def test_security_headers_present(client):
130+
"""Test that Flask-Talisman security headers are present"""
131+
response = client.get("/")
132+
assert response.status_code == 200
133+
134+
# Check CSP header is present and doesn't contain unsafe-inline for scripts
135+
csp_header = response.headers.get('Content-Security-Policy')
136+
assert csp_header is not None
137+
assert 'script-src' in csp_header
138+
assert 'nonce-' in csp_header
139+
# Style-src still needs unsafe-inline for Bootstrap, but script-src should use nonce
140+
assert "script-src 'self' 'nonce-" in csp_header
141+
142+
# Check other security headers (Flask-Talisman defaults)
143+
assert response.headers.get('X-Frame-Options') == 'SAMEORIGIN'
144+
assert response.headers.get('X-Content-Type-Options') == 'nosniff'
145+
146+
# Ensure deprecated X-XSS-Protection header is not present
147+
assert 'X-XSS-Protection' not in response.headers
148+
149+
150+
def test_configurable_pattern_validation():
151+
"""Test that configurable password validation pattern works in validation function"""
152+
import portal
153+
154+
# Test characters that should be allowed with expanded pattern
155+
errors = portal.validate_input("[email protected]", "testpass")
156+
assert "Username contains invalid characters" not in ' '.join(errors)
157+
158+
# Test expanded character set (should be allowed)
159+
errors = portal.validate_input("test+user#1", "testpass")
160+
assert "Username contains invalid characters" not in ' '.join(errors)
161+
162+
# Test characters that should still be rejected (like spaces)
163+
errors = portal.validate_input("test user", "testpass")
164+
assert "Username contains invalid characters" in ' '.join(errors)

0 commit comments

Comments
 (0)