fix: reject ambiguous module routes

A module can register both `GET /events/:id` and `GET /events/new`. Driven
through the pinned router, `/events/new` reached the parameter handler under
`demo.events.read` when the parameter route registered first, and the static
handler under `demo.events.create` when it registered second. Nothing rejected
the pair, so declaration order decided which permission guarded the path. That
is a permission policy, not a routing preference, and it must not be inferred
from array position.

Two routes of one module now overlap when they share a method and a segment
count and, at every position, either both segments are the same static literal
or at least one is a parameter. Under this grammar — static literals or `:name`
parameters, no wildcard, regex, optional, or catch-all — that is exactly "some
concrete request path matches both". `moduleRoutesOverlap` implements it in the
contracts package, where path semantics already live and Hono cannot reach.

Core checks it during staged registration, after the existing duplicate check,
so identical shapes (including paths differing only in parameter name) keep
returning MODULE_ROUTE_DUPLICATE and a genuine intersection returns the new
MODULE_ROUTE_OVERLAP. The rejected module publishes none of its staged routes or
capabilities, no start hook runs, and no listener binds. Rejection rather than a
specificity rule: ranking static above parameter would still silently pick which
permission applies. Disjoint routes keep their order; different methods,
different segment counts, and the same local path in two different modules
(each has its own platform-owned mount prefix) all remain legal.

The same requests also pinned down what handlers actually receive. Route
declarations are validated; route *parameters* are not, and the two are
unrelated. A single encoded segment arrives decoded as `/../admin`, `a\b`, an
embedded NUL, CR/LF, `1; drop`, or non-ASCII text — none of which any declared
path could contain. The contract now says so explicitly: decoded values are
untrusted, module-owned input that a handler must validate against its own exact
domain grammar before any database, filesystem, command, or identifier use, and
re-encoding is not a substitute. No generic validator, path-normalization layer,
or route field was added, because module parameters are module-owned domain
types and one platform-wide pattern would either reject legitimate values or
approve meaningless ones.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-07-26 23:10:51 -05:00
co-authored by Claude Opus 5
parent 4a34a8203c
commit 800ecfdb92
6 changed files with 468 additions and 0 deletions
+59
View File
@@ -219,6 +219,65 @@ registration outright rather than being ignored. `method` is one of `GET`, `POST
`DELETE`, always uppercase; there is no `OPTIONS` (the platform owns CORS preflight) and no
wildcard/all-method registration.
### Two routes may not both match one request path
Within one module and one HTTP method, two route patterns must never be able to match the same
request path. `/events/:id` and `/events/new` can — `/events/new` matches both — and that pair is
rejected during registration with `MODULE_ROUTE_OVERLAP`. So are `/events/:id/edit` against
`/events/new/:action`, and `/a/:left/c` against `/a/b/:right`. Two identical shapes, including paths
differing only in parameter name (`/events/:id` and `/events/:eventId`), keep the existing
`MODULE_ROUTE_DUPLICATE` result.
This is rejected rather than resolved by a "static beats parameter" rule because the routes may
carry *different permissions*, and letting declaration order decide which one guards `/events/new`
would make a permission policy depend on the order lines appear in your `register` hook. A rejected
registration publishes **none** of that module's routes or capabilities and runs no start hook, so
the failure is loud at startup rather than subtle at request time.
Still perfectly legal: two different static literals (`/events/new` and `/events/archive`),
different segment counts (`/events/:id` and `/events/:id/edit`), the same path under different
methods, and the same local path in two different modules — each module has its own
platform-owned mount prefix. Declaration order among disjoint routes is preserved exactly.
### Route parameters are decoded, untrusted input — validate them yourself
`context.params` holds the router's **decoded** path values. Decoding is not validation, and the
fact that your *declared* path passed the platform's path grammar says nothing about what a caller
may *send*. A single encoded segment routinely carries characters no declared path could:
| Request segment | `context.params` value |
| --- | --- |
| `%2F..%2Fadmin` | `/../admin` |
| `a%5Cb` | `a\b` |
| `a%00b` | `a`, NUL, `b` |
| `a%0D%0Ab` | `a`, CR, LF, `b` |
| `1%3B%20drop` | `1; drop` |
| `%C3%A9v%C3%A9nement` | `événement` |
**Validate every parameter you use against your own exact domain grammar** — a UUID, slug, date,
numeric identifier, or whatever your module owns — before any database, filesystem, command,
identifier, or other side-effecting use, and reject a missing, repeated, malformed, or semantically
invalid value with your own sanitized response:
```ts
const eventId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
async handler(request, context) {
const id = context.params.eventId;
if (id === undefined || !eventId.test(id)) {
return Response.json({ code: "INVALID_REQUEST" }, { status: 400 });
}
// ...only now is `id` safe to use as a domain identifier.
}
```
Re-encoding the value, or a decode/re-encode round trip, is **not** a substitute for that check. The
platform deliberately supplies no generic parameter validator: your parameters are your domain
types, and one platform-wide pattern would either reject legitimate values or approve meaningless
ones. The platform continues to own authentication, authorization, `Origin`, CORS, body limits,
cache behavior, and uncaught-handler sanitization, and permission enforcement always completes
before your handler runs — an unauthorized caller can never reach your validation at all.
`maxBodyBytes` is a conscious, required request-body contract, not an optional or defaulted field:
`0` means the route accepts no body at all, and a non-empty declared (`Content-Length`) or streamed
body is rejected before the handler ever runs; any other value is the exact maximum number of bytes