July 4 - Update security
This commit is contained in:
@@ -267,6 +267,7 @@ def create_app(config_name='default'):
|
||||
# obvious XSS vectors without breaking Bootstrap CDN / Google Fonts.
|
||||
@app.after_request
|
||||
def set_security_headers(response):
|
||||
from flask import request as _request
|
||||
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
|
||||
response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN')
|
||||
response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||
@@ -279,8 +280,17 @@ def create_app(config_name='default'):
|
||||
"img-src 'self' data: blob: https://maps.gstatic.com https://maps.googleapis.com; "
|
||||
"connect-src 'self' https://cdn.jsdelivr.net; "
|
||||
"frame-src https://maps.google.com https://www.google.com; "
|
||||
# Hardening directives that don't affect existing inline scripts/styles:
|
||||
# block plugins, injected <base> tags, and cross-origin form posts.
|
||||
"object-src 'none'; base-uri 'self'; form-action 'self'; "
|
||||
"frame-ancestors 'none';"
|
||||
)
|
||||
# HSTS — advertise only over HTTPS (Nginx terminates TLS and forwards
|
||||
# X-Forwarded-Proto). includeSubDomains is deliberately OMITTED: a tenant
|
||||
# custom domain may run unrelated subdomains that are not yet HTTPS, and
|
||||
# this header must never force-upgrade one of those.
|
||||
if _request.is_secure or _request.headers.get('X-Forwarded-Proto', '') == 'https':
|
||||
response.headers.setdefault('Strict-Transport-Security', 'max-age=31536000')
|
||||
return response
|
||||
|
||||
# ── Error handler: 413 Request Entity Too Large ───────────────────────
|
||||
|
||||
@@ -23,6 +23,7 @@ from flask import Blueprint, render_template, request, flash, redirect, current_
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, PasswordField, SelectField
|
||||
from wtforms.validators import DataRequired, Email, Length, EqualTo, ValidationError
|
||||
from app.utils.forms import strong_password
|
||||
|
||||
bp = Blueprint('signup', __name__, url_prefix='/signup')
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -92,7 +93,8 @@ class SignupForm(FlaskForm):
|
||||
validators=[DataRequired(), Length(min=2, max=32)])
|
||||
plan = SelectField('Plan', choices=[]) # populated in view
|
||||
password = PasswordField('Password',
|
||||
validators=[DataRequired(), Length(min=8, max=128)])
|
||||
validators=[DataRequired(), Length(max=128),
|
||||
strong_password()])
|
||||
confirm = PasswordField('Confirm Password',
|
||||
validators=[EqualTo('password', 'Passwords must match.')])
|
||||
|
||||
|
||||
+43
-9
@@ -5,9 +5,46 @@ from wtforms import (StringField, PasswordField, SelectField, TextAreaField,
|
||||
RadioField)
|
||||
from wtforms.validators import (DataRequired, Email, Length, EqualTo,
|
||||
Optional, NumberRange, ValidationError)
|
||||
import re as _re
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
# ── Password strength ──────────────────────────────────────────────────────────
|
||||
# A small blocklist of trivially weak passwords, matched case-insensitively.
|
||||
_COMMON_PASSWORDS = {
|
||||
'password', 'password1', 'password123', '12345678', '123456789',
|
||||
'qwerty123', 'qwertyui', '11111111', 'letmein1', 'welcome1', 'welcome123',
|
||||
'iloveyou', 'admin123', 'changeme1', 'passw0rd', 'abc12345', 'football1',
|
||||
}
|
||||
|
||||
|
||||
def strong_password(min_length=8):
|
||||
"""WTForms validator enforcing a baseline password strength.
|
||||
|
||||
Policy (NIST-aligned — length first, light complexity, blocklist):
|
||||
* at least ``min_length`` characters,
|
||||
* at least one letter AND one digit,
|
||||
* not a well-known weak password.
|
||||
|
||||
Skips empty values, so it can sit after ``Optional()`` on edit forms where a
|
||||
blank password means "leave the existing one unchanged".
|
||||
"""
|
||||
def _validator(form, field):
|
||||
pw = field.data or ''
|
||||
if not pw:
|
||||
return
|
||||
if len(pw) < min_length:
|
||||
raise ValidationError(
|
||||
f'Password must be at least {min_length} characters long.')
|
||||
if not (_re.search(r'[A-Za-z]', pw) and _re.search(r'\d', pw)):
|
||||
raise ValidationError(
|
||||
'Password must include at least one letter and one number.')
|
||||
if pw.lower() in _COMMON_PASSWORDS:
|
||||
raise ValidationError(
|
||||
'That password is too common — please choose a less predictable one.')
|
||||
return _validator
|
||||
|
||||
|
||||
# ── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
@@ -21,7 +58,7 @@ class ProfileForm(FlaskForm):
|
||||
full_name = StringField('Full Name', validators=[Optional(), Length(max=150)])
|
||||
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
|
||||
current_password = PasswordField('Current Password', validators=[Optional()])
|
||||
new_password = PasswordField('New Password', validators=[Optional(), Length(min=6, max=100)])
|
||||
new_password = PasswordField('New Password', validators=[Optional(), Length(max=100), strong_password()])
|
||||
confirm_password = PasswordField('Confirm New Password', validators=[EqualTo('new_password', message='Passwords must match.')])
|
||||
|
||||
def __init__(self, user=None, *args, **kwargs):
|
||||
@@ -48,7 +85,7 @@ class UserForm(FlaskForm):
|
||||
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=100)])
|
||||
full_name = StringField('Full Name', validators=[Optional(), Length(max=150)])
|
||||
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
|
||||
password = PasswordField('Password', validators=[Optional(), Length(min=6, max=100)])
|
||||
password = PasswordField('Password', validators=[Optional(), Length(max=100), strong_password()])
|
||||
confirm_password = PasswordField('Confirm Password', validators=[Optional(), EqualTo('password')])
|
||||
role = SelectField('Role', choices=[
|
||||
('admin', 'Administrator'),
|
||||
@@ -67,10 +104,7 @@ class UserForm(FlaskForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.user = user
|
||||
|
||||
def validate_password(self, field):
|
||||
"""Enforce minimum length only when a new password is actually provided."""
|
||||
if field.data and len(field.data) < 6:
|
||||
raise ValidationError('Password must be at least 6 characters.')
|
||||
# Password strength is enforced by the strong_password() field validator.
|
||||
|
||||
def validate_confirm_password(self, field):
|
||||
"""Require confirmation to match only when a new password is provided."""
|
||||
@@ -212,7 +246,7 @@ class CustomerUserForm(FlaskForm):
|
||||
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=100)])
|
||||
full_name = StringField('Full Name', validators=[Optional(), Length(max=150)])
|
||||
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
|
||||
password = PasswordField('Password', validators=[Optional(), Length(min=8)])
|
||||
password = PasswordField('Password', validators=[Optional(), Length(max=128), strong_password()])
|
||||
confirm_password = PasswordField('Confirm Password',
|
||||
validators=[Optional(), EqualTo('password',
|
||||
message='Passwords must match.')])
|
||||
@@ -258,7 +292,7 @@ class ForgotPasswordForm(FlaskForm):
|
||||
|
||||
|
||||
class ResetPasswordForm(FlaskForm):
|
||||
password = PasswordField('New Password', validators=[DataRequired(), Length(min=8, max=100)])
|
||||
password = PasswordField('New Password', validators=[DataRequired(), Length(max=100), strong_password()])
|
||||
confirm_password = PasswordField('Confirm Password', validators=[DataRequired(),
|
||||
EqualTo('password', message='Passwords must match.')])
|
||||
|
||||
@@ -266,7 +300,7 @@ class ResetPasswordForm(FlaskForm):
|
||||
class SetPasswordForm(FlaskForm):
|
||||
"""Public form for customer to choose their username and password via emailed link."""
|
||||
username = StringField('Choose a Username', validators=[DataRequired(), Length(min=3, max=100)])
|
||||
password = PasswordField('Password', validators=[DataRequired(), Length(min=8, max=100)])
|
||||
password = PasswordField('Password', validators=[DataRequired(), Length(max=100), strong_password()])
|
||||
confirm_password = PasswordField('Confirm Password',
|
||||
validators=[DataRequired(),
|
||||
EqualTo('password', message='Passwords must match.')])
|
||||
|
||||
Reference in New Issue
Block a user