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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,7 @@ jobs:
run: npm run typecheck
- name: Tests
run: npm test
# typecheck and tests both pass on a tree that fails to bundle, so main
# could break the deploy without CI noticing.
- name: Build
run: npm run build
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,34 @@

All notable changes to this project are documented in this file.

## [Unreleased]

### Security

- **Refuse to start with a missing or weak `JWT_SECRET`.** An unset secret produced a zero-length signing key that `jose` would still sign *and verify* with, so a deploy that forgot to set it would have accepted forged sessions for any account, silently. A secret of at least 32 characters is now required.
- **Replaced bcryptjs with PBKDF2 (WebCrypto).** bcrypt cost-10 burns ~74ms of CPU; the Workers Free plan allows 10ms per request, so login and registration hard-failed on any fork deployed there. Legacy bcrypt hashes still verify and are re-hashed on next login. See `worker/lib/password.ts` for the iteration count and the trade-off behind it.
- **Password changes now revoke existing sessions** via `users.token_version`. Previously a session stayed valid for its full 30 days after the password changed.
- **Closed a user-enumeration timing leak on login** — an unknown email skipped hashing and answered measurably faster than a real account.
- **Rate limited the credential endpoints** (10/min/IP).
- **Rejected `javascript:` and `data:` URLs** on `job.url` and `contact.linkedin`, both of which are rendered into an `<a href>`. `contact.linkedin` had no URL validation at all.
- **Added a CSP** and security headers, on both static assets and `/api/*` responses, and self-hosted the font so the app makes no third-party request.
- **Registration no longer creates an orphan account when `JWT_SECRET` is broken.** It hashed and INSERTed the user, then threw while signing the session — leaving a row behind that turned the retry into a `409` for an account nobody could log into. Session config is now validated before any handler writes.
- **`PBKDF2_ITERATIONS` is configurable**, so a Paid deployment can run OWASP's 600k. Existing hashes upgrade on next login rather than needing a reset.

### Fixed

- Registration races return `409` instead of `500`.
- `/api/stats` excludes archived jobs from *every* stat; previously only the funnel filtered them, so archiving a job removed it from the funnel while it still dragged on the response rate.
- Changing status from the job drawer now reorders correctly — it went through `PATCH /jobs/:id`, which never updated `sort_order`, so the card landed at an arbitrary slot in its new column.
- Validation errors name the field that actually failed instead of always saying "Company and title are required".

### Changed

- The API is split into explicit public and protected routers, so a route's protection follows from where it is mounted rather than which line it sits on.
- CI now runs the build.

> **Upgrading:** run `npm run db:migrate:remote` (adds `users.token_version`) and make sure `JWT_SECRET` is at least 32 characters before deploying.

## [0.1.0] — 2026-07-14

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ npm run dev # → http://localhost:5173

## Tech stack

React 19 · TypeScript · Vite · Tailwind CSS v4 · dnd-kit · Hono · Cloudflare Workers · D1 (SQLite) · bcryptjs · jose · Zod
React 19 · TypeScript · Vite · Tailwind CSS v4 · dnd-kit · Hono · Cloudflare Workers · D1 (SQLite) · WebCrypto PBKDF2 · jose · Zod

## License

Expand Down
13 changes: 10 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,16 @@ Request → Worker (fetch handler)

### Authentication

1. **Registration/Login** — validates with Zod, hashes password (bcryptjs, cost 10), signs JWT (HS256, jose), sets httpOnly cookie `tracker_auth`
2. **Verification** — `requireAuth` middleware reads cookie, verifies JWT, sets `c.set("userId", userId)`
3. **Logout** — clears the cookie server-side
1. **Registration/Login** — validates with Zod, hashes the password (PBKDF2-HMAC-SHA256 via WebCrypto), signs a JWT (HS256, jose), sets the httpOnly cookie `tracker_auth`
2. **Verification** — `requireAuth` reads the cookie, verifies the JWT, checks the token version against the database, then sets `c.set("userId", userId)`
3. **Logout** — clears the auth cookie in the response. It does **not** revoke the JWT: the token stays valid until it expires, so logout protects the browser it ran in, not a token that has already been stolen. Revocation is what `token_version` is for.
4. **Password change** — increments `users.token_version`, which invalidates every session issued before it

Notes:

- **Why PBKDF2 and not bcrypt.** `bcryptjs` is pure JS and costs ~74ms of CPU per hash. The Workers **Free** plan allows 10ms per request, so login hard-failed there. PBKDF2 runs natively. See `worker/lib/password.ts` for the iteration count and the strength/cost trade-off behind it — it is a deliberate compromise, not a default.
- **Legacy hashes.** Accounts created under bcrypt still log in and are transparently re-hashed to PBKDF2 on their next successful login.
- **Rate limiting.** The credential endpoints are throttled per-IP via Cloudflare's rate-limit binding. The binding is optional: without it the endpoints still work and log a warning.

### Database

Expand Down
27 changes: 25 additions & 2 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,31 @@ These go in `.dev.vars` for local development and are set via `wrangler secret p

| Variable | Required | Description |
|---|---|---|
| `JWT_SECRET` | Yes | Used to sign auth cookies. Must be at least 32 characters. |
| `JWT_SECRET` | Yes | Signs the auth cookie. Must be at least 32 characters — enforced at runtime, not merely advised. Generate with `openssl rand -base64 32`. |
| `ALLOW_REGISTRATION` | No | Set to `"true"` to allow new account signups. Defaults to `"false"`. |
| `PBKDF2_ITERATIONS` | No | Password-hash work factor. Defaults to `50000`, chosen to fit the Workers **Free** plan's 10ms CPU cap. Values below the default are ignored. |

### Hardening the password hash on the Paid plan

The default of 50,000 iterations is a deliberate compromise: the Workers Free plan allows 10ms of CPU per request, and no password hash is both OWASP-grade and that cheap — being slow is the entire point of one. The default keeps a free-tier fork deployable, at roughly 12x below OWASP's guidance for PBKDF2-SHA256.

On the **Paid** plan that cap doesn't apply (30s default), so raise it:

```bash
npx wrangler secret put PBKDF2_ITERATIONS # e.g. 600000 (OWASP), ~72ms per login
```

Existing accounts are not invalidated. Each hash records the iteration count it was created with, verification reads that value, and an account is re-hashed at the current setting on its next successful login.

> **If `JWT_SECRET` is missing or too short the app refuses to issue a session and returns a 500.** That is deliberate: an empty key still produces JWTs that verify, so a deploy that forgot the secret would silently accept forged sessions for any account. Failing loudly is the safe behaviour.

## Bindings

| Binding | Required | Description |
|---|---|---|
| `DB` | Yes | D1 database. |
| `ASSETS` | Yes | Static asset fetcher for the SPA. |
| `AUTH_RATE_LIMITER` | No | Rate-limit binding declared in `wrangler.jsonc` (10 attempts/min/IP on the credential endpoints). Without it those endpoints still work but are **not** throttled, and a warning is logged. |

### `ALLOW_REGISTRATION` pattern

Expand Down Expand Up @@ -66,7 +89,7 @@ See `migrations/0001_init.sql` and `migrations/0002_salary_currency_period.sql`

| Table | Purpose |
|---|---|
| `users` | Email + bcrypt password hash |
| `users` | Email + PBKDF2 password hash + token_version |
| `jobs` | Job applications with status, salary, metadata |
| `contacts` | Per-job contact people |
| `activities` | Per-job timeline entries |
Expand Down
5 changes: 4 additions & 1 deletion docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,10 @@ npm run db:migrate:remote

```bash
npx wrangler secret put JWT_SECRET
# Paste your secret value when prompted (min 32 chars recommended)
# Generate one with: openssl rand -base64 32
# At least 32 characters — this is ENFORCED, not advisory. A missing or short
# secret makes the app refuse to issue sessions (it would otherwise sign them
# with an empty key, which anyone could forge).

npx wrangler secret put ALLOW_REGISTRATION
# Set to "true" initially, then "false" after creating your account
Expand Down
8 changes: 2 additions & 6 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tracker — Job Applications</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg width='32' height='32' viewBox='0 0 32 32' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='2' y='2' width='28' height='28' fill='%23FFD60A' stroke='%23111' stroke-width='3'/%3E%3Ccircle cx='16' cy='16' r='6' fill='none' stroke='%23111' stroke-width='3'/%3E%3Ccircle cx='16' cy='16' r='2' fill='%23111'/%3E%3C/svg%3E" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700;800&display=swap"
rel="stylesheet"
/>
<!-- Space Grotesk is self-hosted; see the @font-face in src/index.css. -->
<link rel="preload" href="/fonts/space-grotesk-var.woff2" as="font" type="font/woff2" crossorigin />
</head>
<body>
<div id="root"></div>
Expand Down
7 changes: 7 additions & 0 deletions migrations/0003_token_version.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Session JWTs were stateless, so changing your password rehashed the secret
-- but left every already-issued cookie valid for its full 30-day life. A
-- stolen session survived the exact action taken to revoke it.
--
-- token_version is embedded in each JWT and compared on every authenticated
-- request. Bumping it invalidates all outstanding sessions for that user.
ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0;
15 changes: 15 additions & 0 deletions public/_headers
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: DENY
Strict-Transport-Security: max-age=31536000; includeSubDomains
Permissions-Policy: geolocation=(), microphone=(), camera=(), interest-cohort=()
Comment on lines +1 to +7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Apply these security headers to Worker-generated responses too.

public/_headers only covers static asset responses; /api/* is handled by Worker code, so those responses won't inherit this CSP/header policy. Add the shared headers in the Worker response path as well, or document that this policy is static-assets only.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@public/_headers` around lines 1 - 7, Apply the security headers defined in
public/_headers to Worker-generated /api/* responses by adding them in the
Worker response path, reusing the same policy values for CSP and related
headers. Ensure all relevant Worker responses receive the shared headers rather
than limiting the policy to static assets.


# style-src needs 'unsafe-inline' because dnd-kit writes drag transforms to
# inline style attributes, which CSP treats as inline styles. script-src does
# NOT get it: that is the directive that matters here, and with it in place a
# stored javascript: URL has nothing to execute with.
#
# font-src 'self' is why the Space Grotesk woff2 is served from public/fonts
# rather than fonts.gstatic.com.
Binary file added public/fonts/space-grotesk-var.woff2
Binary file not shown.
14 changes: 14 additions & 0 deletions shared/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
// `new URL()` — which is all zod's .url() checks — happily accepts
// "javascript:alert(1)" and "data:text/html,…". Both of those become live
// script when dropped into an <a href>, so every user-supplied URL is checked
// against this before it is stored OR rendered. Rendering is checked too, not
// just writing: rows saved before this existed may already hold a bad scheme.
export function safeExternalUrl(url: string | null | undefined): string | null {
if (!url) return null;
try {
return /^https?:$/.test(new URL(url).protocol) ? url : null;
} catch {
return null; // not a parseable absolute URL
}
}

export const JOB_STATUSES = ["wishlist", "applied", "interview", "offer", "rejected"] as const;
export type JobStatus = (typeof JOB_STATUSES)[number];

Expand Down
27 changes: 20 additions & 7 deletions src/components/JobDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ import {
JOB_STATUSES,
PERIODS,
STATUS_LABELS,
safeExternalUrl,
type Activity,
type Contact,
type Job,
type JobStatus,
type Reminder,
} from "../../shared/types";
import { Button } from "./ui/button";
Expand All @@ -44,7 +46,9 @@ const TAB_ICONS: Record<Tab, LucideIcon> = {
interface Props {
job: Job;
onClose: () => void;
onChange: (job: Job) => void;
// Takes every job the server changed, not just this one: moving a card
// renumbers its whole destination column.
onChange: (jobs: Job[]) => void;
onDelete: (id: string) => void;
}

Expand All @@ -63,7 +67,16 @@ export default function JobDrawer({ job, onClose, onChange, onDelete }: Props) {
useFocusTrap(asideRef, true, onClose);

const patch = async (fields: Partial<Job>) => {
onChange(await api.patch<Job>(`/jobs/${job.id}`, fields));
onChange([await api.patch<Job>(`/jobs/${job.id}`, fields)]);
};

// Status has to go through /move, not PATCH. PATCH changes the status column
// but never touches sort_order, so the card kept the ordering value from the
// column it left and landed at an arbitrary slot in the one it joined.
// /move renumbers the destination column and returns all of its rows.
const changeStatus = async (status: JobStatus) => {
if (status === job.status) return;
onChange(await api.patch<Job[]>(`/jobs/${job.id}/move`, { status, index: 0 }));
};

const remove = async () => {
Expand Down Expand Up @@ -93,7 +106,7 @@ export default function JobDrawer({ job, onClose, onChange, onDelete }: Props) {
<div className="mt-3 flex items-center gap-2">
<select
value={job.status}
onChange={(e) => patch({ status: e.target.value as Job["status"] })}
onChange={(e) => changeStatus(e.target.value as JobStatus)}
className="border-[3px] border-brut-ink bg-input px-3 py-1.5 text-xs font-bold uppercase tracking-wider text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background transition-all"
>
{JOB_STATUSES.map((s) => (
Expand All @@ -102,9 +115,9 @@ export default function JobDrawer({ job, onClose, onChange, onDelete }: Props) {
</option>
))}
</select>
{job.url && (
{safeExternalUrl(job.url) && (
<a
href={job.url}
href={safeExternalUrl(job.url)!}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1 text-xs font-bold uppercase tracking-wider text-brut-applied underline decoration-2 underline-offset-2"
Expand Down Expand Up @@ -426,8 +439,8 @@ function ContactsTab({ jobId }: { jobId: string }) {
{ct.email}
</a>
)}
{ct.linkedin && (
<a href={ct.linkedin} target="_blank" rel="noreferrer" className="flex items-center gap-1 text-brut-applied underline decoration-2 underline-offset-2">
{safeExternalUrl(ct.linkedin) && (
<a href={safeExternalUrl(ct.linkedin)!} target="_blank" rel="noreferrer" className="flex items-center gap-1 text-brut-applied underline decoration-2 underline-offset-2">
LinkedIn
<ExternalLink size={11} strokeWidth={2.5} />
</a>
Expand Down
14 changes: 14 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
@import "tailwindcss";

/* Self-hosted so the app makes no third-party request on load: the Google Fonts
<link> leaked every visitor's IP and User-Agent to Google, blocked the first
paint on a server we don't control, and forced font-src to allow an external
origin in the CSP. This is the latin subset of the variable face (300–700).
Note the design uses font-black (900), which this face does not contain — the
browser synthesises it, exactly as it did when Google served the font. */
@font-face {
font-family: "Space Grotesk";
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url("/fonts/space-grotesk-var.woff2") format("woff2");
}

@theme {
--font-sans: "Space Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-head: "Space Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
Expand Down
4 changes: 3 additions & 1 deletion src/pages/Board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ export default function Board() {
<JobDrawer
job={openJob}
onClose={() => setOpenJobId(null)}
onChange={(j) => setJobs((js) => js.map((x) => (x.id === j.id ? j : x)))}
onChange={(updated) =>
setJobs((js) => js.map((x) => updated.find((u) => u.id === x.id) ?? x))
}
onDelete={(id) => {
setJobs((js) => js.filter((x) => x.id !== id));
setOpenJobId(null);
Expand Down
2 changes: 1 addition & 1 deletion src/pages/TableView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export default function TableView() {
<JobDrawer
job={openJob}
onClose={() => setOpenJobId(null)}
onChange={(j) => setJobs((js) => js.map((x) => (x.id === j.id ? j : x)))}
onChange={(updated) => setJobs((js) => js.map((x) => updated.find((u) => u.id === x.id) ?? x))}
onDelete={(id) => { setJobs((js) => js.filter((x) => x.id !== id)); setOpenJobId(null); }}
/>
)}
Expand Down
Loading
Loading