@cryptotaxi247 / CoPilot / commits / 526fb2fe

chore: lift bcrypt<5 cap, enforce 72-byte password limit at API layer (#854)

Final cleanup of the auth-deps modernization sequence (#842 → #853). Lifts the bcrypt<5 cap that #851/#853 left in place. The cap was a defensive measure against bcrypt 5's `ValueError on >72 bytes` behaviour change vs. bcrypt 4's silent truncation. Removing the cap requires three coordinated changes so users with unusually long passwords don't suddenly hit a bcrypt-level 500: 1. Schema-layer enforcement (the right place to fail). - UserInput.password max_length 256 → 72 - PasswordReset.new_password max_length 256 → 72 - PasswordResetToken.validate_password 256 → 72 - Password.length le=128 → le=72 - Password.generate rejects length>72 explicitly Long passwords are now rejected at request validation with a 422 ValidationError ("Value is longer than maximum length.") instead of crashing in bcrypt with an opaque 500. 2. Defensive truncation safety net (matches bcrypt 4 silent-truncate behaviour, so passwords hashed under bcrypt 4 still verify). - AuthHandler.get_password_hash and verify_password now slice `password.encode("utf-8")[:72]` before passing to bcrypt. - Prevents bcrypt 5 ValueError on any path that bypasses schema validation (internal callers, future code, edge-case Unicode where char count <72 but byte count >72). - Existing user passwords whose original input was >72 bytes were truncated when first hashed under bcrypt 4; the new code applies the same truncation at verify time, so the same hash matches. 3. Pre-existing bug surfaced by the test scenario, fixed here. `validation_exception_handler` did `ErrorType(error["type"])`, raising ValueError for any pydantic 2 error code the legacy ErrorType enum didn't list. This had been broken since #849 (pydantic 1→2 migration) but no test path was hitting long-string validation. With the new 72-byte cap that path fires. - Handler: try/except, fall back to ErrorType.GENERAL on unknown codes so unknown error types still produce a 422 with a sane message instead of a 400 + "X is not a valid ErrorType". - ErrorType: added the v2 codes that map to existing concepts — string_too_short, string_too_long, string_pattern_mismatch, int_parsing, greater_than{,_equal}, less_than{,_equal}, datetime_parsing, date_parsing, enum, missing. 4. requirements.in: bcrypt<5 → bcrypt; pip-compile resolves to 5.0.0. Verified locally: - bcrypt 5.0.0 installed in the rebuilt image - admin login: HTTP 200, bearer token issued - registering a 104-char password: HTTP 422 with structured response {"error_type":"string_too_long", "message":"Value is longer than maximum length."} - registering a normal 10-char password: HTTP 201 - 2FA setup: HTTP 200 with QR data URI + 8 backup codes - existing-hash verification: implicit in admin login (admin pwd is generated and hashed under the same bcrypt 5 the verify path uses) Roadmap state: with this PR the auth-deps modernization sequence is fully done. The auth/ stack has no unmaintained deps, no log-noise warnings, clear errors for malformed env vars, schema-level rejection of overlong passwords, and bcrypt 5 with proper 72-byte handling. Co-authored-by: taylor_socfortress <taylor.walton@socfortress.co> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

taylorcopilot committed May 8, 2026 at 10:24 UTC 526fb2fe7b1edd5563b66149aa7d109fb80b6a25
6 files changed +65 -18
backend/app/auth/models/users.py
+17 -12
@@ -83,11 +83,14 @@ class RoleEnum(int, Enum):
83
84 class UserInput(SQLModel):
85 username: str
86 + # bcrypt's input is capped at 72 bytes; longer passwords would either be silently
87 + # truncated (bcrypt 4 behaviour) or rejected with ValueError (bcrypt 5+). We enforce
88 + # the limit here so users get a clean 422 ValidationError instead.
89 password: str = Field(
87 - max_length=256,
90 + max_length=72,
91 min_length=8,
92 regex="^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&#]).{8,}$",
90 - description="Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character",
93 + description="Password must be 8-72 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character",
94 )
95 email: EmailStr
96 role_id: RoleEnum = Field(
@@ -110,11 +113,12 @@ class UserLogin(SQLModel):
113
114
115 class Password(BaseModel):
116 + # Capped at 72 — bcrypt's input limit. The generated chars are ASCII so 72 chars = 72 bytes.
117 length: int = Field(
118 default=12,
119 ge=8,
116 - le=128,
117 - description="The length of the password",
120 + le=72,
121 + description="The length of the password (max 72 — bcrypt input limit)",
122 )
123 hashed: str # Holds the hashed password
124 plain: str # Holds the plain password
@@ -122,14 +126,14 @@ class Password(BaseModel):
126 @field_validator("length")
127 @classmethod
128 def validate_length(cls, value):
125 - if value < 8 or value > 128:
126 - raise ValueError("Password length must be between 8 and 128 characters.")
129 + if value < 8 or value > 72:
130 + raise ValueError("Password length must be between 8 and 72 characters.")
131 return value
132
133 @classmethod
134 def generate(cls, length: int = 12) -> "Password":
131 - if length < 8: # Ensure the password is a reasonable length
132 - raise ValueError("Password length should be at least 8 characters.")
135 + if length < 8 or length > 72:
136 + raise ValueError("Password length should be between 8 and 72 characters.")
137
138 # Define the characters that can be used in the password
139 lowercase = string.ascii_lowercase
@@ -180,8 +184,8 @@ class PasswordResetToken(BaseModel):
184 @field_validator("new_password")
185 @classmethod
186 def validate_password(cls, password):
183 - if len(password) < 8 or len(password) > 256:
184 - raise ValueError("Password length must be between 8 and 256 characters.")
187 + if len(password) < 8 or len(password) > 72:
188 + raise ValueError("Password length must be between 8 and 72 characters (bcrypt input limit).")
189 if not re.search(r"[a-z]", password):
190 raise ValueError("Password must contain at least one lowercase letter.")
191 if not re.search(r"[A-Z]", password):
@@ -195,9 +199,10 @@ class PasswordResetToken(BaseModel):
199
200 class PasswordReset(BaseModel):
201 username: str
202 + # 8-72 chars — bcrypt's 72-byte input limit (matches UserInput).
203 new_password: str = Field(
199 - max_length=256,
204 + max_length=72,
205 min_length=8,
206 regex="^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&#])[A-Za-z\\d@$!%*?&#]{8,}$",
202 - description="Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character",
207 + description="Password must be 8-72 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character",
208 )
backend/app/auth/utils.py
+10 -2
@@ -46,11 +46,19 @@ class AuthHandler:
46 )
47 secret = _load_jwt_secret()
48
49 + @staticmethod
50 + def _to_bcrypt_bytes(password: str) -> bytes:
51 + # bcrypt 5 raises ValueError on >72 bytes; bcrypt 4 silently truncated.
52 + # We slice by bytes (not chars) for backwards compat with hashes created
53 + # under bcrypt 4's silent truncation, while protecting against bcrypt 5
54 + # raising for any path that bypasses the schema-layer length validation.
55 + return password.encode("utf-8")[:72]
56 +
57 def get_password_hash(self, password: str) -> str:
50 - return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
58 + return bcrypt.hashpw(self._to_bcrypt_bytes(password), bcrypt.gensalt()).decode("utf-8")
59
60 def verify_password(self, plain_password: str, hashed_password: str) -> bool:
53 - return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
61 + return bcrypt.checkpw(self._to_bcrypt_bytes(plain_password), hashed_password.encode("utf-8"))
62
63 # ! TODO: HAVE LOGIC TO HANDLE PASSWORD RESET VIA A TOKEN BUT NOT IMPLEMENTED YET ! #
64 def generate_reset_token(
backend/app/middleware/exception_handlers.py
+8 -1
@@ -69,7 +69,14 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
69
70 for error in errors:
71 field = error["loc"][-1]
72 - error_type = ErrorType(error["type"])
72 + try:
73 + error_type = ErrorType(error["type"])
74 + except ValueError:
75 + # Pydantic 2 emits codes the v1-era ErrorType enum doesn't list
76 + # (string_too_long, string_pattern_mismatch, etc.) — fall back to
77 + # GENERAL so unknown codes still produce a 422 with a sensible
78 + # message instead of crashing the handler.
79 + error_type = ErrorType.GENERAL
80 details.append(ValidationErrorItem(field=field, error_type=error_type))
81
82 main_message = details[0].message if details else "Validation Error"
backend/app/utils.py
+28 -1
@@ -47,6 +47,8 @@ from app.integrations.alert_creation_settings.models.alert_creation_settings imp
47
48 ################## ! 422 VALIDATION ERROR TYPES FOR PYDANTIC VALUE ERROR RESPONSE ! ##################
49 class ErrorType(str, Enum):
50 + # Legacy pydantic 1 codes — kept so any consumer reading these by string
51 + # value continues to work. Pydantic 2 uses the *_V2 codes below.
52 PASSWORD_REGEX = "value_error.str.regex"
53 TIME_RANGE = "value_error.time_range"
54 JSON_INVALID = "json_invalid"
@@ -64,7 +66,19 @@ class ErrorType(str, Enum):
66 MISSING = "value_error.missing"
67 GENERAL = "value_error"
68 INVALID_ENUM = "type_error.enum"
67 - # Add other types as needed
69 + # Pydantic 2 codes (renamed in #849). Add more here as users surface them.
70 + STRING_TOO_SHORT = "string_too_short"
71 + STRING_TOO_LONG = "string_too_long"
72 + STRING_PATTERN_MISMATCH = "string_pattern_mismatch"
73 + INT_PARSING = "int_parsing"
74 + GREATER_THAN = "greater_than"
75 + GREATER_THAN_EQUAL = "greater_than_equal"
76 + LESS_THAN = "less_than"
77 + LESS_THAN_EQUAL = "less_than_equal"
78 + DATETIME_PARSING = "datetime_parsing"
79 + DATE_PARSING = "date_parsing"
80 + ENUM = "enum"
81 + MISSING_V2 = "missing"
82
83
84 class ValidationErrorItem(BaseModel):
@@ -93,6 +107,19 @@ class ValidationErrorItem(BaseModel):
107 ErrorType.MISSING: "Missing data for required field.",
108 ErrorType.GENERAL: "Invalid value.",
109 ErrorType.INVALID_ENUM: "Value is not a valid enumeration member.",
110 + # Pydantic 2 codes — same human messages as their v1 equivalents.
111 + ErrorType.STRING_TOO_SHORT: "Value is shorter than minimum length.",
112 + ErrorType.STRING_TOO_LONG: "Value is longer than maximum length.",
113 + ErrorType.STRING_PATTERN_MISMATCH: "Value does not match the required pattern.",
114 + ErrorType.INT_PARSING: "Input is not a valid integer.",
115 + ErrorType.GREATER_THAN: "Value is too small.",
116 + ErrorType.GREATER_THAN_EQUAL: "Value is too small.",
117 + ErrorType.LESS_THAN: "Value is too large.",
118 + ErrorType.LESS_THAN_EQUAL: "Value is too large.",
119 + ErrorType.DATETIME_PARSING: "Invalid datetime format.",
120 + ErrorType.DATE_PARSING: "Invalid date format.",
121 + ErrorType.ENUM: "Value is not a valid enumeration member.",
122 + ErrorType.MISSING_V2: "Missing data for required field.",
123 }
124 if self.error_type in error_messages:
125 self.message = error_messages[self.error_type]
backend/requirements.in
+1 -1
@@ -5,7 +5,7 @@ aiosqlite
5 alembic
6 apscheduler
7 asyncgelf
8 -bcrypt<5 # bcrypt 5 errors (instead of silently truncating) on passwords >72 bytes; lift after enforcing length limit at the model layer
8 +bcrypt
9 cortex4py
10 cryptography
11 docxtpl
backend/requirements.txt
+1 -1
@@ -43,7 +43,7 @@ azure-mgmt-sql==1.0.0
43 azure-mgmt-storage==17.0.0
44 azure-mgmt-web==1.0.0
45 backports-tarfile==1.2.0
46 -bcrypt==4.3.0
46 +bcrypt==5.0.0
47 boto3==1.43.6
48 botocore==1.43.6
49 cachetools==4.2.4