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:
co-authored by
Claude Opus 5
parent
4a34a8203c
commit
800ecfdb92
@@ -5,6 +5,7 @@ import {
|
|||||||
type ModuleRouteHandler,
|
type ModuleRouteHandler,
|
||||||
type ModuleRouteHandlerContext,
|
type ModuleRouteHandlerContext,
|
||||||
type ModuleRuntimeServices,
|
type ModuleRuntimeServices,
|
||||||
|
moduleRoutesOverlap,
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
type PermissionId,
|
type PermissionId,
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
@@ -1454,3 +1455,153 @@ describe("module routes and the rest of the API", () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pass 4: the decoded-parameter boundary and the routing ambiguity that made it dangerous.
|
||||||
|
*
|
||||||
|
* These drive the pinned Hono matcher with real requests rather than asserting what a mock returns,
|
||||||
|
* because the whole point is what the real router actually hands a module handler.
|
||||||
|
*/
|
||||||
|
describe("module route parameters are decoded, untrusted input", () => {
|
||||||
|
function paramsApp() {
|
||||||
|
const seen: Array<Readonly<Record<string, string>>> = [];
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async (_request, context) => {
|
||||||
|
seen.push(context.params);
|
||||||
|
return Response.json({ id: context.params.id });
|
||||||
|
});
|
||||||
|
const { app } = buildModuleApp({
|
||||||
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||||
|
routes: [
|
||||||
|
moduleRoute({
|
||||||
|
handler,
|
||||||
|
method: "GET",
|
||||||
|
moduleId: "calendar",
|
||||||
|
path: "/items/:id",
|
||||||
|
permission: readPermission,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return { app, handler, seen };
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
// A single encoded segment can carry characters the declaration grammar forbids outright.
|
||||||
|
["an encoded path separator and dot segments", "%2F..%2Fadmin", "/../admin"],
|
||||||
|
["an encoded backslash", "a%5Cb", "a\\b"],
|
||||||
|
["an embedded NUL", "a%00b", "a\u0000b"],
|
||||||
|
["encoded CR/LF", "a%0D%0Ab", "a\r\nb"],
|
||||||
|
["a percent sign", "100%25", "100%"],
|
||||||
|
["non-ASCII text", "%C3%A9v%C3%A9nement", "événement"],
|
||||||
|
["a statement separator", "1%3B%20drop", "1; drop"],
|
||||||
|
])("delivers %s decoded, exactly as the router produced it", async (_label, encoded, decoded) => {
|
||||||
|
const { app, seen } = paramsApp();
|
||||||
|
|
||||||
|
const response = await app.request(`/api/v1/modules/calendar/items/${encoded}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ id: decoded });
|
||||||
|
// The declared path grammar (`/items/:id`) permits none of these characters — validating the
|
||||||
|
// *declaration* says nothing about what a request may send, which is exactly why a handler
|
||||||
|
// must validate the decoded value against its own domain grammar before using it.
|
||||||
|
expect(seen[0]?.id).toBe(decoded);
|
||||||
|
expect(Object.isFrozen(seen[0])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps parameter delivery behind authorization", async () => {
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async () => new Response("never"));
|
||||||
|
const { app, evaluator } = buildModuleApp({
|
||||||
|
evaluator: { authorize: vi.fn().mockResolvedValue({ allowed: false }) },
|
||||||
|
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
||||||
|
routes: [
|
||||||
|
moduleRoute({
|
||||||
|
handler,
|
||||||
|
method: "GET",
|
||||||
|
moduleId: "calendar",
|
||||||
|
path: "/items/:id",
|
||||||
|
permission: readPermission,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/items/%2F..%2Fadmin");
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(evaluator.authorize).toHaveBeenCalled();
|
||||||
|
// An unauthorized caller can never observe how the handler would treat a parameter, because
|
||||||
|
// the handler never runs.
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a handler reject a decoded value against its own domain grammar", async () => {
|
||||||
|
// The platform supplies no generic parameter validator; this is the module's own check, which
|
||||||
|
// is exactly the documented contract.
|
||||||
|
const canonicalId = /^[0-9a-f]{8}$/u;
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async (_request, context) => {
|
||||||
|
const id = context.params.id;
|
||||||
|
if (id === undefined || !canonicalId.test(id)) {
|
||||||
|
return Response.json({ code: "INVALID_REQUEST" }, { status: 400 });
|
||||||
|
}
|
||||||
|
return Response.json({ id });
|
||||||
|
});
|
||||||
|
const { app } = buildModuleApp({
|
||||||
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||||
|
routes: [
|
||||||
|
moduleRoute({
|
||||||
|
handler,
|
||||||
|
method: "GET",
|
||||||
|
moduleId: "calendar",
|
||||||
|
path: "/items/:id",
|
||||||
|
permission: readPermission,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await app.request("/api/v1/modules/calendar/items/deadbeef")).status).toBe(200);
|
||||||
|
const rejected = await app.request("/api/v1/modules/calendar/items/%2F..%2Fadmin");
|
||||||
|
expect(rejected.status).toBe(400);
|
||||||
|
expect(await rejected.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module route overlap can never reach mounting", () => {
|
||||||
|
it("would otherwise let registration order choose the governing permission", async () => {
|
||||||
|
// Both orders are mounted directly here — bypassing the runtime rejection — to show precisely
|
||||||
|
// what `MODULE_ROUTE_OVERLAP` prevents: the same request reaching a different handler *and* a
|
||||||
|
// different permission depending only on the order the routes were registered in.
|
||||||
|
const createPermission = parsePermissionId("calendar.events.create");
|
||||||
|
const byId = vi.fn<ModuleRouteHandler>(async () => Response.json({ handler: "byId" }));
|
||||||
|
const create = vi.fn<ModuleRouteHandler>(async () => Response.json({ handler: "new" }));
|
||||||
|
const registry = permissionRegistry([
|
||||||
|
{ id: readPermission, baseline: "public" },
|
||||||
|
{ id: createPermission, baseline: "public" },
|
||||||
|
]);
|
||||||
|
const paramRoute = moduleRoute({
|
||||||
|
handler: byId,
|
||||||
|
method: "GET",
|
||||||
|
moduleId: "calendar",
|
||||||
|
path: "/events/:id",
|
||||||
|
permission: readPermission,
|
||||||
|
});
|
||||||
|
const staticRoute = moduleRoute({
|
||||||
|
handler: create,
|
||||||
|
method: "GET",
|
||||||
|
moduleId: "calendar",
|
||||||
|
path: "/events/new",
|
||||||
|
permission: createPermission,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parameterFirst = buildModuleApp({ registry, routes: [paramRoute, staticRoute] });
|
||||||
|
const staticFirst = buildModuleApp({ registry, routes: [staticRoute, paramRoute] });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await (await parameterFirst.app.request("/api/v1/modules/calendar/events/new")).json(),
|
||||||
|
).toEqual({ handler: "byId" });
|
||||||
|
expect(
|
||||||
|
await (await staticFirst.app.request("/api/v1/modules/calendar/events/new")).json(),
|
||||||
|
).toEqual({ handler: "new" });
|
||||||
|
|
||||||
|
// Core rejects this pair during staged registration, so no such snapshot can ever be published
|
||||||
|
// to `mountModuleHttpRoutes` — see `core/tests/module-runtime.test.ts`.
|
||||||
|
expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type ModuleRuntimeDefinition,
|
type ModuleRuntimeDefinition,
|
||||||
type ModuleRuntimeServices,
|
type ModuleRuntimeServices,
|
||||||
moduleRouteShapeKey,
|
moduleRouteShapeKey,
|
||||||
|
moduleRoutesOverlap,
|
||||||
parseModuleRuntimeDefinition,
|
parseModuleRuntimeDefinition,
|
||||||
type RegisteredCapability,
|
type RegisteredCapability,
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
@@ -37,6 +38,7 @@ export type ModuleRuntimeIssueCode =
|
|||||||
| "MODULE_ROUTE_DUPLICATE"
|
| "MODULE_ROUTE_DUPLICATE"
|
||||||
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
|
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
|
||||||
| "MODULE_ROUTE_METHOD_INVALID"
|
| "MODULE_ROUTE_METHOD_INVALID"
|
||||||
|
| "MODULE_ROUTE_OVERLAP"
|
||||||
| "MODULE_ROUTE_PATH_INVALID"
|
| "MODULE_ROUTE_PATH_INVALID"
|
||||||
| "MODULE_ROUTE_PERMISSION_INVALID"
|
| "MODULE_ROUTE_PERMISSION_INVALID"
|
||||||
| "MODULE_ROUTE_PERMISSION_UNDECLARED"
|
| "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);
|
shapes.add(shapeKey);
|
||||||
local.push(
|
local.push(
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
|
|||||||
@@ -562,6 +562,141 @@ describe("module HTTP routes", () => {
|
|||||||
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_DUPLICATE"]);
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_DUPLICATE"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["parameter registered first", "/events/:id", "/events/new"],
|
||||||
|
["static registered first", "/events/new", "/events/:id"],
|
||||||
|
["multi-position, parameter first", "/events/:id/edit", "/events/new/:action"],
|
||||||
|
["multi-position, static first", "/events/new/:action", "/events/:id/edit"],
|
||||||
|
["parameters on both sides", "/a/:left/c", "/a/b/:right"],
|
||||||
|
])("rejects routes that can both match one request path (%s) regardless of registration order", async (_label, firstPath, secondPath) => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
// Deliberately *different* permissions: the defect this rejects is that whichever route
|
||||||
|
// registered first would decide which permission governs the ambiguous path.
|
||||||
|
await createRuntimeModule(modulesDirectory, "overlap", {
|
||||||
|
manifest: manifestFor("overlap", {
|
||||||
|
permissions: [
|
||||||
|
permissionDeclaration("overlap.events.read"),
|
||||||
|
permissionDeclaration("overlap.events.create"),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "overlap", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({ method: "GET", path: "${firstPath}", permission: "overlap.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
||||||
|
http.route({ method: "GET", path: "${secondPath}", permission: "overlap.events.create", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await captureRuntimeError(modulesDirectory);
|
||||||
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_OVERLAP"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["two different static literals", "/events/new", "/events/archive"],
|
||||||
|
["different segment counts", "/events/:id", "/events/:id/edit"],
|
||||||
|
["a different static prefix", "/events/:id", "/people/:id"],
|
||||||
|
["module root against a one-segment path", "/", "/events"],
|
||||||
|
])("still allows disjoint routes (%s) and preserves their registration order", async (_label, firstPath, secondPath) => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(modulesDirectory, "disjoint", {
|
||||||
|
manifest: manifestFor("disjoint", {
|
||||||
|
permissions: [permissionDeclaration("disjoint.events.read")],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "disjoint", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({ method: "GET", path: "${firstPath}", permission: "disjoint.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
||||||
|
http.route({ method: "GET", path: "${secondPath}", permission: "disjoint.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = await startModuleRuntime({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
services: fakeModuleRuntimeServices(),
|
||||||
|
});
|
||||||
|
expect(runtime.routes.map((route) => route.path)).toEqual([firstPath, secondPath]);
|
||||||
|
await runtime.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows the same overlapping local path under different methods", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(modulesDirectory, "methods", {
|
||||||
|
manifest: manifestFor("methods", {
|
||||||
|
permissions: [permissionDeclaration("methods.events.read")],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "methods", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({ method: "GET", path: "/events/:id", permission: "methods.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
||||||
|
http.route({ method: "POST", path: "/events/new", permission: "methods.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = await startModuleRuntime({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
services: fakeModuleRuntimeServices(),
|
||||||
|
});
|
||||||
|
expect(runtime.routes).toHaveLength(2);
|
||||||
|
await runtime.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows two different modules to declare the same overlapping local paths", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
for (const moduleId of ["alpha", "beta"]) {
|
||||||
|
await createRuntimeModule(modulesDirectory, moduleId, {
|
||||||
|
manifest: manifestFor(moduleId, {
|
||||||
|
permissions: [permissionDeclaration(`${moduleId}.events.read`)],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "${moduleId}", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({ method: "GET", path: "/events/:id", permission: "${moduleId}.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
||||||
|
}
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each module has its own platform-owned mount prefix, so these can never collide.
|
||||||
|
const runtime = await startModuleRuntime({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
services: fakeModuleRuntimeServices(),
|
||||||
|
});
|
||||||
|
expect(runtime.routes.map((route) => route.moduleId).sort()).toEqual(["alpha", "beta"]);
|
||||||
|
await runtime.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes no route from a module whose registration was rejected for an overlap", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(modulesDirectory, "partial", {
|
||||||
|
manifest: manifestFor("partial", {
|
||||||
|
permissions: [permissionDeclaration("partial.events.read")],
|
||||||
|
}),
|
||||||
|
serverSource: `export default {
|
||||||
|
id: "partial", version: "0.1.0",
|
||||||
|
register({ http }) {
|
||||||
|
http.route({ method: "GET", path: "/healthy", permission: "partial.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
||||||
|
http.route({ method: "GET", path: "/events/:id", permission: "partial.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
||||||
|
http.route({ method: "GET", path: "/events/new", permission: "partial.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
|
||||||
|
},
|
||||||
|
start() { throw new Error("start must never run for a rejected registration"); }
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await captureRuntimeError(modulesDirectory);
|
||||||
|
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_OVERLAP"]);
|
||||||
|
// The earlier, perfectly valid `/healthy` route is not published either: registration is
|
||||||
|
// all-or-nothing, and the start hook above would have thrown had it ever run.
|
||||||
|
});
|
||||||
|
|
||||||
it("fails a malformed route with its specific structured issue code", async () => {
|
it("fails a malformed route with its specific structured issue code", async () => {
|
||||||
const badMethodDirectory = await createModulesDirectory();
|
const badMethodDirectory = await createModulesDirectory();
|
||||||
await createRuntimeModule(badMethodDirectory, "bad-method", {
|
await createRuntimeModule(badMethodDirectory, "bad-method", {
|
||||||
|
|||||||
@@ -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
|
`DELETE`, always uppercase; there is no `OPTIONS` (the platform owns CORS preflight) and no
|
||||||
wildcard/all-method registration.
|
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:
|
`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
|
`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
|
body is rejected before the handler ever runs; any other value is the exact maximum number of bytes
|
||||||
|
|||||||
@@ -141,6 +141,49 @@ export function moduleRouteShapeKey(method: ModuleHttpMethod, path: ModuleRouteP
|
|||||||
return `${method} /${shapeSegments.join("/")}`;
|
return `${method} /${shapeSegments.join("/")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `"/"` is the module root and has no segments; every other canonical path is its `/`-separated
|
||||||
|
* segments. Takes an already-validated `ModuleRoutePath`. */
|
||||||
|
function moduleRoutePathSegments(path: ModuleRoutePath): readonly string[] {
|
||||||
|
return path === "/" ? [] : path.slice(1).split("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether two routes of the same module can both match at least one request path.
|
||||||
|
*
|
||||||
|
* Two routes overlap when they share an HTTP 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
|
||||||
|
* route grammar — static kebab-case literals or `:name` parameters, with no wildcard, regex,
|
||||||
|
* optional, or catch-all segment — that condition is exactly "some concrete path matches both".
|
||||||
|
*
|
||||||
|
* This exists because an overlap is a *permission* ambiguity, not a routing preference:
|
||||||
|
* `GET /events/:id` and `GET /events/new` can carry different permissions, and with an
|
||||||
|
* order-sensitive matcher whichever registered first decides which permission governs
|
||||||
|
* `/events/new`. Core rejects the overlap at registration instead of picking a winner, so no
|
||||||
|
* router's matching order can ever select the governing permission. Identical shapes (including
|
||||||
|
* paths differing only in parameter name) remain the pre-existing duplicate result; callers check
|
||||||
|
* that first.
|
||||||
|
*
|
||||||
|
* Deliberately framework-neutral: path semantics already live in this package, and nothing about
|
||||||
|
* this rule may depend on Hono, which stays confined to `apps/api`.
|
||||||
|
*/
|
||||||
|
export function moduleRoutesOverlap(
|
||||||
|
first: { readonly method: ModuleHttpMethod; readonly path: ModuleRoutePath },
|
||||||
|
second: { readonly method: ModuleHttpMethod; readonly path: ModuleRoutePath },
|
||||||
|
): boolean {
|
||||||
|
if (first.method !== second.method) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const firstSegments = moduleRoutePathSegments(first.path);
|
||||||
|
const secondSegments = moduleRoutePathSegments(second.path);
|
||||||
|
if (firstSegments.length !== secondSegments.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return firstSegments.every((segment, index) => {
|
||||||
|
const other = secondSegments[index] as string;
|
||||||
|
return segment.startsWith(":") || other.startsWith(":") || segment === other;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a module's route handler receives about the caller, the match, and the platform services
|
* What a module's route handler receives about the caller, the match, and the platform services
|
||||||
* available to it — never an authorization-store, grant-management, storage, jobs, configuration,
|
* available to it — never an authorization-store, grant-management, storage, jobs, configuration,
|
||||||
@@ -149,6 +192,28 @@ export function moduleRouteShapeKey(method: ModuleHttpMethod, path: ModuleRouteP
|
|||||||
* before ever invoking its handler; this contracts-layer module never invokes a handler itself.
|
* before ever invoking its handler; this contracts-layer module never invokes a handler itself.
|
||||||
* `services` is the same frozen `ModuleRuntimeServices` object the owning module's `start`/`stop`
|
* `services` is the same frozen `ModuleRuntimeServices` object the owning module's `start`/`stop`
|
||||||
* hooks receive for this API runtime — never a fresh one built per request.
|
* hooks receive for this API runtime — never a fresh one built per request.
|
||||||
|
*
|
||||||
|
* ## `params` values are untrusted input
|
||||||
|
*
|
||||||
|
* `params` holds the HTTP router's **decoded** path values, frozen alongside this context. Decoding
|
||||||
|
* is not validation, and the declaration grammar above constrains only what a module may *declare*
|
||||||
|
* — it says nothing about what a request may *send*. A caller can percent-encode anything into a
|
||||||
|
* single segment, so a decoded value routinely contains characters no declared path could:
|
||||||
|
* verified against the pinned router, `%2F..%2Fadmin` arrives as `/../admin`, `a%5Cb` as `a\b`,
|
||||||
|
* `a%00b` with an embedded NUL, `a%0D%0Ab` with CR/LF, and `%C3%A9v%C3%A9nement` as `événement`.
|
||||||
|
*
|
||||||
|
* Every handler must therefore validate each parameter it uses against its own exact domain
|
||||||
|
* grammar — a UUID, slug, date, numeric id, or whatever that module owns — **before** any
|
||||||
|
* database, filesystem, command, identifier, or other side-effecting use, and reject a missing,
|
||||||
|
* repeated, malformed, or semantically invalid value with its own sanitized response. Re-encoding,
|
||||||
|
* decode/re-encode round-tripping, or inspecting the raw URL is not a substitute for that check.
|
||||||
|
* The platform deliberately supplies no generic parameter grammar: module parameters are
|
||||||
|
* module-owned 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; permission enforcement always completes before a
|
||||||
|
* handler — and therefore any parameter validation it performs — can run.
|
||||||
*/
|
*/
|
||||||
export interface ModuleRouteHandlerContext {
|
export interface ModuleRouteHandlerContext {
|
||||||
readonly moduleId: ModuleId;
|
readonly moduleId: ModuleId;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type ModuleRouteDefinitionInput,
|
type ModuleRouteDefinitionInput,
|
||||||
moduleRoutePathSchema,
|
moduleRoutePathSchema,
|
||||||
moduleRouteShapeKey,
|
moduleRouteShapeKey,
|
||||||
|
moduleRoutesOverlap,
|
||||||
parseModuleRouteDefinition,
|
parseModuleRouteDefinition,
|
||||||
parseModuleRoutePath,
|
parseModuleRoutePath,
|
||||||
safeParseModuleRouteDefinition,
|
safeParseModuleRouteDefinition,
|
||||||
@@ -294,3 +295,42 @@ describe("parseModuleRouteDefinition", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("moduleRoutesOverlap", () => {
|
||||||
|
const route = (method: "GET" | "POST", path: string) => ({
|
||||||
|
method,
|
||||||
|
path: parseModuleRoutePath(path),
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a parameter against a static literal", "/events/:id", "/events/new"],
|
||||||
|
["a later position", "/events/:id/edit", "/events/new/:action"],
|
||||||
|
["parameters on both sides at different positions", "/a/:left/c", "/a/b/:right"],
|
||||||
|
["identical parameter shapes with different names", "/events/:id", "/events/:eventId"],
|
||||||
|
["identical static paths", "/events/new", "/events/new"],
|
||||||
|
["root against root", "/", "/"],
|
||||||
|
])("reports an overlap for %s", (_label, first, second) => {
|
||||||
|
expect(moduleRoutesOverlap(route("GET", first), route("GET", second))).toBe(true);
|
||||||
|
// The relation is symmetric, so registration order cannot change the verdict.
|
||||||
|
expect(moduleRoutesOverlap(route("GET", second), route("GET", first))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["two different static literals", "/events/new", "/events/archive"],
|
||||||
|
["different segment counts", "/events/:id", "/events/:id/edit"],
|
||||||
|
["a static prefix that differs earlier", "/events/:id", "/people/:id"],
|
||||||
|
["root against a one-segment path", "/", "/events"],
|
||||||
|
])("reports no overlap for %s", (_label, first, second) => {
|
||||||
|
expect(moduleRoutesOverlap(route("GET", first), route("GET", second))).toBe(false);
|
||||||
|
expect(moduleRoutesOverlap(route("GET", second), route("GET", first))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never reports an overlap across different methods", () => {
|
||||||
|
expect(moduleRoutesOverlap(route("GET", "/events/:id"), route("POST", "/events/new"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(moduleRoutesOverlap(route("GET", "/events/new"), route("POST", "/events/new"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user