Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ What shipped in each release, newest first. The format follows [Keep a Changelog

### Fixed

- Password forms stop input at 72 bytes and explain the limit in plain words, and `make install` installs the backend test tools so `make test` runs on a fresh machine ([#529](https://github.com/vidithq/vidit/pull/529)).
- Local storage refuses a key that escapes its root on delete too, which clears the CodeQL path-injection alerts ([#525](https://github.com/vidithq/vidit/pull/525)).
- The narrow-viewport smoke job no longer fails at random on the collection pages ([#374](https://github.com/vidithq/vidit/pull/374)).
- The map detail panel shows only the selected event, and a failed load shows the error with a Retry control ([#357](https://github.com/vidithq/vidit/pull/357)).
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ seed: mock-admin seed-detections
@echo "Done. admin@vidit.app exists and the synthetic archive's detections are in."

install:
cd backend && uv sync
cd backend && uv sync --all-extras
cd frontend && npm install

env:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ make test # backend pytest

- **Database connection failed**: ensure `docker-compose up -d` is running and nothing else holds port 5432.
- **Frontend can't reach the API**: check `NEXT_PUBLIC_API_URL` in `frontend/.env.local` is `http://localhost:8000/api/v1`.
- **"Module not found"**: re-run `uv sync` (backend) / `npm install` (frontend), or `make install` for both.
- **"Module not found"**: re-run `uv sync --all-extras` (backend) / `npm install` (frontend), or `make install` for both.

---

Expand Down
9 changes: 5 additions & 4 deletions backend/app/schemas/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
# Mirrored by ``frontend/src/lib/auth.ts`` (``PASSWORD_MIN_LENGTH``).
PASSWORD_MIN_LENGTH = 8
# bcrypt reads at most 72 bytes, so the ceiling is in UTF-8 bytes, not characters.
# Mirrored by ``frontend/src/lib/auth.ts`` (``PASSWORD_MAX_BYTES``).
PASSWORD_MAX_BYTES = 72


Expand All @@ -22,12 +23,12 @@ def _within_bcrypt_limit(password: str) -> str:
return password


# A password a new credential is hashed from (register, reset, change). A
# character count never exceeds the byte count, so ``max_length`` only stops
# huge input early; the byte check is the rule.
# A password a new credential is hashed from (register, reset, change). No
# ``max_length``: it would answer an over-long ASCII password with Pydantic's
# generic message before the byte check runs.
NewPassword = Annotated[
str,
Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_BYTES),
Field(min_length=PASSWORD_MIN_LENGTH),
AfterValidator(_within_bcrypt_limit),
]

Expand Down
15 changes: 15 additions & 0 deletions backend/tests/test_auth_change_password.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,21 @@ def test_change_password_refuses_a_new_password_over_72_bytes(client, user_facto
assert ok.status_code == 200


def test_change_password_explains_the_byte_limit_for_an_over_long_ascii_password(
client, user_factory
):
user, current = user_factory()
response = client.post(
"/api/v1/auth/change-password",
json={"current_password": current, "new_password": "a" * 73},
headers=login_as(client, user),
)
assert response.status_code == 422
(error,) = response.json()["detail"]
assert error["type"] == "password_too_long"
assert "72 bytes" in error["msg"]


@pytest.mark.parametrize("current", ["茅" * 40, "a" * 201], ids=["80-bytes", "201-chars"])
def test_change_password_treats_an_over_long_current_password_as_incorrect(
client, user_factory, current
Expand Down
2 changes: 1 addition & 1 deletion docs/engineering.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ Every other frontend copy of a backend rule is hand-kept and listed below. Chang
|---|---|---|
| `lib/mediaTypes.ts` | `storage.ALLOWED_IMAGE_TYPES`, `ALLOWED_VIDEO_TYPES` | picker accepts a file the API refuses |
| `lib/coordinates.ts` | `events.validate_coordinates` | form accepts coordinates the API refuses |
| `lib/auth.ts` `PASSWORD_MIN_LENGTH` (the byte limit shows the API's 422 message) | `schemas/auth.PASSWORD_MIN_LENGTH` | password hint disagrees with the 422 |
| `lib/auth.ts` `PASSWORD_MIN_LENGTH`, `PASSWORD_MAX_BYTES` | `schemas/auth.PASSWORD_MIN_LENGTH`, `PASSWORD_MAX_BYTES` | password form accepts a password the API refuses, or refuses one it accepts |
| `lib/auth.ts` CSRF cookie and header names; the cookie name again in `proxy.ts` (edge runtime) | `auth_cookies.CSRF_COOKIE`, `CSRF_HEADER` | every write is refused |
| `lib/proofImages.ts` | `sanitize.PROOF_PLACEHOLDER_PREFIX`, `storage.safe_original_filename` | proof images lose their upload binding |
| `lib/search.ts::AUTHOR_FILTER_RE` | `event_filters.AUTHOR_FILTER_PATTERN` | author filter parses differently |
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/app/(auth)/reset-password/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { apiFetch } from "@/lib/api";
import { PASSWORD_MIN_LENGTH, validatePasswordChange } from "@/lib/auth";
import { PASSWORD_MAX_BYTES, PASSWORD_MIN_LENGTH, validatePasswordChange } from "@/lib/auth";
import { useMutation } from "@/hooks/useMutation";
import { AuthCard } from "@/components/auth/AuthCard";
import { TEXT_LINK } from "@/components/ui/styles";
Expand Down Expand Up @@ -100,6 +100,7 @@ function ResetPasswordInner() {
type="password"
required
minLength={PASSWORD_MIN_LENGTH}
maxLength={PASSWORD_MAX_BYTES}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
Expand All @@ -114,6 +115,7 @@ function ResetPasswordInner() {
type="password"
required
minLength={PASSWORD_MIN_LENGTH}
maxLength={PASSWORD_MAX_BYTES}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useState } from "react";
import { apiFetch } from "@/lib/api";
import { PASSWORD_MIN_LENGTH, validatePasswordChange } from "@/lib/auth";
import { PASSWORD_MAX_BYTES, PASSWORD_MIN_LENGTH, validatePasswordChange } from "@/lib/auth";
import { useMutation } from "@/hooks/useMutation";
import { useRequireAuth } from "@/hooks/useRequireAuth";
import { PageLoading, PageShell } from "@/components/ui/PageShell";
Expand Down Expand Up @@ -201,6 +201,7 @@ export default function SettingsPage() {
type="password"
required
minLength={PASSWORD_MIN_LENGTH}
maxLength={PASSWORD_MAX_BYTES}
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
Expand All @@ -217,6 +218,7 @@ export default function SettingsPage() {
type="password"
required
minLength={PASSWORD_MIN_LENGTH}
maxLength={PASSWORD_MAX_BYTES}
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/components/auth/RegisterForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Link from "next/link";
import { AuthCard } from "@/components/auth/AuthCard";
import { TEXT_LINK } from "@/components/ui/styles";
import { ApiError } from "@/lib/api";
import { PASSWORD_MIN_LENGTH } from "@/lib/auth";
import { PASSWORD_MAX_BYTES, PASSWORD_MIN_LENGTH, validateNewPassword } from "@/lib/auth";
import {
FORM_ERROR_BANNER,
FORM_LABEL_COMPACT,
Expand Down Expand Up @@ -58,11 +58,16 @@ export default function RegisterForm({
onSuccess: (resolvedEmail) => onSuccess(resolvedEmail),
}
);
const { error } = submitRegister;
const { error, setError } = submitRegister;
const submitting = submitRegister.loading;

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationError = validateNewPassword(password);
if (validationError) {
setError(validationError);
return;
}
await submitRegister.run();
};

Expand Down Expand Up @@ -142,6 +147,7 @@ export default function RegisterForm({
type="password"
required
minLength={PASSWORD_MIN_LENGTH}
maxLength={PASSWORD_MAX_BYTES}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
Expand Down
23 changes: 18 additions & 5 deletions frontend/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export const CSRF_HEADER = "X-CSRF-Token";

// Mirrors `schemas/auth.PASSWORD_MIN_LENGTH`; change both.
export const PASSWORD_MIN_LENGTH = 8;
// Mirrors `schemas/auth.PASSWORD_MAX_BYTES`; change both. UTF-8 bytes (the bcrypt input limit). As
// `maxLength` it caps UTF-16 units, which never outnumber UTF-8 bytes, so it never blocks a valid one.
export const PASSWORD_MAX_BYTES = 72;

export function readCsrfToken(): string | null {
if (typeof document === "undefined") return null;
Expand Down Expand Up @@ -38,17 +41,27 @@ export function hasSessionCookie(): boolean {
}

/**
* Client-side password-change guard: at least `PASSWORD_MIN_LENGTH` characters and a match
* with the confirmation. Returns the message, or `null`. `label` names the field.
* Client-side new-password guard: at least `PASSWORD_MIN_LENGTH` characters and at most
* `PASSWORD_MAX_BYTES` UTF-8 bytes. Returns the message, or `null`. `label` names the field.
*/
export function validateNewPassword(password: string, label = "Password"): string | null {
if (password.length < PASSWORD_MIN_LENGTH) {
return `${label} must be at least ${PASSWORD_MIN_LENGTH} characters.`;
}
if (new TextEncoder().encode(password).length > PASSWORD_MAX_BYTES) {
return `${label} must be at most ${PASSWORD_MAX_BYTES} bytes. A plain letter, digit or symbol takes 1 byte; an accented letter or emoji takes 2 to 4.`;
}
return null;
}

/** `validateNewPassword` plus a match with the confirmation. */
export function validatePasswordChange(
password: string,
confirm: string,
label = "New password"
): string | null {
if (password.length < PASSWORD_MIN_LENGTH) {
return `${label} must be at least ${PASSWORD_MIN_LENGTH} characters.`;
}
const invalid = validateNewPassword(password, label);
if (invalid) return invalid;
if (password !== confirm) {
return `${label}s don't match.`;
}
Expand Down
Loading