Skip to content

Commit 6a0920c

Browse files
authored
Merge pull request #453 from AikidoSec/express-ghost
Make non-owned props of express wrapped functions accessible
2 parents f2255d0 + b6370d2 commit 6a0920c

File tree

3 files changed

+147
-19
lines changed

3 files changed

+147
-19
lines changed

library/helpers/fetch.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,16 @@ async function request({
5252

5353
export async function fetch({
5454
url,
55-
method,
56-
headers,
55+
method = "GET",
56+
headers = {},
5757
body = "",
58-
timeoutInMS,
58+
timeoutInMS = 5000,
5959
}: {
6060
url: URL;
61-
method: string;
62-
headers: Record<string, string>;
61+
method?: string;
62+
headers?: Record<string, string>;
6363
body?: string;
64-
timeoutInMS: number;
64+
timeoutInMS?: number;
6565
}): Promise<{ body: string; statusCode: number }> {
6666
const abort = new AbortController();
6767

library/sources/Express.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Express } from "./Express";
55
import { FileSystem } from "../sinks/FileSystem";
66
import { HTTPServer } from "./HTTPServer";
77
import { createTestAgent } from "../helpers/createTestAgent";
8+
import { fetch } from "../helpers/fetch";
89

910
// Before require("express")
1011
const agent = createTestAgent({
@@ -124,6 +125,20 @@ function getApp(userMiddleware = true) {
124125
// A middleware that is used as a route
125126
app.use("/api/*path", apiMiddleware);
126127

128+
const newRouter = express.Router();
129+
newRouter.get("/nested-router", (req, res) => {
130+
res.send(getContext());
131+
});
132+
133+
app.use(newRouter);
134+
135+
const nestedApp = express();
136+
nestedApp.get("/", (req, res) => {
137+
res.send(getContext());
138+
});
139+
140+
app.use("/nested-app", nestedApp);
141+
127142
app.get("/", (req, res) => {
128143
const context = getContext();
129144

@@ -554,3 +569,91 @@ t.test("it preserves original function name in Layer object", async () => {
554569
1
555570
);
556571
});
572+
573+
t.test("it supports nested router", async () => {
574+
const response = await request(getApp()).get("/nested-router");
575+
576+
t.match(response.body, {
577+
method: "GET",
578+
source: "express",
579+
route: "/nested-router",
580+
});
581+
});
582+
583+
t.test("it supports nested app", async (t) => {
584+
const response = await request(getApp()).get("/nested-app");
585+
586+
t.match(response.body, {
587+
method: "GET",
588+
source: "express",
589+
route: "/nested-app",
590+
});
591+
});
592+
593+
// Express instrumentation results in routes with no stack, crashing Ghost
594+
// https://github.com/open-telemetry/opentelemetry-js-contrib/issues/2271
595+
// https://github.com/open-telemetry/opentelemetry-js-contrib/pull/2294
596+
t.test(
597+
"it keeps handle properties even if router is patched before instrumentation does it",
598+
async () => {
599+
const { createServer } = require("http") as typeof import("http");
600+
const expressApp = express();
601+
const router = express.Router();
602+
603+
let routerLayer: { name: string; handle: { stack: any[] } } | undefined =
604+
undefined;
605+
606+
const CustomRouter: (...p: Parameters<typeof router>) => void = (
607+
req,
608+
res,
609+
next
610+
) => router(req, res, next);
611+
612+
router.use("/:slug", (req, res, next) => {
613+
// On express v4, the router is available as `app._router`
614+
// On express v5, the router is available as `app.router`
615+
// @ts-expect-error stack is private
616+
const stack = req.app.router.stack as any[];
617+
routerLayer = stack.find((router) => router.name === "CustomRouter");
618+
return res.status(200).send("bar");
619+
});
620+
621+
// The patched router now has express router's own properties in its prototype so
622+
// they are not accessible through `Object.keys(...)`
623+
// https://github.com/TryGhost/Ghost/blob/fefb9ec395df8695d06442b6ecd3130dae374d94/ghost/core/core/frontend/web/site.js#L192
624+
Object.setPrototypeOf(CustomRouter, router);
625+
expressApp.use(CustomRouter);
626+
627+
// supertest acts weird with the custom router, so we need to create a server manually
628+
const server = createServer(expressApp);
629+
await new Promise<void>((resolve) => {
630+
server.listen(0, resolve);
631+
});
632+
633+
if (!server) {
634+
throw new Error("server not found");
635+
}
636+
637+
const address = server.address();
638+
639+
if (typeof address === "string") {
640+
throw new Error("address is a string");
641+
}
642+
643+
const response = await fetch({
644+
url: new URL(`http://localhost:${address!.port}/foo`),
645+
});
646+
t.same(response.body, "bar");
647+
server.close();
648+
649+
if (!routerLayer) {
650+
throw new Error("router layer not found");
651+
}
652+
653+
t.ok(
654+
// @ts-expect-error handle is private
655+
routerLayer.handle.stack.length === 1,
656+
"router layer stack is accessible"
657+
);
658+
}
659+
);

library/sources/express/wrapRequestHandler.ts

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,62 @@
1+
/* eslint-disable prefer-rest-params */
12
import type { RequestHandler } from "express";
23
import { runWithContext } from "../../agent/Context";
4+
import { createWrappedFunction } from "../../helpers/wrap";
35
import { contextFromRequest } from "./contextFromRequest";
46

57
export function wrapRequestHandler(handler: RequestHandler): RequestHandler {
6-
const fn: RequestHandler = (req, res, next) => {
7-
const context = contextFromRequest(req);
8+
const fn = createWrappedFunction(handler, function wrap(handler) {
9+
return function wrap(this: RequestHandler) {
10+
if (arguments.length === 0) {
11+
return handler.apply(this);
12+
}
813

9-
return runWithContext(context, () => {
10-
return handler(req, res, next);
11-
});
12-
};
14+
const context = contextFromRequest(arguments[0]);
15+
16+
return runWithContext(context, () => {
17+
return handler.apply(this, arguments);
18+
});
19+
};
20+
}) as RequestHandler;
21+
22+
// Some libraries/apps have properties on the handler functions that are not copied by our createWrappedFunction function
23+
// (createWrappedFunction only copies properties when hasOwnProperty is true)
24+
// Let's set up a proxy to forward the property access to the original handler
25+
// e.g. https://github.com/TryGhost/Ghost/blob/fefb9ec395df8695d06442b6ecd3130dae374d94/ghost/core/core/frontend/web/site.js#L192
26+
for (const key in handler) {
27+
if (handler.hasOwnProperty(key)) {
28+
continue;
29+
}
1330

14-
if (handler.name) {
15-
preserveFunctionName(fn, handler.name);
31+
Object.defineProperty(fn, key, {
32+
get() {
33+
// @ts-expect-error Types unknown
34+
return handler[key];
35+
},
36+
set(value) {
37+
// @ts-expect-error Types unknown
38+
handler[key] = value;
39+
},
40+
});
1641
}
1742

43+
// For some libraries/apps it's important to preserve the function name
44+
// e.g. Ghost looks up a middleware function by name in the router stack
45+
preserveLayerName(fn, handler.name);
46+
1847
return fn;
1948
}
2049

2150
/**
22-
* Preserve the original function name
23-
* e.g. Ghost looks up a middleware function by name in the router stack
24-
*
2551
* Object.getOwnPropertyDescriptor(function myFunction() {}, "name")
26-
*
2752
* {
2853
* value: 'myFunction',
2954
* writable: false,
3055
* enumerable: false,
3156
* configurable: true
3257
* }
3358
*/
34-
function preserveFunctionName(wrappedFunction: Function, originalName: string) {
59+
function preserveLayerName(wrappedFunction: Function, originalName: string) {
3560
try {
3661
Object.defineProperty(wrappedFunction, "name", {
3762
value: originalName,

0 commit comments

Comments
 (0)