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
64 changes: 54 additions & 10 deletions packages/api/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { MiddlewareHandler } from "hono"
import { timingSafeEqual } from "node:crypto"
import { unauthorized } from "../lib/errors"

/**
* Simple token-based auth middleware.
Expand All @@ -7,23 +9,65 @@ import type { MiddlewareHandler } from "hono"
* GET, POST → public (no token required)
* PUT, DELETE, PATCH → require Bearer token
*
* BUG: The allow-list check uses `'post'` (lowercase) instead of `'POST'`.
* HTTP methods are always uppercase per RFC 7231, so POST is never matched
* as a public method — POST requests incorrectly require a token.
* HTTP methods are case-sensitive and uppercase per RFC 7231, but the incoming
* method is normalised before comparison so the allow-list cannot be bypassed
* or wrongly missed by a differently-cased method token.
*
* Fix: change `'post'` to `'POST'` in the public methods array.
* NOTE (S-3, docs/plans/2026-08-09-auth-and-user-creation-security-assessment.md):
* this allow-list is keyed on method only, with no path component, and is applied
* globally via app.use("*"). Every GET/POST route therefore inherits public access
* silently, including routes added in future. Replacing this with an explicit
* route+method list is deferred — it needs sign-off because auth.test.ts pins the
* current public-POST behaviour deliberately.
*/
const PUBLIC_METHODS: readonly string[] = ["GET", "POST"]

/** RFC 6750 §2.1 — the credential must be presented with the `Bearer` scheme. */
const BEARER = /^Bearer (.+)$/

/**
* Resolve the expected token, failing closed when it is absent.
*
* Read lazily rather than at module scope: the test suite sets process.env.API_TOKEN
* in beforeEach, which runs after import time, so a module-level throw would break
* the suite. There is deliberately no fallback value — a hardcoded default would be
* a credential in source control (CWE-798, ISM-1402), and a missing secret must deny
* access rather than silently authenticate against a publicly known string.
*/
function expectedToken(): string | undefined {
const token = process.env.API_TOKEN
return token && token.length > 0 ? token : undefined
}

/** Constant-time comparison to avoid leaking the shared prefix length (CWE-208). */
function tokensMatch(provided: string, expected: string): boolean {
const a = Buffer.from(provided, "utf8")
const b = Buffer.from(expected, "utf8")
if (a.length !== b.length) {
return false
}
return timingSafeEqual(a, b)
}

export const authMiddleware: MiddlewareHandler = async (c, next) => {
// BUG: 'post' should be 'POST' — POST is never treated as public
const publicMethods = ["GET", "post"]
const method = c.req.method.toUpperCase()

if (publicMethods.includes(c.req.method)) {
if (PUBLIC_METHODS.includes(method)) {
return next()
}

const token = c.req.header("Authorization")?.replace("Bearer ", "")
if (!token || token !== (process.env.API_TOKEN ?? "test-token")) {
return c.json({ error: "Unauthorized", status: 401 }, 401)
const expected = expectedToken()
if (!expected) {
return unauthorized(c)
}

const match = BEARER.exec(c.req.header("Authorization") ?? "")
if (!match) {
return unauthorized(c)
}

if (!tokensMatch(match[1]!, expected)) {
return unauthorized(c)
}

return next()
Expand Down
85 changes: 78 additions & 7 deletions packages/api/src/middleware/validate.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,92 @@
import type { MiddlewareHandler } from "hono"
import { badRequest } from "../lib/errors"

/**
* Validate that the request body is valid JSON and contains the required fields.
* Rules applied to a single required string field.
*
* ISM-1240 requires input received over the internet to be validated, and failing
* input to be rejected rather than coerced into shape — so every rule below results
* in a 400, never a silent transformation of the value.
*/
export function requireFields(fields: string[]): MiddlewareHandler {
export type FieldRule = {
/** Maximum accepted length in characters. Bounds unbounded writes (CWE-770). */
maxLength: number
/** Optional format constraint, e.g. email shape. */
pattern?: RegExp
}

/**
* Env for routers using `requireFields`, exposing the validated body to handlers.
*
* Handlers read `c.get("validatedBody")` rather than re-reading `c.req.json()`, so
* the values they use are the ones that passed validation — there is no path by
* which an unvalidated field reaches the data layer.
*/
export type ValidatedEnv = {
Variables: {
validatedBody: Record<string, string>
}
}

/** Control characters are rejected in all string input regardless of field. */
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/

/**
* Validate that the request body is valid JSON and that every required field is
* present, is genuinely a string, and satisfies its length and format rules.
*
* The type check is the important part: `c.req.json()` yields `any`, so without a
* runtime `typeof` guard the API will happily persist a number or a nested object
* into a field the shared `User` type declares as `string`, breaking every
* downstream consumer compiled against that type.
*/
export function requireFields(rules: Record<string, FieldRule>): MiddlewareHandler<ValidatedEnv> {
const names = Object.keys(rules)

return async (c, next) => {
let body: Record<string, unknown>
let body: unknown
try {
body = await c.req.json()
} catch {
return c.json({ error: "Invalid JSON body", status: 400 }, 400)
return badRequest(c, "Invalid JSON body")
}

if (typeof body !== "object" || body === null || Array.isArray(body)) {
return badRequest(c, "Body must be a JSON object")
}
const missing = fields.filter((f) => !(f in body) || body[f] === undefined || body[f] === "")

const source = body as Record<string, unknown>
const validated: Record<string, string> = {}

const missing = names.filter((name) => {
const value = source[name]
return value === undefined || value === null || value === ""
})
if (missing.length > 0) {
return c.json({ error: `Missing required fields: ${missing.join(", ")}`, status: 400 }, 400)
return badRequest(c, `Missing required fields: ${missing.join(", ")}`)
}
c.set("body", body)

for (const name of names) {
const rule = rules[name]
const value = source[name]

if (typeof value !== "string") {
return badRequest(c, `Field "${name}" must be a string`)
}
if (CONTROL_CHARS.test(value)) {
return badRequest(c, `Field "${name}" contains invalid characters`)
}
if (value.length > rule.maxLength) {
return badRequest(c, `Field "${name}" must be at most ${rule.maxLength} characters`)
}
if (rule.pattern && !rule.pattern.test(value)) {
return badRequest(c, `Field "${name}" is not a valid format`)
}

validated[name] = value
}

c.set("validatedBody", validated)
return next()
}
}
45 changes: 31 additions & 14 deletions packages/api/src/routes/users.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
import { Hono } from "hono"
import { db } from "../lib/db"
import { notFound } from "../lib/errors"
// BUG: missing import — `badRequest` is used below but not imported here.
// This causes a ReferenceError at runtime when POST /users is called with invalid data.
// Fix: add `badRequest` to the import from "../lib/errors"
import { requireFields, type ValidatedEnv } from "../middleware/validate"

const router = new Hono()
const router = new Hono<ValidatedEnv>()

/** RFC 5321 §4.5.3.1.3 caps a forward-path at 256 octets; 254 is the practical maximum. */
const EMAIL_MAX_LENGTH = 254
const USERNAME_MAX_LENGTH = 64
const EMAIL_PATTERN = /^[^@\s]+@[^@\s.]+(\.[^@\s.]+)+$/

/**
* List users.
*
* Email is deliberately omitted from this projection. The route is public (see the
* S-3 note in middleware/auth.ts), so returning the full record would allow
* unauthenticated bulk enumeration of every user's email address — a disclosure
* beyond the primary purpose of collection (APP 6) and a failure to take reasonable
* steps to protect personal information (APP 11). Callers that need an email must
* fetch a single user by id.
*/
router.get("/", (c) => {
return c.json(db.users.findAll())
const users = db.users.findAll().map(({ id, username, createdAt }) => ({ id, username, createdAt }))
return c.json(users)
})

router.get("/:id", (c) => {
Expand All @@ -17,15 +31,18 @@ router.get("/:id", (c) => {
return c.json(user)
})

router.post("/", async (c) => {
const body = await c.req.json().catch(() => null)
if (!body || !body.username || !body.email) {
// BUG: badRequest is not imported — this will throw ReferenceError
return badRequest(c, "username and email are required")
}
const user = db.users.create({ username: body.username, email: body.email })
return c.json(user, 201)
})
router.post(
"/",
requireFields({
username: { maxLength: USERNAME_MAX_LENGTH },
email: { maxLength: EMAIL_MAX_LENGTH, pattern: EMAIL_PATTERN },
}),
(c) => {
const { username, email } = c.get("validatedBody")
const user = db.users.create({ username, email })
return c.json(user, 201)
},
)

router.delete("/:id", (c) => {
const ok = db.users.delete(c.req.param("id"))
Expand Down
6 changes: 1 addition & 5 deletions packages/shared/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
/**
* Shared types used by both the API and any consumers.
*
* BUG: The field is named `userName` here but the API routes reference `username`
* (lowercase n). This causes a type error in routes/users.ts and a runtime
* mismatch when serialising responses.
*/

export type User = {
id: string
userName: string // BUG: should be `username` to match API usage
username: string
email: string
createdAt: string
}
Expand Down
17 changes: 13 additions & 4 deletions packages/shared/src/utils/pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,19 @@ import type { PaginatedResponse } from "../types"
* @param items Full array of items
* @param page 1-indexed page number
* @param size Number of items per page
*
* TODO: implement this function — it is currently a stub.
* The test in packages/shared/test/pagination.test.ts exercises the full contract.
*/
export function paginate<T>(items: T[], page: number, size: number): PaginatedResponse<T> {
throw new Error("not implemented")
const pageSize = Math.max(1, Math.floor(size))
const currentPage = Math.max(1, Math.floor(page))
const total = items.length
const totalPages = Math.ceil(total / pageSize)
const start = (currentPage - 1) * pageSize

return {
data: items.slice(start, start + pageSize),
page: currentPage,
pageSize,
total,
totalPages,
}
}
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"types": ["bun-types"],
"paths": {
"@e2e/shared": ["./packages/shared/src/index.ts"]
}
Expand Down