Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/participant-uploads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Participant uploads and account timestamps

Profile image writes accept only base64 data URLs containing actual PNG, JPEG
or WebP images, at most 1 MiB decoded and 16 million pixels. URLs, SVG, animated
images, type mismatches and malformed images are rejected. Pillow decodes and
re-encodes only pixels so metadata and trailing payloads are not stored.

CV writes accept PDF only, at most 1 MiB and 20 pages. The parsed object graph
is inspected for JavaScript, active actions, embedded files and rich media;
encrypted and malformed PDFs are rejected. The stored PDF is rebuilt from
pages without annotations, document metadata or attachments. This is file
validation and sanitisation, not an antivirus or a guarantee against every
possible vulnerability in a PDF viewer. Keep the parsers updated.

These rules apply at request-schema validation for account creation, profile
updates, and event registration/update. Read schemas do not reinterpret legacy
values. Existing stored files are not retroactively sanitised. Deploy the
backend together with the frontend: browser restrictions alone can be bypassed.

The request body limit is 3 MiB so a profile request containing a 1 MiB image
and 1 MiB CV, with base64 overhead, can pass transport validation; each file
still has its own 1 MiB limit. Review any proxy or deployment override of
RATE_LIMIT__MAX_BODY_BYTES if large, valid uploads are rejected before the API.

New users receive a fresh UTC creation timestamp on each INSERT, rather than
the date captured when the module was imported. Profile responses include the
time and UTC offset. This fixes new records; dates already stored incorrectly
cannot be inferred or repaired without a trustworthy audit trail.
2 changes: 1 addition & 1 deletion docs/request-protection.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Redis instance, prefix and signing secret. No in-memory fallback exists.
| Password recovery, verification resend, contact, per IP | 20/hour combined | `MAIL_PER_HOUR` |
| Signup and public mail routes, per normalized email | 3/15 minutes combined | `MAIL_PER_RECIPIENT` |
| Signup and public mail routes, across all clients | 300/hour combined | `MAIL_TOTAL_PER_HOUR` |
| Request body | 1 MiB, including streamed requests | `MAX_BODY_BYTES` |
| Request body | 3 MiB, including streamed requests (two 1 MiB files encoded as base64 plus JSON) | `MAX_BODY_BYTES` |
| Receiving the complete request body | 10 seconds | `BODY_TIMEOUT_SECONDS` |

Budgets count attempts, including invalid credentials/payloads; not just successful
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ dependencies = [
"uvicorn>=0.30.0",
"redis>=5,<7",
"segno>=1.6",
"pillow>=12.0",
"pypdf>=6.0",
]

[tool.hatch.build.targets.wheel]
Expand Down
2 changes: 1 addition & 1 deletion src/configuration/Settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ class RateLimitSettings(BaseSettings):
mail_per_hour: int = Field(default=20, ge=1)
mail_per_recipient: int = Field(default=3, ge=1)
mail_total_per_hour: int = Field(default=300, ge=1)
max_body_bytes: int = Field(default=1048576, ge=1024)
max_body_bytes: int = Field(default=3145728, ge=1024)
body_timeout_seconds: float = Field(default=10, gt=0)


Expand Down
5 changes: 3 additions & 2 deletions src/impl/Event/schema.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# from __future__ import annotations
from datetime import datetime
from typing import Optional
from src.utils.uploads import Curriculum

from pydantic import Field, field_validator

Expand Down Expand Up @@ -108,7 +109,7 @@ class EventUpdate(BaseSchema):
class HackerEventRegistration(BaseSchema):
shirt_size: str
food_restrictions: str
cv: Optional[str] = None
cv: Optional[Curriculum] = None
description: Optional[str] = None
github: Optional[str] = None
linkedin: Optional[str] = None
Expand All @@ -130,7 +131,7 @@ def shirt_size_validation(cls, v):
class HackerEventRegistrationUpdate(BaseSchema):
shirt_size: Optional[str] = None
food_restrictions: Optional[str] = None
cv: Optional[str] = None
cv: Optional[Curriculum] = None
description: Optional[str] = None
github: Optional[str] = None
linkedin: Optional[str] = None
Expand Down
5 changes: 3 additions & 2 deletions src/impl/Hacker/schema.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Optional
from src.utils.uploads import Curriculum

from src.impl.User.schema import UserCreate, UserGet, UserGetAll, UserUpdate

Expand All @@ -9,7 +10,7 @@ class HackerCreate(UserCreate):
study_center: Optional[str] = None
location: Optional[str] = None
how_did_you_meet_us: Optional[str] = None
cv: Optional[str] = None
cv: Optional[Curriculum] = None


class HackerGet(UserGet):
Expand All @@ -35,4 +36,4 @@ class HackerUpdate(UserUpdate):
study_center: Optional[str] = None
location: Optional[str] = None
how_did_you_meet_us: Optional[str] = None
cv: Optional[str] = None
cv: Optional[Curriculum] = None
10 changes: 7 additions & 3 deletions src/impl/User/model.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import date
from datetime import date, datetime, timezone
from typing import Optional

from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
Expand All @@ -23,8 +23,12 @@ class User(BaseModel):
address: Mapped[Optional[str]] = mapped_column(String)
shirt_size: Mapped[Optional[str]] = mapped_column(String)
type: Mapped[Optional[str]] = mapped_column(String)
created_at: Mapped[Optional[date]] = mapped_column(DateTime, default=date.today())
updated_at: Mapped[Optional[date]] = mapped_column(DateTime, default=date.today())
created_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None)
)
updated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None)
)
image: Mapped[str] = mapped_column(String, default="")
# is_image_url: bool = mapped_column(Boolean, default=False)
code: Mapped[str] = mapped_column(String, default="", unique=True, index=True)
Expand Down
15 changes: 10 additions & 5 deletions src/impl/User/schema.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import re
from datetime import date
from datetime import date, datetime, timezone
from typing import Optional

from pydantic import field_validator
from pydantic import field_validator, field_serializer
from src.utils.uploads import ProfileImage

from src.impl.UserConfig.schema import UserConfigCreate, UserConfigGetAll
from src.utils.Base.BaseSchema import BaseSchema
Expand All @@ -19,7 +20,7 @@ class UserCreate(BaseSchema):
telephone: str
address: Optional[str] = None
shirt_size: Optional[str] = None
image: Optional[str] = None
image: Optional[ProfileImage] = None
config: UserConfigCreate
# is_image_url: Optional[bool] = None = None
# recive_mails: Optional[bool] = None = None
Expand Down Expand Up @@ -63,10 +64,14 @@ def shirt_size_validation(cls, v):
class UserGet(BaseSchema):
name: str
nickname: str
created_at: date
created_at: datetime
type: str
image: Optional[str] = None

@field_serializer("created_at")
def serialize_created_at(self, value):
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value


class UserGetAll(UserGet):
id: int
Expand All @@ -92,7 +97,7 @@ class UserUpdate(BaseSchema):
telephone: Optional[str] = None
address: Optional[str] = None
shirt_size: Optional[str] = None
image: Optional[str] = None
image: Optional[ProfileImage] = None
# is_image_url: Optional[bool] = None
# recive_mails: Optional[bool] = None

Expand Down
126 changes: 126 additions & 0 deletions src/utils/uploads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Validate untrusted participant uploads and store a canonical, inert format."""
import base64
import binascii
import io
import warnings
from typing import Annotated

from PIL import Image, UnidentifiedImageError
from pydantic import AfterValidator
from pypdf import PdfReader, PdfWriter
from pypdf.generic import ArrayObject, DictionaryObject, IndirectObject

MAX_FILE_BYTES = 1024 * 1024
MAX_IMAGE_PIXELS = 16_000_000
MIME_FORMATS = {"image/png": "PNG", "image/jpeg": "JPEG", "image/webp": "WEBP"}


def decode_upload(value, allowed):
if len(value) > 4 * ((MAX_FILE_BYTES + 2) // 3) + 64:
raise ValueError("File exceeds 1 MiB")
header, separator, encoded = value.partition(",")
if not separator or not header.startswith("data:") or not header.endswith(";base64"):
raise ValueError("Upload a file, not a URL")
mime = header[5:-7]
if mime not in allowed:
raise ValueError("Unsupported file type")
try:
raw = base64.b64decode(encoded, validate=True)
except (ValueError, binascii.Error) as exc:
raise ValueError("Invalid file encoding") from exc
if not raw or len(raw) > MAX_FILE_BYTES:
raise ValueError("File must be non-empty and at most 1 MiB")
return mime, raw


def encode_upload(mime, raw):
if len(raw) > MAX_FILE_BYTES:
raise ValueError("Processed file exceeds 1 MiB")
return f"data:{mime};base64," + base64.b64encode(raw).decode("ascii")


def validate_image(value):
if value in (None, ""):
return value
mime, raw = decode_upload(value, MIME_FORMATS)
try:
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
with Image.open(io.BytesIO(raw)) as image:
if image.format != MIME_FORMATS[mime]:
raise ValueError("Image content does not match its type")
if image.width * image.height > MAX_IMAGE_PIXELS or getattr(image, "n_frames", 1) != 1:
raise ValueError("Image dimensions or animation are not supported")
image.verify()
with Image.open(io.BytesIO(raw)) as image:
# Copy only decoded pixels: discard EXIF, embedded text, trailing
# polyglot payloads and all other user-provided metadata.
mode = "RGB" if mime == "image/jpeg" else "RGBA"
clean = Image.new(mode, image.size)
clean.paste(image.convert(mode))
output = io.BytesIO()
clean.save(output, format=MIME_FORMATS[mime])
return encode_upload(mime, output.getvalue())
except (UnidentifiedImageError, OSError, Image.DecompressionBombError,
Image.DecompressionBombWarning) as exc:
raise ValueError("Invalid or unsafe image") from exc


# Reject active content anywhere in the reachable object graph, including
# encoded PDF names and indirect references (a byte-string search is not enough).
FORBIDDEN_PDF_KEYS = {"/AA", "/OpenAction", "/JS", "/JavaScript", "/EmbeddedFiles",
"/EF", "/XFA", "/RichMediaContent", "/RichMediaSettings"}
FORBIDDEN_PDF_ACTIONS = {"/JavaScript", "/Launch", "/SubmitForm", "/ImportData",
"/GoToR", "/GoToE", "/Rendition", "/RichMediaExecute"}


def validate_cv(value):
if value in (None, ""):
return value
mime, raw = decode_upload(value, {"application/pdf"})
if not raw.startswith(b"%PDF-"):
raise ValueError("CV must contain a PDF document")
try:
reader = PdfReader(io.BytesIO(raw), strict=True)
if reader.is_encrypted:
raise ValueError("Encrypted PDFs are not supported")
visited = set()
pending = [reader.trailer]
count = 0
while pending:
obj = pending.pop()
count += 1
if count > 50_000:
raise ValueError("PDF is too complex")
if isinstance(obj, IndirectObject):
key = (obj.idnum, obj.generation)
if key in visited:
continue
visited.add(key)
obj = obj.get_object()
if isinstance(obj, DictionaryObject):
if FORBIDDEN_PDF_KEYS.intersection(obj.keys()) or obj.get("/S") in FORBIDDEN_PDF_ACTIONS:
raise ValueError("PDF contains active content or attachments")
if obj.get("/Type") == "/EmbeddedFile" or obj.get("/Subtype") in {"/RichMedia", "/FileAttachment"}:
raise ValueError("PDF contains embedded content")
pending.extend(obj.values())
elif isinstance(obj, ArrayObject):
pending.extend(obj)
if not 1 <= len(reader.pages) <= 20:
raise ValueError("CV must contain between 1 and 20 pages")
writer = PdfWriter()
for page in reader.pages:
# Retain page content, but not actions, interactive widgets, links,
# document metadata, forms or trailing non-PDF payloads.
writer.add_page(page, excluded_keys=["/Annots", "/AA"])
output = io.BytesIO()
writer.write(output)
return encode_upload(mime, output.getvalue())
except ValueError:
raise
except Exception as exc:
raise ValueError("Invalid or unsafe PDF") from exc


ProfileImage = Annotated[str, AfterValidator(validate_image)]
Curriculum = Annotated[str, AfterValidator(validate_cv)]
16 changes: 13 additions & 3 deletions tests/test_privacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,26 @@ def test_pending_profile_does_not_expose_verification_token(client, signup_paylo
assert_no_credentials(response.json())


def test_verification_response_has_no_credentials(client, signup_payload, engine):
def test_verification_returns_only_the_new_verified_session(client, signup_payload, engine):
from src.impl.User.model import User

signup = client.post("/v1/hacker/signup", json=signup_payload).json()
with Session(engine) as session:
token = session.get(User, signup["user_id"]).verification_token
response = client.post("/v1/auth/verify", params={"token": token})
assert response.status_code == 200, response.text
assert response.json() == {"success": True}
assert_no_credentials(response.json())
# Verification now intentionally starts a session (existing API contract).
# Check the narrow response and ownership rather than treating the newly
# issued session tokens as leaked database credentials.
result = response.json()
assert set(result) == {"success", "user_id", "access_token", "refresh_token", "token_type"}
assert result["success"] is True
assert result["user_id"] == signup["user_id"]
profile = client.get("/v1/auth/me", headers={"Authorization": f"Bearer {result['access_token']}"})
assert profile.status_code == 200
assert profile.json()["id"] == signup["user_id"]
assert profile.json()["is_verified"] is True
assert_no_credentials(profile.json())


def test_hacker_cannot_read_organizer_nif(client, signup_payload, engine):
Expand Down
Loading
Loading