diff --git a/.github/workflows/docker-deploy.yml b/.github/workflows/docker-deploy.yml
index da8e71d..6eceee8 100644
--- a/.github/workflows/docker-deploy.yml
+++ b/.github/workflows/docker-deploy.yml
@@ -22,16 +22,16 @@ jobs:
id-token: write
steps:
- name: Checkout repository
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
- name: Log in to the GHCR registry
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef
+ uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef #v3.6.2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef
+ uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef #v3.6.2
with:
registry: dhi.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -39,7 +39,7 @@ jobs:
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
- name: Extract metadata (tags, labels) for Docker
id: meta
- uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
+ uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f #v5.8.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
@@ -50,7 +50,7 @@ jobs:
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
- name: Build and push Docker image
id: push
- uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
+ uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 #v6.18.0
with:
context: .
push: true
@@ -59,7 +59,7 @@ jobs:
# This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)."
- name: Generate artifact attestation
- uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd
+ uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a #v3.0.0
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.push.outputs.digest }}
diff --git a/.github/workflows/docker-test.yml b/.github/workflows/docker-test.yml
index 2fd12c4..e6b9f22 100644
--- a/.github/workflows/docker-test.yml
+++ b/.github/workflows/docker-test.yml
@@ -16,10 +16,10 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5
- name: Log in to Docker Hub
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef
+ uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef #v3.6.2
with:
registry: dhi.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
new file mode 100644
index 0000000..bf35aa9
--- /dev/null
+++ b/.github/workflows/pytest.yml
@@ -0,0 +1,34 @@
+name: Pytest
+
+on:
+ pull_request:
+ paths:
+ - "**.py"
+ - "uv.lock"
+ - ".python-version"
+ - "pyproject.toml"
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 #v7.1.2
+ with:
+ version: "latest"
+
+ - name: Set up Python
+ uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c #v6
+ with:
+ python-version: "3.13.3"
+
+ - name: Install dependencies
+ run: uv sync --frozen --no-cache
+
+ - name: Run pytest
+ run: uv run pytest
diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml
index 88902ce..efd1b41 100644
--- a/.github/workflows/ruff.yml
+++ b/.github/workflows/ruff.yml
@@ -9,5 +9,5 @@ jobs:
ruff:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- - uses: astral-sh/ruff-action@0c50076f12c38c3d0115b7b519b54a91cb9cf0ad
\ No newline at end of file
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5
+ - uses: astral-sh/ruff-action@57714a7c8a2e59f32539362ba31877a1957dded1 #v3.5.1
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 9753a19..203166f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,12 +8,14 @@ dependencies = [
"aiocache>=0.12.3",
"fastapi-sso>=0.17.0",
"fastapi[standard]>=0.115.6",
+ "bleach>=6.1.0",
"pydantic-settings>=2.7.1",
"pyjwt>=2.10.1",
"pymongo>=4.13.2",
"ruff>=0.9.2",
"sentry-sdk[fastapi]>=2.20.0",
"valkey-glide>=2.0.1",
+ "pytest>=8.3.3",
]
[tool.ruff]
@@ -28,3 +30,7 @@ quote-style = "double"
indent-style = "space"
line-ending = "lf"
+[tool.pytest.ini_options]
+pythonpath = ["."]
+testpaths = ["tests"]
+
diff --git a/server/database/backtest.py b/server/database/backtest.py
index b5df698..1a12590 100644
--- a/server/database/backtest.py
+++ b/server/database/backtest.py
@@ -3,6 +3,7 @@
from fastapi import HTTPException
from server.database import database
+from server.helpers.sanitize import is_valid_object_id
from server.models.backtest import Backtests, Course
backtest_course_code_collection = database.get_collection(
@@ -36,6 +37,9 @@ async def retrieve_courses(course_code: str) -> list[Course]:
@cached(ttl=86400)
async def retrieve_backtest(backtest_id: str) -> list[Backtests]:
+ if not is_valid_object_id(backtest_id):
+ raise HTTPException(status_code=404, detail="Backtest not found")
+
backtest = await backtest_collection.find_one(
{"course_ids": {"$in": [ObjectId(backtest_id)]}}
)
diff --git a/server/database/laf.py b/server/database/laf.py
index d39d1d0..e281c03 100644
--- a/server/database/laf.py
+++ b/server/database/laf.py
@@ -13,6 +13,7 @@
datetime_time_delta,
get_next_sequence_value,
)
+from server.helpers.sanitize import is_valid_object_id, reject_mongo_operators
from server.models.laf import ArchivedLAFItem, ExpiredItem, LAFItem, LostReportItem
sequence_id_collection = database.get_collection("sequence_id")
@@ -109,6 +110,7 @@ async def lost_report_helper(lost_report: dict) -> LostReportItem:
# Add a new laf item into to the database
async def add_laf(laf_data: dict) -> LAFItem:
+ reject_mongo_operators(laf_data)
type_id = await get_type_id(laf_data["type"])
del laf_data["type"]
@@ -136,6 +138,8 @@ async def add_laf(laf_data: dict) -> LAFItem:
async def update_laf(laf_id: int, laf_data: dict, now: datetime) -> bool:
+ reject_mongo_operators(laf_data)
+
laf_item = await laf_items_collection.find_one({"_id": laf_id})
if laf_item is None:
raise HTTPException(
@@ -148,7 +152,6 @@ async def update_laf(laf_id: int, laf_data: dict, now: datetime) -> bool:
type_id = await get_type_id(laf_data["type"])
del laf_data["type"]
laf_data["type_id"] = type_id
-
updated_laf_item = await laf_items_collection.update_one(
{"_id": laf_id}, {"$set": laf_data}
)
@@ -397,6 +400,7 @@ async def retrieve_expired_laf(
# Add a new lost report into to the database
async def add_lost_report(lost_report_data: dict, auth: bool) -> LostReportItem:
+ reject_mongo_operators(lost_report_data)
type_id = await get_type_id(lost_report_data["type"])
del lost_report_data["type"]
now = datetime.now()
@@ -425,6 +429,11 @@ async def add_lost_report(lost_report_data: dict, auth: bool) -> LostReportItem:
async def update_lost_report(
lost_report_id: str, lost_report_data: dict, now: datetime
) -> bool:
+ if not is_valid_object_id(lost_report_id):
+ return False
+
+ reject_mongo_operators(lost_report_data)
+
lost_report_id_bson = ObjectId(lost_report_id)
lost_report = await lost_reports_collection.find_one({"_id": lost_report_id_bson})
if lost_report is None:
@@ -436,7 +445,6 @@ async def update_lost_report(
type_id = await get_type_id(lost_report_data["type"])
del lost_report_data["type"]
lost_report_data["type_id"] = type_id
-
updated_lost_report = await lost_reports_collection.update_one(
{"_id": lost_report_id_bson}, {"$set": lost_report_data}
)
diff --git a/server/database/loanertech.py b/server/database/loanertech.py
index 9fa7bc7..5f5e077 100644
--- a/server/database/loanertech.py
+++ b/server/database/loanertech.py
@@ -2,6 +2,7 @@
from server.database import database
from server.helpers.db import get_next_sequence_value
+from server.helpers.sanitize import reject_mongo_operators
from server.models.loanertech import LoanerTechItem, LoanerTechItemUnauthorized
sequence_id_collection = database.get_collection("sequence_id")
@@ -47,6 +48,7 @@ async def retrieve_loanertechs() -> list[LoanerTechItem]:
# Add a new loanertech item into to the database
async def add_loanertech(loanertech_data: dict) -> LoanerTechItem:
+ reject_mongo_operators(loanertech_data)
# Add the ID to the loanertech data
loanertech_data["_id"] = await get_next_sequence_value(
"loanertech_id", sequence_id_collection
@@ -81,6 +83,7 @@ async def update_loanertech(id: int, data: dict) -> bool:
# Return false if an empty request body is sent.
if len(data) < 1:
return False
+ reject_mongo_operators(data)
loanertech = await loanertech_collection.find_one({"_id": id})
if loanertech:
updated_loanertech = await loanertech_collection.update_one(
diff --git a/server/helpers/sanitize.py b/server/helpers/sanitize.py
new file mode 100644
index 0000000..a39f524
--- /dev/null
+++ b/server/helpers/sanitize.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+import re
+from typing import Any
+
+import bleach
+
+_OBJECT_ID_RE = re.compile(r"^[a-fA-F0-9]{24}$")
+_WHITESPACE_RE = re.compile(r"\s+")
+
+
+def strip_tags(text: str) -> str:
+ if text is None:
+ return ""
+ # Remove all remaining HTML tags using bleach
+ return bleach.clean(text, tags=[], attributes={}, strip=True)
+
+
+def normalize_ws(text: str) -> str:
+ # Collapse whitespace and trim
+ return _WHITESPACE_RE.sub(" ", text or "").strip()
+
+
+def sanitize_text(text: str, max_len: int | None = None) -> str:
+ cleaned = normalize_ws(strip_tags(str(text)))
+ if max_len is not None and len(cleaned) > max_len:
+ cleaned = cleaned[:max_len]
+ return cleaned
+
+
+def is_valid_object_id(value: str) -> bool:
+ if not isinstance(value, str):
+ return False
+ return bool(_OBJECT_ID_RE.fullmatch(value))
+
+
+def _reject_key(key: Any) -> None:
+ if isinstance(key, str) and (key.startswith("$") or "." in key):
+ raise ValueError("MongoDB operator or dotted keys are not allowed in input")
+
+
+def reject_mongo_operators(obj: Any) -> Any:
+ # Recursively validate that no keys start with '$' or contain '.'
+ # Only dict and iterable container types need validation (primitives pass through)
+ if isinstance(obj, dict):
+ for k, v in obj.items():
+ _reject_key(k)
+ reject_mongo_operators(v)
+ elif isinstance(obj, (list, tuple, set)):
+ for item in obj:
+ reject_mongo_operators(item)
+ # Primitives (str, int, float, bool, None) and other types pass through unchanged
+ return obj
diff --git a/server/models/__init__.py b/server/models/__init__.py
index 5721423..e69de29 100644
--- a/server/models/__init__.py
+++ b/server/models/__init__.py
@@ -1,20 +0,0 @@
-from typing import Any
-
-from pydantic import BaseModel
-
-
-class ResponseModel(BaseModel):
- data: Any
- message: str
-
-
-class BoolResponse(ResponseModel):
- data: bool
-
-
-class StringListResponse(ResponseModel):
- data: list[str]
-
-
-class IntResponse(ResponseModel):
- data: int
diff --git a/server/models/auth.py b/server/models/auth.py
index a80d686..4501082 100644
--- a/server/models/auth.py
+++ b/server/models/auth.py
@@ -1,12 +1,30 @@
-from pydantic import BaseModel, Field
+from typing import Annotated
+from uuid import UUID
+
+from pydantic import BaseModel, BeforeValidator, Field
+
+
+def validate_uuid_code(v: str) -> str:
+ """Validate that a string is a valid UUID v4."""
+ # Strip whitespace
+ v = v.strip() if isinstance(v, str) else str(v).strip()
+ try:
+ # Validate it's a valid UUID v4
+ uuid_obj = UUID(v, version=4)
+ return str(uuid_obj)
+ except (ValueError, AttributeError):
+ raise ValueError("code must be a valid UUID v4")
+
+
+UUIDCode = Annotated[str, BeforeValidator(validate_uuid_code)]
class TokenRequest(BaseModel):
- code: str = Field(...)
+ code: UUIDCode = Field(...)
class Config:
json_schema_extra = {
"example": {
- "code": "adsa-sda-dsa-ds-d-asd",
+ "code": "550e8400-e29b-41d4-a716-446655440000",
}
}
diff --git a/server/models/backtest.py b/server/models/backtest.py
index 2dd5075..c08a70d 100644
--- a/server/models/backtest.py
+++ b/server/models/backtest.py
@@ -1,6 +1,20 @@
-from typing import TypedDict
+from typing import Annotated, TypedDict
-from server.models import ResponseModel
+from pydantic import BeforeValidator
+
+from server.models.common import ResponseModel, validate_object_id
+
+
+def validate_course_code(v: str) -> str:
+ """Validate that a string is a valid course code (exactly 4 uppercase letters)."""
+ v = v.strip() if isinstance(v, str) else str(v).strip()
+ if not (len(v) == 4 and v.isalpha() and v.isupper()):
+ raise ValueError("must be exactly 4 uppercase letters (A-Z)")
+ return v
+
+
+ObjectId = Annotated[str, BeforeValidator(validate_object_id)]
+CourseCode = Annotated[str, BeforeValidator(validate_course_code)]
class Course(TypedDict):
diff --git a/server/models/common.py b/server/models/common.py
new file mode 100644
index 0000000..45995af
--- /dev/null
+++ b/server/models/common.py
@@ -0,0 +1,45 @@
+from typing import Annotated, Any
+
+from pydantic import BaseModel, BeforeValidator
+
+from server.helpers.sanitize import is_valid_object_id, sanitize_text
+
+
+def validate_name(v: str | None) -> str:
+ """Validate and sanitize name filter (max 100 characters)."""
+ return sanitize_text(v, max_len=100)
+
+
+def validate_object_id(v: str) -> str:
+ """Validate that a string is a valid MongoDB ObjectId (24 hex characters)."""
+ if not is_valid_object_id(v):
+ raise ValueError("must be a valid ObjectId (24 hexadecimal characters)")
+ return v
+
+
+def validate_name_filter(v: str | None) -> str | None:
+ """Validate optional name filter."""
+ if v is None:
+ return None
+ return validate_name(v)
+
+
+NameFilter = Annotated[str, BeforeValidator(validate_name_filter)]
+Name = Annotated[str, BeforeValidator(validate_name)]
+
+
+class ResponseModel(BaseModel):
+ data: Any
+ message: str
+
+
+class BoolResponse(ResponseModel):
+ data: bool
+
+
+class StringListResponse(ResponseModel):
+ data: list[str]
+
+
+class IntResponse(ResponseModel):
+ data: int
diff --git a/server/models/laf.py b/server/models/laf.py
index 8f4cd64..ef9c7c2 100644
--- a/server/models/laf.py
+++ b/server/models/laf.py
@@ -2,9 +2,19 @@
from enum import Enum
from typing import Annotated, TypedDict
-from pydantic import BaseModel, BeforeValidator, EmailStr, Field, PlainSerializer
+from pydantic import (
+ BaseModel,
+ BeforeValidator,
+ EmailStr,
+ Field,
+ PlainSerializer,
+)
-from server.models import ResponseModel
+from server.helpers.sanitize import sanitize_text
+from server.models.common import Name, ResponseModel
+
+# Constants for field length limits
+LOCATION_MAX_LEN = 60
def parse_date_flexible(date_str: str) -> str:
@@ -28,10 +38,57 @@ def parse_date_flexible(date_str: str) -> str:
raise ValueError(f"Date '{date_str}' must be in MM/DD/YYYY or YYYY-MM-DD format")
+# TODO: limit on frontend
+def validate_description(v: str) -> str:
+ """Validate and sanitize description (max 2000 characters)."""
+ return sanitize_text(v, max_len=2000)
+
+
+def validate_type(v: str) -> str:
+ """Validate and sanitize type (max 40 characters)."""
+ return sanitize_text(v, max_len=40)
+
+
+# TODO: will convert to list[str] later
+def validate_location(v: str) -> str:
+ """Validate and sanitize location (max 60 characters per comma-separated item)."""
+ # Split by comma, sanitize each item individually, then join back
+ if not v:
+ return v
+ location_items = [
+ sanitize_text(item.strip(), max_len=LOCATION_MAX_LEN) for item in v.split(",")
+ ]
+ return ",".join(location_items)
+
+
+def validate_description_filter(v: str | None) -> str | None:
+ """Validate and sanitize description filter (max 2000 characters)."""
+ if v is None:
+ return None
+ return validate_description(v)
+
+
+def validate_type_filter(v: str | None) -> str | None:
+ """Validate and sanitize type filter (max 40 characters)."""
+ if v is None:
+ return None
+ return validate_type(v)
+
+
+# Optional versions for query parameters
+DescriptionFilter = Annotated[str, BeforeValidator(validate_description_filter)]
+TypeFilter = Annotated[str, BeforeValidator(validate_type_filter)]
+
+# Non-optional versions for required model fields
+Description = Annotated[str, BeforeValidator(validate_description)]
+TypeString = Annotated[str, BeforeValidator(validate_type)]
+Location = Annotated[str, BeforeValidator(validate_location)]
+
+
class LAFItemRequest(BaseModel):
- type: str = Field(...)
- location: str = Field(...)
- description: str = Field(...)
+ type: TypeString = Field(...)
+ location: Location = Field(...)
+ description: Description = Field(...)
date: Annotated[
str,
Field(...),
@@ -50,7 +107,7 @@ class Config:
class LAFFoundItem(BaseModel):
- name: str = Field(...)
+ name: Name = Field(...)
email: EmailStr = Field(...)
class Config:
@@ -67,16 +124,16 @@ class Config:
class LostReportRequest(BaseModel):
- type: str = Field(...)
- name: str = Field(...)
+ type: TypeString = Field(...)
+ name: Name = Field(...)
email: EmailStr = Field(...)
- description: str = Field(...)
+ description: Description = Field(...)
date: Annotated[
str,
Field(...),
BeforeValidator(parse_date_flexible),
]
- location: str = Field(...)
+ location: Location = Field(...)
class Config:
json_schema_extra = {
diff --git a/server/models/loanertech.py b/server/models/loanertech.py
index 65d0260..8b1e612 100644
--- a/server/models/loanertech.py
+++ b/server/models/loanertech.py
@@ -1,15 +1,28 @@
-from typing import Literal, Optional, TypedDict, Union
+from typing import Annotated, Literal, Optional, TypedDict, Union
-from pydantic import BaseModel, EmailStr, Field
+from pydantic import BaseModel, BeforeValidator, EmailStr, Field
-from server.models import ResponseModel
+from server.helpers.sanitize import sanitize_text
+from server.models.common import Name, ResponseModel
+
+
+def validate_loanertech_description(v: str) -> str:
+ """Validate and sanitize loanertech description (max 250 characters)."""
+ return sanitize_text(v, max_len=250)
+
+
+LoanerTechDescription = Annotated[str, BeforeValidator(validate_loanertech_description)]
class LoanerTechCheckout(BaseModel):
ids: list[int] = Field(...)
- phone_number: str = Field(..., max_length=12)
+ phone_number: str = Field(
+ ...,
+ max_length=20,
+ pattern=r"^\\(?([0-9]{3})\\)?[-.\s]?([0-9]{3})[-.\s]?([0-9]{4})$",
+ )
email: Optional[EmailStr] = Field(...)
- name: str = Field(...)
+ name: Name = Field(...)
class Config:
json_schema_extra = {
@@ -34,7 +47,7 @@ class Config:
class LoanerTechRequest(BaseModel):
- description: str = Field(...)
+ description: LoanerTechDescription = Field(...)
class Config:
json_schema_extra = {
diff --git a/server/routes/backtest.py b/server/routes/backtest.py
index 86b1092..74a649c 100644
--- a/server/routes/backtest.py
+++ b/server/routes/backtest.py
@@ -5,8 +5,13 @@
retrieve_coursecodes,
retrieve_courses,
)
-from server.models import StringListResponse
-from server.models.backtest import BacktestsReponse, CoursesResponse
+from server.models.backtest import (
+ BacktestsReponse,
+ CourseCode,
+ CoursesResponse,
+ ObjectId,
+)
+from server.models.common import StringListResponse
router = APIRouter()
@@ -28,7 +33,7 @@ async def get_coursecodes() -> StringListResponse:
response_description="Courses list retrieved",
response_model=CoursesResponse,
)
-async def get_courses(course_code: str) -> CoursesResponse:
+async def get_courses(course_code: CourseCode) -> CoursesResponse:
courses = await retrieve_courses(course_code)
return CoursesResponse(data=courses, message="Courses data retrieved successfully")
@@ -38,7 +43,7 @@ async def get_courses(course_code: str) -> CoursesResponse:
response_description="Backtests retrieved",
response_model=BacktestsReponse,
)
-async def get_backtest(course_id: str) -> BacktestsReponse:
+async def get_backtest(course_id: ObjectId) -> BacktestsReponse:
backtest = await retrieve_backtest(course_id)
return BacktestsReponse(
data=backtest, message="Backtests data retrieved successfully"
diff --git a/server/routes/laf.py b/server/routes/laf.py
index 563e61f..40873a1 100644
--- a/server/routes/laf.py
+++ b/server/routes/laf.py
@@ -2,6 +2,7 @@
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from fastapi.encoders import jsonable_encoder
+from pydantic import EmailStr
from server.database.laf import (
add_laf,
@@ -25,10 +26,18 @@
update_lost_report_item,
)
from server.helpers.auth import required_auth, simple_auth_check
-from server.models import BoolResponse, IntResponse, StringListResponse
+from server.helpers.sanitize import sanitize_text
+from server.models.common import (
+ BoolResponse,
+ IntResponse,
+ NameFilter,
+ StringListResponse,
+)
from server.models.laf import (
+ LOCATION_MAX_LEN,
DateFilter,
DateString,
+ DescriptionFilter,
ExpireLAFItemsReponse,
LAFArchiveItems,
LAFFoundItem,
@@ -40,6 +49,7 @@
LostReportItemResponse,
LostReportItemsResponse,
LostReportRequest,
+ TypeFilter,
)
router = APIRouter()
@@ -167,16 +177,23 @@ async def get_laf_items(
location: Optional[list[str]] = Query(
None, description="List of possible locations"
),
- description: Optional[str] = Query(None, description="Description of the item"),
- type: Optional[str] = Query(None, description="Type of the item"),
+ description: Optional[DescriptionFilter] = Query(
+ None, description="Description of the item"
+ ),
+ type: Optional[TypeFilter] = Query(None, description="Type of the item"),
archived: bool = Query(False, description="Archived items"),
- id: Optional[int] = Query(None, description="ID of the item"),
+ id: Optional[int] = Query(None, description="ID of the item", ge=1),
auth: dict = Depends(required_auth),
) -> LAFItemsResponse:
+ sanitized_locations = (
+ [sanitize_text(x, max_len=LOCATION_MAX_LEN) for x in location]
+ if location is not None
+ else None
+ )
dict_laf_filters = {
"date": date,
"dateFilter": dateFilter,
- "location": location,
+ "location": sanitized_locations,
"description": description,
"type": type,
"id": id,
@@ -197,11 +214,16 @@ async def get_laf_items_expired(
umbrella: int = Query(90, description="Umbrella days to expiration"),
inexpensive: int = Query(180, description="Inexpensive days to expiration"),
expensive: int = Query(365, description="Expensive days to expiration"),
- type: str = Query("All", description="Type of the item"),
+ type: TypeFilter = Query("All", description="Type of the item"),
auth: dict = Depends(required_auth),
) -> ExpireLAFItemsReponse:
laf_items = await retrieve_expired_laf(
- water_bottle, clothing, umbrella, inexpensive, expensive, type
+ water_bottle,
+ clothing,
+ umbrella,
+ inexpensive,
+ expensive,
+ type,
)
return ExpireLAFItemsReponse(data=laf_items, message="Retrieved expired LAF items")
@@ -278,7 +300,8 @@ async def new_lost_report(
dict_lost_report = jsonable_encoder(lost_report)
dict_lost_report["location"] = [
- location.strip() for location in dict_lost_report["location"].split(",")
+ sanitize_text(location, max_len=LOCATION_MAX_LEN)
+ for location in dict_lost_report["location"].split(",")
]
new_lost_report = await add_lost_report(dict_lost_report, authenticated)
return LostReportItemResponse(
@@ -297,17 +320,24 @@ async def get_lost_reports(
location: Optional[list[str]] = Query(
None, description="List of possible locations"
),
- description: Optional[str] = Query(None, description="Description of the item"),
- type: Optional[str] = Query(None, description="Type of the item"),
- name: Optional[str] = Query(None, description="Name of the owner"),
- email: Optional[str] = Query(None, description="Email of the owner"),
+ description: Optional[DescriptionFilter] = Query(
+ None, description="Description of the item"
+ ),
+ type: Optional[TypeFilter] = Query(None, description="Type of the item"),
+ name: Optional[NameFilter] = Query(None, description="Name of the owner"),
+ email: Optional[EmailStr] = Query(None, description="Email of the owner"),
archived: bool = Query(False, description="Archived items"),
auth: bool = Depends(required_auth),
) -> LostReportItemsResponse:
+ sanitized_locations = (
+ [sanitize_text(x, max_len=LOCATION_MAX_LEN) for x in location]
+ if location is not None
+ else None
+ )
dict_lost_report_filters = {
"date": date,
"dateFilter": dateFilter,
- "location": location,
+ "location": sanitized_locations,
"description": description,
"type": type,
"name": name,
diff --git a/server/routes/loanertech.py b/server/routes/loanertech.py
index ec8dcfe..8da1d48 100644
--- a/server/routes/loanertech.py
+++ b/server/routes/loanertech.py
@@ -1,6 +1,6 @@
from typing import Tuple
-from fastapi import APIRouter, Body, Depends, HTTPException, status
+from fastapi import APIRouter, Body, Depends, HTTPException, Path, status
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
@@ -13,7 +13,7 @@
update_loanertech,
)
from server.helpers.auth import required_auth, simple_auth_check
-from server.models import BoolResponse
+from server.models.common import BoolResponse
from server.models.loanertech import (
LoanerTechCheckin,
LoanerTechCheckout,
@@ -60,7 +60,7 @@ async def add_loanertech_data(
@router.get("/{id}", response_description="LoanerTech data retrieved")
-async def get_loanertech_data(id: int) -> LoanerTechResponse:
+async def get_loanertech_data(id: int = Path(..., ge=1)) -> LoanerTechResponse:
loanertech = await retrieve_loanertech(id)
if loanertech:
return LoanerTechResponse(
@@ -77,7 +77,7 @@ async def get_loanertech_data(id: int) -> LoanerTechResponse:
response_model=BoolResponse,
)
async def update_loanertech_data(
- id: int,
+ id: int = Path(..., ge=1),
req: LoanerTechRequest = Body(...),
auth: dict = Depends(required_auth),
) -> BoolResponse:
@@ -95,7 +95,7 @@ async def update_loanertech_data(
response_model=BoolResponse,
)
async def delete_loanertech_data(
- id: int,
+ id: int = Path(..., ge=1),
auth: dict = Depends(required_auth),
) -> BoolResponse:
if await delete_loanertech(id):
diff --git a/tests/test_sanitize.py b/tests/test_sanitize.py
new file mode 100644
index 0000000..6aec76c
--- /dev/null
+++ b/tests/test_sanitize.py
@@ -0,0 +1,85 @@
+from server.helpers.sanitize import (
+ is_valid_object_id,
+ reject_mongo_operators,
+ sanitize_text,
+ strip_tags,
+)
+
+
+def test_strip_tags_removes_html_and_scripts():
+ html = "hello world"
+ assert strip_tags(html) == "alert('x')hello world"
+
+
+def test_sanitize_text_normalizes_whitespace_and_limits():
+ text = " Hello\n\tworld "
+ assert sanitize_text(text) == "Hello world"
+ long = "a" * 10
+ assert sanitize_text(long, max_len=5) == "aaaaa"
+
+
+def test_is_valid_object_id():
+ # Valid cases
+ assert is_valid_object_id("0" * 24)
+ assert is_valid_object_id("a" * 24) # lowercase
+ assert is_valid_object_id("A" * 24) # uppercase
+ assert is_valid_object_id("AaBbCc012345678901234567") # mixed case (24 chars)
+ assert is_valid_object_id(
+ "0123456789abcdefABCDEF01"
+ ) # mixed case with all hex chars (24 chars)
+
+ # Invalid cases
+ assert not is_valid_object_id("g" * 24) # invalid hex character
+ assert not is_valid_object_id("123") # too short
+ assert not is_valid_object_id("") # empty string
+ assert not is_valid_object_id("0" * 23) # too short (23 chars)
+ assert not is_valid_object_id("0" * 25) # too long (25 chars)
+
+ # Non-string types
+ assert not is_valid_object_id(None)
+ assert not is_valid_object_id(123)
+ assert not is_valid_object_id(123456789012345678901234) # integer
+ assert not is_valid_object_id([])
+ assert not is_valid_object_id({})
+
+
+def test_reject_mongo_operators_allows_safe():
+ # Test dicts with nested structures
+ safe = {"name": "ok", "nested": {"list": [1, 2, {"a": "b"}]}}
+ assert reject_mongo_operators(safe) is safe
+
+ # Test tuples containing safe data
+ safe_tuple = ({"a": 1}, {"b": 2})
+ assert reject_mongo_operators(safe_tuple) is safe_tuple
+
+ # Test sets containing safe data
+ safe_set = {1, 2, 3, "test"}
+ assert reject_mongo_operators(safe_set) is safe_set
+
+ # Test primitives pass through unchanged
+ assert reject_mongo_operators("string") == "string"
+ assert reject_mongo_operators(123) == 123
+ assert reject_mongo_operators(True) is True
+ assert reject_mongo_operators(None) is None
+ assert reject_mongo_operators(1.5) == 1.5
+
+
+def test_reject_mongo_operators_blocks_dangerous_keys():
+ # Test dangerous keys in top-level dict
+ # Test dangerous keys in nested structures within lists
+ # Test dangerous keys in nested structures within tuples
+ test_cases = [
+ {"$where": 1},
+ {"nested": {"$gt": 5}},
+ {"a.b": 1},
+ {"nested": {"a.b": 2}},
+ [{"$ne": 1}, {"safe": "ok"}],
+ ({"$in": [1, 2]}, {"safe": "ok"}),
+ ]
+
+ for bad in test_cases:
+ try:
+ reject_mongo_operators(bad)
+ assert False, f"Expected ValueError not raised for {bad}"
+ except ValueError:
+ pass
diff --git a/uv.lock b/uv.lock
index 6214da1..9348641 100644
--- a/uv.lock
+++ b/uv.lock
@@ -47,11 +47,13 @@ version = "1.0.0"
source = { virtual = "." }
dependencies = [
{ name = "aiocache" },
+ { name = "bleach" },
{ name = "fastapi", extra = ["standard"] },
{ name = "fastapi-sso" },
{ name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "pymongo" },
+ { name = "pytest" },
{ name = "ruff" },
{ name = "sentry-sdk", extra = ["fastapi"] },
{ name = "valkey-glide" },
@@ -60,23 +62,37 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "aiocache", specifier = ">=0.12.3" },
+ { name = "bleach", specifier = ">=6.1.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.6" },
{ name = "fastapi-sso", specifier = ">=0.17.0" },
{ name = "pydantic-settings", specifier = ">=2.7.1" },
{ name = "pyjwt", specifier = ">=2.10.1" },
{ name = "pymongo", specifier = ">=4.13.2" },
+ { name = "pytest", specifier = ">=8.3.3" },
{ name = "ruff", specifier = ">=0.9.2" },
{ name = "sentry-sdk", extras = ["fastapi"], specifier = ">=2.20.0" },
{ name = "valkey-glide", specifier = ">=2.0.1" },
]
+[[package]]
+name = "bleach"
+version = "6.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "webencodings" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" },
+]
+
[[package]]
name = "certifi"
-version = "2025.11.12"
+version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
@@ -325,6 +341,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
[[package]]
name = "jinja2"
version = "3.1.6"
@@ -419,6 +444,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
]
+[[package]]
+name = "packaging"
+version = "25.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
[[package]]
name = "protobuf"
version = "6.33.2"
@@ -593,6 +636,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/fc/f352a070d8ff6f388ce344c5ddb82348a38e0d1c99346fa6bfdef07134fe/pymongo-4.15.5-cp314-cp314t-win_arm64.whl", hash = "sha256:576a7d4b99465d38112c72f7f3d345f9d16aeeff0f923a3b298c13e15ab4f0ad", size = 1051166, upload-time = "2025-12-02T18:44:09.048Z" },
]
+[[package]]
+name = "pytest"
+version = "9.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
+]
+
[[package]]
name = "python-dotenv"
version = "1.2.1"
@@ -974,6 +1033,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
+[[package]]
+name = "webencodings"
+version = "0.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" },
+]
+
[[package]]
name = "websockets"
version = "15.0.1"