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
+18
View File
@@ -12,6 +12,7 @@ import {
type ModuleRuntimeDefinition,
type ModuleRuntimeServices,
moduleRouteShapeKey,
moduleRoutesOverlap,
parseModuleRuntimeDefinition,
type RegisteredCapability,
type RegisteredModuleHttpRoute,
@@ -37,6 +38,7 @@ export type ModuleRuntimeIssueCode =
| "MODULE_ROUTE_DUPLICATE"
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
| "MODULE_ROUTE_METHOD_INVALID"
| "MODULE_ROUTE_OVERLAP"
| "MODULE_ROUTE_PATH_INVALID"
| "MODULE_ROUTE_PERMISSION_INVALID"
| "MODULE_ROUTE_PERMISSION_UNDECLARED"
@@ -331,6 +333,22 @@ class StagedModuleHttpRoutes {
),
);
}
// Distinct from a duplicate: these are *different* routes that can both match at least one
// request path. With an order-sensitive matcher, whichever registered first would decide
// which permission governs that path — a permission-policy decision no routing order may
// make. Core rejects the ambiguity here instead of ranking by specificity, so the module
// publishes none of its staged routes and no request can reach an ambiguous match.
const overlapping = local.find((existing) => moduleRoutesOverlap(existing, parsed.data));
if (overlapping) {
throw new ModuleRuntimeSignal(
runtimeIssue(
"MODULE_ROUTE_OVERLAP",
"register",
module.manifest.id,
`Module ${module.manifest.id} registered HTTP routes that can both match the same request path.`,
),
);
}
shapes.add(shapeKey);
local.push(
Object.freeze({