# Form Validation Hardening Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Harden form validation across the application — fix security gaps (inactive user login, rate limiting, password max length), add missing validation (username, groups, locale, password strength), deduplicate profile validation, require current password on change, add CSRF tokens to admin forms, and display all validation errors. **Architecture:** Extend `src/porchlight/validation.py` with new Pydantic models and a shared error-formatting helper. Add `slowapi` middleware for rate limiting. Add `zxcvbn` for password strength. Update route handlers to use the new models and helper. Update templates for CSRF tokens and password UI. **Tech Stack:** Pydantic v2, `slowapi` (rate limiting), `zxcvbn` (password strength), FastAPI, HTMX. --- ### Task 1: Add `slowapi` and `zxcvbn` dependencies **Files:** - Modify: `pyproject.toml:7-22` - [ ] **Step 1: Add dependencies** In `pyproject.toml`, add to the `dependencies` list: ```toml "slowapi>=0.1.9", "zxcvbn>=4.5", ``` - [ ] **Step 2: Install** Run: `uv sync` - [ ] **Step 3: Verify imports** Run: ```bash uv run python -c "from slowapi import Limiter; print('slowapi OK')" uv run python -c "from zxcvbn import zxcvbn; print('zxcvbn OK')" ``` Expected: Both print OK. - [ ] **Step 4: Commit** ```bash git add pyproject.toml uv.lock git commit -m "build: add slowapi and zxcvbn dependencies" ``` --- ### Task 2: Add locale BCP 47 validator to `ProfileUpdate` **Files:** - Modify: `src/porchlight/validation.py:1-42` - Modify: `tests/test_validation.py` - [ ] **Step 1: Write the failing tests** Add to `tests/test_validation.py`: ```python class TestProfileUpdateLocale: def test_valid_locale_language_only(self) -> None: p = ProfileUpdate(locale="en") assert p.locale == "en" def test_valid_locale_language_region(self) -> None: p = ProfileUpdate(locale="sv-SE") assert p.locale == "sv-SE" def test_valid_locale_language_script_region(self) -> None: p = ProfileUpdate(locale="zh-Hans-CN") assert p.locale == "zh-Hans-CN" def test_valid_locale_three_letter(self) -> None: p = ProfileUpdate(locale="gsw") assert p.locale == "gsw" def test_empty_locale_allowed(self) -> None: p = ProfileUpdate(locale="") assert p.locale == "" def test_invalid_locale_rejected(self) -> None: with pytest.raises(ValidationError, match="locale"): ProfileUpdate(locale="not_a_locale!") def test_invalid_locale_numbers(self) -> None: with pytest.raises(ValidationError, match="locale"): ProfileUpdate(locale="12345") def test_whitespace_locale_becomes_empty(self) -> None: p = ProfileUpdate(locale=" ") assert p.locale == "" ``` - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/test_validation.py::TestProfileUpdateLocale -v` Expected: FAIL (no locale validation exists yet) - [ ] **Step 3: Add locale validator** In `src/porchlight/validation.py`, add at the top: ```python import re ``` And add a validator to `ProfileUpdate`: ```python @field_validator("locale", mode="before") @classmethod def validate_locale(cls, v: str) -> str: if isinstance(v, str): v = v.strip() if v == "": return "" # BCP 47 simplified: language(-Script)?(-REGION)? if not re.match(r"^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$", v): raise ValueError("Locale must be a valid BCP 47 language tag (e.g. en, sv-SE, zh-Hans-CN)") return v ``` - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/test_validation.py::TestProfileUpdateLocale -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/porchlight/validation.py tests/test_validation.py git commit -m "feat: add BCP 47 locale validation to ProfileUpdate" ``` --- ### Task 3: Add username validation model **Files:** - Modify: `src/porchlight/validation.py` - Modify: `tests/test_validation.py` - [ ] **Step 1: Write the failing tests** Add to `tests/test_validation.py`: ```python from porchlight.validation import UsernameInput class TestUsernameInput: def test_valid_simple(self) -> None: u = UsernameInput(username="alice") assert u.username == "alice" def test_valid_with_dots_dashes_underscores(self) -> None: u = UsernameInput(username="alice.bob_charlie-1") assert u.username == "alice.bob_charlie-1" def test_valid_email_style(self) -> None: u = UsernameInput(username="user@example.com") assert u.username == "user@example.com" def test_valid_uppercase(self) -> None: u = UsernameInput(username="Alice") assert u.username == "Alice" def test_empty_rejected(self) -> None: with pytest.raises(ValidationError, match="username"): UsernameInput(username="") def test_whitespace_only_rejected(self) -> None: with pytest.raises(ValidationError, match="username"): UsernameInput(username=" ") def test_too_long_rejected(self) -> None: with pytest.raises(ValidationError): UsernameInput(username="a" * 256) def test_spaces_rejected(self) -> None: with pytest.raises(ValidationError, match="username"): UsernameInput(username="alice bob") def test_special_chars_rejected(self) -> None: with pytest.raises(ValidationError, match="username"): UsernameInput(username="alice