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
12 changes: 12 additions & 0 deletions docs/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,18 @@ app.use("/api/*", cors());
app.use("/admin/*", requireAdmin);
```

Path-specific middleware matches the path as the client sent it. Before any of
it runs, the server refuses with `400` a request whose path the URL parser would
rewrite — a `..` or `.` segment, raw or percent-encoded (`%2e%2e`), a backslash,
or a fragment. Without that refusal, `GET /docs/../admin` would match `/docs/*`
middleware in Hono while React Router, which matches the resolved path, ran the
`/admin` loader, so `app.use("/admin/*", requireAdmin)` would never see it. The
check compares the raw path with `URL.pathname`, so it also refuses, fail
closed, bytes the parser percent-encodes rather than resolves — raw UTF-8 such
as `/café`, `"`, `<`, `>`, `` ` ``, `{`, `}` — and a `Host` header carrying `/`,
`?` or `\`. Browsers resolve and encode such paths before sending them; only a
hand-built request is refused.

### Common Patterns

#### Authentication
Expand Down
154 changes: 154 additions & 0 deletions src/server.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2762,3 +2762,157 @@ describe("build id (deploy-skew handshake)", () => {
}
});
});

describe("a request path the URL parser would rewrite", () => {
async function overSocket(
server: { fetch(request: Request): Response | Promise<Response> },
path: string,
headers: Record<string, string> = {},
): Promise<{ status: number; body: string }> {
const listener = Deno.serve(
{ port: 0, hostname: "127.0.0.1", onListen() {} },
(request) => server.fetch(request),
);
const connection = await Deno.connect({
hostname: "127.0.0.1",
port: listener.addr.port,
});
try {
const lines = [
`GET ${path} HTTP/1.1`,
"host: localhost",
"connection: close",
...Object.entries(headers).map(([name, value]) => `${name}: ${value}`),
];
await connection.write(
new TextEncoder().encode(`${lines.join("\r\n")}\r\n\r\n`),
);
const chunks: number[] = [];
const buffer = new Uint8Array(65536);
for (let read; (read = await connection.read(buffer)) !== null;) {
chunks.push(...buffer.subarray(0, read));
}
const raw = new TextDecoder().decode(new Uint8Array(chunks));
return {
status: Number(raw.split(" ")[1]),
body: raw.slice(raw.indexOf("\r\n\r\n") + 4),
};
} finally {
connection.close();
await listener.shutdown();
}
}

function fixture(): {
server: ReturnType<typeof createServer>;
runs: { guard: number; admin: number; docs: number };
} {
const runs = { guard: 0, admin: 0, docs: 0 };
const client = new Client({
path: "/",
main: { default: () => <Outlet /> },
children: [
{
path: "docs",
main: { default: () => <Outlet /> },
catchall: () =>
Promise.resolve({
default: () => <div>Public docs</div>,
}),
},
{
path: "admin",
main: { default: () => <div>Private admin</div> },
},
],
});
const server = createServer(import.meta.url, client, {
path: "/",
children: [
{
path: "docs",
catchall: {
loader: () => {
runs.docs++;
return { splat: true };
},
},
},
{
path: "admin",
main: {
default: new Hono().use(() => {
runs.guard++;
throw new HttpError(403, "Guarded");
}),
loader: () => {
runs.admin++;
return { secret: true };
},
},
},
],
});
return { server, runs };
}

for (
const path of [
"/docs/../admin",
"/docs/%2e%2e/admin",
"/docs/%2E%2E/admin",
"/docs/.%2e/admin",
"/docs/%2e./admin",
"/docs\\..\\admin",
"/docs/./admin",
"/docs/x/../../admin",
]
) {
it(
`refuses ${JSON.stringify(path)} before Hono or React Router routes it`,
async () => {
const { server, runs } = fixture();
const response = await overSocket(server, path);
assertEquals(
response.status,
400,
`Hono matched the raw path under /docs while React Router resolved it to /admin, so the guard on /admin never ran: ${response.status} ${response.body}`,
);
assertEquals(runs, { guard: 0, admin: 0, docs: 0 });
},
);
}

it("refuses a data request the same way", async () => {
const { server, runs } = fixture();
const response = await overSocket(server, "/docs/../admin", {
"x-juniper-route-id": "/admin/main",
});
assertEquals(response.status, 400);
assertEquals(runs, { guard: 0, admin: 0, docs: 0 });
});

it("still lets the route's own guard answer the resolved path", async () => {
const { server, runs } = fixture();
const response = await overSocket(server, "/admin");
assertEquals(response.status, 403);
assertEquals(runs, { guard: 1, admin: 0, docs: 0 });
});

it("serves paths the parser leaves alone, including dots inside a segment and in the query", async () => {
const { server, runs } = fixture();
for (
const path of [
"/docs/a%20b",
"/docs/v1..v2/notes.md",
"/docs/x?next=../admin",
"/docs/x?next=%2e%2e%2Fadmin",
]
) {
const response = await overSocket(server, path);
assertEquals(response.status, 200, `${path}: ${response.body}`);
assertStringIncludes(response.body, "Public docs");
}
assertEquals(runs, { guard: 0, admin: 0, docs: 4 });
});
});
38 changes: 37 additions & 1 deletion src/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,42 @@ function varyByRoute(headers: Headers): void {
mergeVary(headers, ["accept", "x-juniper-route-id"]);
}

function rawRequestPath(url: string): string {
const pathStart = url.indexOf("/", url.indexOf("://") + 3);
if (pathStart === -1) return "/";
const queryStart = url.indexOf("?", pathStart);
return url.slice(pathStart, queryStart === -1 ? undefined : queryStart);
}

/**
* Whether the URL parser would rewrite the request path before React Router
* matched it: a dot segment, raw (`..`) or percent-encoded (`%2e%2e`), a
* backslash, or a fragment. Hono routes on the path as sent, so such a request
* can match one route's middleware while running another route's loader. The
* raw string is compared with `URL.pathname`, so bytes the parser
* percent-encodes rather than resolves (raw UTF-8, `"`, `<`, `>`, `` ` ``, `{`,
* `}`) and a `Host` carrying `/`, `?` or `\` are flagged too, fail closed.
*/
function isUnresolvedPath(request: Request): boolean {
return rawRequestPath(request.url) !== new URL(request.url).pathname;
}

/**
* Creates the Hono application from generated client and server route trees.
*
* `Builder` normally writes this call into `main.ts`; customize behavior in route
* modules instead of editing generated files. Each request gets a fresh router
* context. Hono middleware runs before SSR or route-data handlers. Errors denied
* by middleware render without invoking loaders. Responses vary by `Accept` and
* by middleware render without invoking loaders. A request whose path the URL
* parser would rewrite — a `..` or `.` segment, raw or percent-encoded, a
* backslash, or a fragment — is refused with 400 before any route middleware
* runs, because Hono matches the path as sent while React Router matches the
* resolved one, and a request the two disagree on could pass one route's
* middleware and run another route's loader. The same comparison also refuses,
* fail closed, bytes the parser percent-encodes rather than resolves — raw
* UTF-8 such as `/café`, `"`, `<`, `>`, `` ` ``, `{`, `}` — and a `Host`
* header carrying `/`, `?` or `\`. Browsers resolve and encode such paths
* before sending them, so only a hand-built request sees the refusal. Responses vary by `Accept` and
* `X-Juniper-Route-Id` while retaining application cache variation. Route data
* responses and redirects sent to data requests default to `Cache-Control:
* private, no-cache`, plus `no-transform` when deferred; a policy route
Expand Down Expand Up @@ -92,6 +121,13 @@ export function createServer<
const projectRoot = path.dirname(path.fromFileUrl(moduleUrl));
const appWrapper = new Hono<E, S, BasePath>({ strict: true });

appWrapper.use(async (c, next) => {
if (isUnresolvedPath(c.req.raw)) {
throw new HttpError(400, "Request path is not normalized");
}
await next();
});

appWrapper.use(async (c, next) => {
c.set("context", new RouterContextProvider());
const buildId = await getBuildId(projectRoot);
Expand Down
Loading