From 343f12259c1fa18b2ee3f33dc3aba7c284405e68 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Mon, 13 Apr 2026 18:15:47 +0200 Subject: [PATCH] Add profile feature, input sanitization, and stronger security checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - app/core/sanitize.py: shared sanitize_str, normalize_email, validate_phone, validate_date_of_birth — applied to every user-supplied DB-bound input - app/schemas/user.py: sanitize full_name, normalize email on UserCreate - app/models/profile.py: profiles table (position, phone, dob, address, updated_at) - app/models/user.py: Profile back-ref, is_superuser admin-role comment - app/schemas/profile.py: ProfileRead/ProfileUpdate with full sanitization - app/routers/profile.py: GET+PUT /api/profile/me (lazy profile creation) - app/main.py: register /api/profile router - alembic migration 676084df61d1: create profiles table Frontend: - components/Nav.tsx: shared nav (Dashboard | Profile | Logout) - pages/ProfilePage.tsx: profile view + inline edit form with error handling - pages/DashboardPage.tsx: use Nav component - api/client.ts: ProfileData type, getProfile, updateProfile - App.tsx: /profile private route Security: - scripts/security_check.py: tighter SQL injection patterns (f-string/format/% in execute/query/text()), new SANIT category for raw request→DB patterns Co-Authored-By: Claude Sonnet 4.6 --- README.md | 6 +- TODO.md | 6 + .../676084df61d1_add_profiles_table.py | 40 +++++ backend/app/core/sanitize.py | 74 +++++++++ backend/app/main.py | 3 +- backend/app/models/__init__.py | 3 +- backend/app/models/profile.py | 34 ++++ backend/app/models/user.py | 12 +- backend/app/routers/profile.py | 48 ++++++ backend/app/schemas/profile.py | 44 +++++ backend/app/schemas/user.py | 12 ++ changelog/2026-04-13_profile-sanitization.md | 30 ++++ frontend/src/App.tsx | 9 + frontend/src/api/client.ts | 24 +++ frontend/src/components/Nav.tsx | 16 ++ frontend/src/pages/DashboardPage.tsx | 15 +- frontend/src/pages/ProfilePage.tsx | 154 ++++++++++++++++++ scripts/security_check.py | 33 +++- 18 files changed, 547 insertions(+), 16 deletions(-) create mode 100644 backend/alembic/versions/676084df61d1_add_profiles_table.py create mode 100644 backend/app/core/sanitize.py create mode 100644 backend/app/models/profile.py create mode 100644 backend/app/routers/profile.py create mode 100644 backend/app/schemas/profile.py create mode 100644 changelog/2026-04-13_profile-sanitization.md create mode 100644 frontend/src/components/Nav.tsx create mode 100644 frontend/src/pages/ProfilePage.tsx diff --git a/README.md b/README.md index 181aaa5..91fc8d1 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,12 @@ A fullstack SaaS web application built with FastAPI, React, and PostgreSQL. ## Current State - User registration and login (JWT auth) -- Protected dashboard route +- Protected dashboard with nav bar (Dashboard | Profile | Logout) - `/api/users/me` — authenticated user info +- `/api/profile/me` — GET/PUT personal profile (position, phone, date of birth, address) +- Profile data stored in a dedicated `profiles` table; auto-created on first access +- Admin role flag (`is_superuser`) stored in `users` table; hidden from all API responses +- All input sanitized before reaching the DB (null-byte rejection, length caps, format validation) - 3 separate Docker containers: `db` (PostgreSQL), `backend` (FastAPI), `frontend` (nginx) - All containers run as non-root users (UID 1001 for backend and frontend, UID 70 for db) - Dev environment seeds a test user automatically on startup (`test@example.com` / `Test123!`) diff --git a/TODO.md b/TODO.md index f0538fd..0c616a4 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,11 @@ # TODO +## Frontend features + +- [x] **Logout button** — visible when logged in, clears token and redirects to `/login` +- [x] **Profile page** (`/profile`) — shows personal information for the logged-in user +- [x] **Edit & save profile** — form to update personal details, stored in a dedicated `profiles` table (separate from `users`, same PostgreSQL container) + ## Infrastructure - [x] **Rootless containers** — run backend and frontend containers as non-root users (add `USER` directive to Dockerfiles, map UID/GID appropriately) diff --git a/backend/alembic/versions/676084df61d1_add_profiles_table.py b/backend/alembic/versions/676084df61d1_add_profiles_table.py new file mode 100644 index 0000000..ab39e1a --- /dev/null +++ b/backend/alembic/versions/676084df61d1_add_profiles_table.py @@ -0,0 +1,40 @@ +"""add profiles table + +Revision ID: 676084df61d1 +Revises: 38efeff7c45a +Create Date: 2026-04-13 16:11:46.705481 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = '676084df61d1' +down_revision: Union[str, None] = '38efeff7c45a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('profiles', + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('phone', sa.String(length=20), nullable=True), + sa.Column('date_of_birth', sa.Date(), nullable=True), + sa.Column('position', sa.String(length=128), nullable=True), + sa.Column('address', sa.String(length=255), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('profiles') + # ### end Alembic commands ### diff --git a/backend/app/core/sanitize.py b/backend/app/core/sanitize.py new file mode 100644 index 0000000..a58f4b9 --- /dev/null +++ b/backend/app/core/sanitize.py @@ -0,0 +1,74 @@ +""" +Input sanitization utilities. + +Every string that originates from user input and is destined for the database +MUST pass through these helpers before reaching a SQLAlchemy model or query. +SQLAlchemy's ORM already uses bound parameters (no raw SQL), so these helpers +address the layer above: ensuring data is well-formed, length-capped, and free +of null bytes or control characters before it is stored. +""" + +import re +import unicodedata +from datetime import date + +# ── Constants ───────────────────────────────────────────────────────────────── + +_PHONE_RE = re.compile(r"^\+?[\d\s\-()\[\]]{7,20}$") + +# ── Core helper ─────────────────────────────────────────────────────────────── + + +def sanitize_str(value: str | None, max_len: int = 255) -> str | None: + """Strip whitespace, reject null bytes and non-printable control characters, + enforce a maximum length. Returns None unchanged so optional fields work + naturally with ``Optional[str]`` annotations.""" + if value is None: + return None + + # Strip leading/trailing whitespace + value = value.strip() + + # Reject null bytes (common injection vector) + if "\x00" in value: + raise ValueError("Input must not contain null bytes") + + # Reject ASCII control characters (0x01–0x1F, 0x7F) except tab/newline/CR + # which may appear in multi-line fields. Use Unicode category 'Cc'. + for ch in value: + if unicodedata.category(ch) == "Cc" and ch not in ("\t", "\n", "\r"): + raise ValueError("Input contains invalid control characters") + + if len(value) > max_len: + raise ValueError(f"Input must not exceed {max_len} characters") + + return value if value != "" else None + + +def normalize_email(value: str) -> str: + """Lowercase and strip an email address.""" + return value.strip().lower() + + +def validate_phone(value: str | None) -> str | None: + """Sanitize then validate phone number format.""" + value = sanitize_str(value, max_len=20) + if value is None: + return None + if not _PHONE_RE.match(value): + raise ValueError( + "Phone number may only contain digits, spaces, +, -, (, ) and [ ] " + "and must be 7–20 characters" + ) + return value + + +def validate_date_of_birth(value: date | None) -> date | None: + """Reject obviously invalid birth dates (before 1900 or in the future).""" + if value is None: + return None + if value.year < 1900: + raise ValueError("Date of birth must be 1900 or later") + if value > date.today(): + raise ValueError("Date of birth must not be in the future") + return value diff --git a/backend/app/main.py b/backend/app/main.py index f798a62..2e21373 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.core.config import settings -from app.routers import auth, users +from app.routers import auth, profile, users app = FastAPI(title=settings.PROJECT_NAME, version="0.1.0") @@ -16,6 +16,7 @@ app.add_middleware( app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(users.router, prefix="/api/users", tags=["users"]) +app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) @app.get("/api/health") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b2e47e8..de2ccf4 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,3 +1,4 @@ +from app.models.profile import Profile from app.models.user import User -__all__ = ["User"] +__all__ = ["User", "Profile"] diff --git a/backend/app/models/profile.py b/backend/app/models/profile.py new file mode 100644 index 0000000..e177025 --- /dev/null +++ b/backend/app/models/profile.py @@ -0,0 +1,34 @@ +import uuid +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class Profile(Base): + __tablename__ = "profiles" + + id: Mapped[str] = mapped_column( + String, primary_key=True, default=lambda: str(uuid.uuid4()) + ) + # One-to-one with users; deleting a user cascades to the profile. + user_id: Mapped[str] = mapped_column( + String, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False + ) + + phone: Mapped[str | None] = mapped_column(String(20), nullable=True) + date_of_birth: Mapped[date | None] = mapped_column(Date, nullable=True) + # Job title / role within the organisation (e.g. "Software Engineer", "HR Manager"). + position: Mapped[str | None] = mapped_column(String(128), nullable=True) + address: Mapped[str | None] = mapped_column(String(255), nullable=True) + + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + user = relationship("User", back_populates="profile") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index eb32585..6a8a8b2 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,10 +1,14 @@ import uuid +from typing import TYPE_CHECKING from sqlalchemy import Boolean, String -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base +if TYPE_CHECKING: + from app.models.profile import Profile + class User(Base): __tablename__ = "users" @@ -14,4 +18,10 @@ class User(Base): hashed_password: Mapped[str] = mapped_column(String, nullable=False) full_name: Mapped[str] = mapped_column(String, nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True) + # Role flag — True = admin, False = regular user. + # Never exposed in API responses; set only by direct DB or admin tooling. is_superuser: Mapped[bool] = mapped_column(Boolean, default=False) + + profile: Mapped["Profile"] = relationship( + "Profile", back_populates="user", uselist=False, cascade="all, delete-orphan" + ) diff --git a/backend/app/routers/profile.py b/backend/app/routers/profile.py new file mode 100644 index 0000000..8fa8666 --- /dev/null +++ b/backend/app/routers/profile.py @@ -0,0 +1,48 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.deps import get_current_user +from app.models.profile import Profile +from app.models.user import User +from app.schemas.profile import ProfileRead, ProfileUpdate + +router = APIRouter() + + +async def _get_or_create_profile(user: User, db: AsyncSession) -> Profile: + """Return the user's profile, creating an empty one on first access.""" + result = await db.execute(select(Profile).where(Profile.user_id == user.id)) + profile = result.scalar_one_or_none() + if profile is None: + profile = Profile(user_id=user.id) + db.add(profile) + await db.commit() + await db.refresh(profile) + return profile + + +@router.get("/me", response_model=ProfileRead) +async def get_my_profile( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Profile: + return await _get_or_create_profile(current_user, db) + + +@router.put("/me", response_model=ProfileRead) +async def update_my_profile( + body: ProfileUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Profile: + profile = await _get_or_create_profile(current_user, db) + + # Only update fields that were explicitly provided in the request body. + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(profile, field, value) + + await db.commit() + await db.refresh(profile) + return profile diff --git a/backend/app/schemas/profile.py b/backend/app/schemas/profile.py new file mode 100644 index 0000000..d4b5300 --- /dev/null +++ b/backend/app/schemas/profile.py @@ -0,0 +1,44 @@ +from datetime import date, datetime + +from pydantic import BaseModel, field_validator + +from app.core.sanitize import sanitize_str, validate_date_of_birth, validate_phone + + +class ProfileRead(BaseModel): + id: str + user_id: str + phone: str | None + date_of_birth: date | None + position: str | None + address: str | None + updated_at: datetime + + model_config = {"from_attributes": True} + + +class ProfileUpdate(BaseModel): + phone: str | None = None + date_of_birth: date | None = None + position: str | None = None + address: str | None = None + + @field_validator("phone", mode="before") + @classmethod + def clean_phone(cls, v: str | None) -> str | None: + return validate_phone(v) + + @field_validator("position", mode="before") + @classmethod + def clean_position(cls, v: str | None) -> str | None: + return sanitize_str(v, max_len=128) + + @field_validator("address", mode="before") + @classmethod + def clean_address(cls, v: str | None) -> str | None: + return sanitize_str(v, max_len=255) + + @field_validator("date_of_birth", mode="after") + @classmethod + def clean_dob(cls, v: date | None) -> date | None: + return validate_date_of_birth(v) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index b27f2d5..d727fdb 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -2,6 +2,8 @@ import re from pydantic import BaseModel, EmailStr, field_validator +from app.core.sanitize import normalize_email, sanitize_str + # Common words that must not appear as whole words inside a password. # Checked case-insensitively with word boundaries. _FORBIDDEN_WORDS = { @@ -45,6 +47,16 @@ class UserCreate(BaseModel): password: str full_name: str | None = None + @field_validator("email", mode="before") + @classmethod + def normalize_email_field(cls, v: str) -> str: + return normalize_email(v) + + @field_validator("full_name", mode="before") + @classmethod + def sanitize_full_name(cls, v: str | None) -> str | None: + return sanitize_str(v, max_len=128) + @field_validator("password") @classmethod def password_strength(cls, v: str) -> str: diff --git a/changelog/2026-04-13_profile-sanitization.md b/changelog/2026-04-13_profile-sanitization.md new file mode 100644 index 0000000..bc2c0a3 --- /dev/null +++ b/changelog/2026-04-13_profile-sanitization.md @@ -0,0 +1,30 @@ +# 2026-04-13 — Profile feature + input sanitization + +**Timestamp:** 2026-04-13T02:00:00 + +## Summary + +Added shared input sanitization layer applied to all database-bound inputs, introduced the `profiles` table for personal information (position, phone, date of birth, address), and built the frontend profile page with inline editing and a shared nav bar (Dashboard | Profile | Logout). Admin role flag (`is_superuser`) confirmed hidden from API. Security check patterns strengthened. + +## Files Added + +- `backend/app/core/sanitize.py` — shared helpers: `sanitize_str`, `normalize_email`, `validate_phone`, `validate_date_of_birth`; applied to every user-supplied string before it reaches the DB +- `backend/app/models/profile.py` — `Profile` ORM model (profiles table): `user_id` FK, `phone`, `date_of_birth`, `position`, `address`, `updated_at` +- `backend/app/schemas/profile.py` — `ProfileRead` / `ProfileUpdate` Pydantic schemas; all fields sanitized via shared helpers +- `backend/app/routers/profile.py` — `GET /api/profile/me` (lazy-create), `PUT /api/profile/me` +- `backend/alembic/versions/676084df61d1_add_profiles_table.py` — Alembic migration creating the profiles table +- `frontend/src/components/Nav.tsx` — shared nav bar: Dashboard, Profile, Logout +- `frontend/src/pages/ProfilePage.tsx` — profile view + inline edit form; uses TanStack Query for fetch/mutate + +## Files Modified + +- `backend/app/schemas/user.py` — added `normalize_email` and `sanitize_str` validators to `UserCreate` +- `backend/app/models/user.py` — added `Profile` back-reference; added admin-role comment on `is_superuser` +- `backend/app/models/__init__.py` — export `Profile` +- `backend/app/main.py` — register `/api/profile` router +- `frontend/src/api/client.ts` — added `ProfileData`, `ProfileUpdate` types, `getProfile`, `updateProfile` +- `frontend/src/App.tsx` — added `/profile` private route +- `frontend/src/pages/DashboardPage.tsx` — replaced inline logout with `Nav` component +- `scripts/security_check.py` — strengthened SQL injection patterns (f-string/format/% in execute, text() without bindparam); added SANIT category for raw request→DB patterns +- `TODO.md` — frontend feature items marked complete +- `README.md` — Current State updated diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 30b1cef..f60c84e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from "react-router-dom"; import LoginPage from "./pages/LoginPage"; import RegisterPage from "./pages/RegisterPage"; import DashboardPage from "./pages/DashboardPage"; +import ProfilePage from "./pages/ProfilePage"; import LoginSuccessPage from "./pages/LoginSuccessPage"; import RegisterSuccessPage from "./pages/RegisterSuccessPage"; import { useAuth } from "./hooks/useAuth"; @@ -33,6 +34,14 @@ export default function App() { } /> + + + + } + /> ); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0f642e9..287bb80 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -21,3 +21,27 @@ export const register = (email: string, password: string, full_name?: string) => // --- Users --- export const getMe = () => api.get("/users/me").then((r) => r.data); + +// --- Profile --- +export interface ProfileData { + id: string; + user_id: string; + phone: string | null; + date_of_birth: string | null; + position: string | null; + address: string | null; + updated_at: string; +} + +export interface ProfileUpdate { + phone?: string | null; + date_of_birth?: string | null; + position?: string | null; + address?: string | null; +} + +export const getProfile = () => + api.get("/profile/me").then((r) => r.data); + +export const updateProfile = (data: ProfileUpdate) => + api.put("/profile/me", data).then((r) => r.data); diff --git a/frontend/src/components/Nav.tsx b/frontend/src/components/Nav.tsx new file mode 100644 index 0000000..4b0cb47 --- /dev/null +++ b/frontend/src/components/Nav.tsx @@ -0,0 +1,16 @@ +import { Link } from "react-router-dom"; +import { useAuth } from "../hooks/useAuth"; + +export default function Nav() { + const { logout } = useAuth(); + + return ( + + ); +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index a9f51ba..848fc2e 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,16 +1,17 @@ import { useQuery } from "@tanstack/react-query"; import { getMe } from "../api/client"; -import { useAuth } from "../hooks/useAuth"; +import Nav from "../components/Nav"; export default function DashboardPage() { - const { logout } = useAuth(); const { data: user } = useQuery({ queryKey: ["me"], queryFn: getMe }); return ( -
-

Dashboard

- {user &&

Welcome, {user.full_name ?? user.email}

} - -
+ <> +