Skip to content

Commit cf4680f

Browse files
ArkaniadHalf-Shot
andauthored
Add parameter for setting generic body size (#1090)
* Add parameter for setting generic body size to something other than Express default 100kb * Add notes * Fix payload size linting issue * Formatting * Add tests / cleanup etc * lint * Update docs * lint --------- Co-authored-by: Half-Shot <[email protected]>
1 parent d97db85 commit cf4680f

File tree

9 files changed

+207
-30
lines changed

9 files changed

+207
-30
lines changed

changelog.d/1090.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add configuration option for generic webhooks to configure maximum request body size limit, increase default to 1mb from default 100kb.

config.sample.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ listeners:
119119
# requireExpiryTime: false
120120
# maxExpiryTime: 30d
121121
# includeHookBody: true
122+
# payloadSizeLimit:
123+
# # (Optional) How large may an incoming payload be. Specify in bytes or in a format like '1mb', '500kb', '10mb', etc.
124+
# 1mb
122125

123126
#figma:
124127
# # (Optional) Configure this to enable Figma support

spec/generic-hooks.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ describe("Inbound (Generic) Webhooks", () => {
2525
// Prefer to wait for complete as it reduces the concurrency of the test.
2626
waitForComplete: true,
2727
urlPrefix: `http://localhost:${webhooksPort}`,
28+
payloadSizeLimit: "10mb",
2829
},
2930
listeners: [
3031
{
@@ -170,4 +171,37 @@ describe("Inbound (Generic) Webhooks", () => {
170171
(await expectedMsg).data.content["uk.half-shot.hookshot.webhook_data"],
171172
).toBeUndefined();
172173
});
174+
175+
test("should handle an incoming request with a larger body", async () => {
176+
const user = testEnv.getUser("user");
177+
const roomId = await user.createRoom({ name: "My Test Webhooks room" });
178+
const okMsg = user.waitForRoomEvent({
179+
eventType: "m.room.message",
180+
sender: testEnv.botMxid,
181+
roomId,
182+
});
183+
const url = await createInboundConnection(user, testEnv.botMxid, roomId);
184+
expect((await okMsg).data.content.body).toEqual(
185+
"Room configured to bridge webhooks. See admin room for secret url.",
186+
);
187+
188+
const expectedMsg = user.waitForRoomEvent({
189+
eventType: "m.room.message",
190+
sender: testEnv.botMxid,
191+
roomId,
192+
});
193+
const body = Array.from({ length: 1024 * 1024 * 10 }).join("a");
194+
const req = await fetch(url, {
195+
method: "PUT",
196+
body: body,
197+
});
198+
expect(req.status).toEqual(200);
199+
expect(await req.json()).toEqual({ ok: true });
200+
const resultMsg = await expectedMsg;
201+
expect(resultMsg.data.content.body).toBeDefined();
202+
expect(resultMsg.data.content.formatted_body).toBeUndefined();
203+
expect(
204+
resultMsg.data.content["uk.half-shot.hookshot.webhook_data"],
205+
).toBeUndefined();
206+
});
173207
});

src/Connections/GenericHook.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ const WARN_AT_EXPIRY_MS = 3 * 24 * 60 * 60 * 1000;
9999
const MIN_EXPIRY_MS = 60 * 60 * 1000;
100100
const CHECK_EXPIRY_MS = 15 * 60 * 1000;
101101

102+
// This is a bit lower than the max size an event can be in Matrix.
103+
// > The complete event MUST NOT be larger than 65536 bytes, when formatted with the federation event format, including any signatures, and encoded as Canonical JSON.
104+
const MAX_EVENT_SIZE_BYTES = 65536 - 4 * 1024;
105+
102106
const EXPIRY_NOTICE_MESSAGE =
103107
"The webhook **%NAME** will be expiring in %TIME.";
104108

@@ -651,22 +655,46 @@ export class GenericHookConnection
651655
);
652656

653657
// Matrix cannot handle float data, so make sure we parse out any floats.
654-
const safeData =
658+
let safeData =
655659
(this.state.includeHookBody ?? this.config.includeHookBody)
656660
? GenericHookConnection.sanitiseObjectForMatrixJSON(data)
657661
: undefined;
658662

663+
// render can output redundant trailing newlines, so trim it.
664+
content.html = content.html || md.render(content.plain).trim();
665+
666+
while (
667+
content.plain.length +
668+
(content.html ?? "").length +
669+
(safeData ? JSON.stringify(safeData) : "").length >
670+
MAX_EVENT_SIZE_BYTES
671+
) {
672+
// We're oversize, so start trimming the event down.
673+
if (safeData) {
674+
// Start by trimming down the webhook data
675+
safeData = undefined;
676+
continue;
677+
}
678+
if (content.html) {
679+
// Then remove the HTML
680+
content.html = undefined;
681+
continue;
682+
}
683+
// Finally, trim right down. Ensure we account for double width.
684+
content.plain = content.plain.slice(0, MAX_EVENT_SIZE_BYTES / 2) + "…";
685+
break;
686+
}
687+
659688
await this.messageClient.sendMatrixMessage(
660689
this.roomId,
661690
{
662691
msgtype: content.msgtype || "m.notice",
663692
body: content.plain,
664-
// render can output redundant trailing newlines, so trim it.
665-
formatted_body: content.html || md.render(content.plain).trim(),
693+
...(content.html ? { formatted_body: content.html } : undefined),
666694
...(content.mentions
667695
? { "m.mentions": content.mentions }
668696
: undefined),
669-
format: "org.matrix.custom.html",
697+
...(content.html ? { format: "org.matrix.custom.html" } : undefined),
670698
...(safeData
671699
? { "uk.half-shot.hookshot.webhook_data": safeData }
672700
: undefined),

src/Webhooks.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export class Webhooks extends EventEmitter {
6363
this.queue,
6464
false,
6565
this.config.generic.enableHttpGet,
66+
this.config.generic.payloadSizeLimit,
6667
).getRouter(),
6768
);
6869
// TODO: Remove old deprecated endpoint
@@ -71,6 +72,7 @@ export class Webhooks extends EventEmitter {
7172
this.queue,
7273
true,
7374
this.config.generic.enableHttpGet,
75+
this.config.generic.payloadSizeLimit,
7476
).getRouter(),
7577
);
7678
}

src/config/Defaults.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export const DefaultConfigRoot: BridgeConfigRoot = {
125125
waitForComplete: false,
126126
maxExpiryTime: "30d",
127127
sendExpiryNotice: false,
128+
payloadSizeLimit: "1mb",
128129
},
129130
figma: {
130131
publicUrl: `${hookshotWebhooksUrl}/hookshot/`,

src/config/sections/GenericHooks.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { GenericHookServiceConfig } from "../../Connections";
22
import { ConfigError } from "../../Errors";
3-
import { hideKey } from "../Decorators";
3+
import { configKey, hideKey } from "../Decorators";
44
const parseDurationImport = import("parse-duration");
55

66
function makePrefixedUrl(urlString: string): URL {
77
return new URL(urlString.endsWith("/") ? urlString : urlString + "/");
88
}
99

10+
function validatePayloadSizeLimit(sizeLimit: string): boolean {
11+
// Validate format like "1mb", "500kb", "10mb", etc.
12+
const sizePattern = /^\d+(\.\d+)?(kb|mb|gb)$/i;
13+
return sizePattern.test(sizeLimit);
14+
}
15+
1016
export interface BridgeGenericWebhooksConfigYAML {
1117
enabled: boolean;
1218
urlPrefix: string;
@@ -19,6 +25,7 @@ export interface BridgeGenericWebhooksConfigYAML {
1925
sendExpiryNotice?: boolean;
2026
requireExpiryTime?: boolean;
2127
includeHookBody?: boolean;
28+
payloadSizeLimit?: string | number;
2229
}
2330

2431
export class BridgeConfigGenericWebhooks {
@@ -41,6 +48,11 @@ export class BridgeConfigGenericWebhooks {
4148
// Public facing value for config generator
4249
public readonly maxExpiryTime?: string;
4350
public readonly includeHookBody: boolean;
51+
@configKey(
52+
"How large may an incoming payload be. Specify in bytes or in a format like '1mb', '500kb', '10mb', etc.",
53+
true,
54+
)
55+
public readonly payloadSizeLimit: number | string;
4456

4557
constructor(yaml: BridgeGenericWebhooksConfigYAML) {
4658
this.enabled = yaml.enabled || false;
@@ -49,6 +61,40 @@ export class BridgeConfigGenericWebhooks {
4961
this.sendExpiryNotice = yaml.sendExpiryNotice || false;
5062
this.requireExpiryTime = yaml.requireExpiryTime || false;
5163
this.includeHookBody = yaml.includeHookBody ?? true;
64+
65+
// Set default payload size limit to 1mb (safer than 10mb, larger than 100kb)
66+
const payloadSizeLimitYaml =
67+
yaml.payloadSizeLimit === undefined ? "1mb" : yaml.payloadSizeLimit;
68+
69+
if (typeof payloadSizeLimitYaml === "number") {
70+
if (
71+
payloadSizeLimitYaml < 1 ||
72+
Number.isNaN(payloadSizeLimitYaml) ||
73+
!Number.isInteger(payloadSizeLimitYaml)
74+
) {
75+
throw new ConfigError(
76+
"generic.payloadSizeLimit",
77+
"must a positive integer (or in a format like '1mb', '500kb', '10mb', etc.)",
78+
);
79+
}
80+
// Bytes
81+
this.payloadSizeLimit = payloadSizeLimitYaml;
82+
} else if (typeof payloadSizeLimitYaml === "string") {
83+
// Validate the payload size limit format
84+
if (!validatePayloadSizeLimit(payloadSizeLimitYaml)) {
85+
throw new ConfigError(
86+
"generic.payloadSizeLimit",
87+
"must be in format like '1mb', '500kb', '10mb', etc.",
88+
);
89+
}
90+
this.payloadSizeLimit = payloadSizeLimitYaml;
91+
} else {
92+
throw new ConfigError(
93+
"generic.payloadSizeLimit",
94+
"must be a string or a number",
95+
);
96+
}
97+
5298
try {
5399
this.parsedUrlPrefix = makePrefixedUrl(yaml.urlPrefix);
54100
this.urlPrefix = () => {

src/generic/Router.ts

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export class GenericWebhooksRouter {
1515
private readonly queue: MessageQueue,
1616
private readonly deprecatedPath = false,
1717
private readonly allowGet: boolean,
18+
private readonly payloadSizeLimit: string | number,
1819
) {}
1920

2021
private onWebhook(
@@ -92,28 +93,32 @@ export class GenericWebhooksRouter {
9293
});
9394
}
9495

95-
private static xmlHandler(req: Request, res: Response, next: NextFunction) {
96-
express.text({ type: ["*/xml", "+xml"] })(req, res, (err) => {
97-
if (err) {
98-
next(err);
99-
return;
100-
}
101-
if (typeof req.body !== "string") {
102-
next();
103-
return;
104-
}
105-
xml
106-
.parseStringPromise(req.body)
107-
.then((xmlResult) => {
108-
req.body = xmlResult;
96+
private xmlHandler = (req: Request, res: Response, next: NextFunction) => {
97+
express.text({ type: ["*/xml", "+xml"], limit: this.payloadSizeLimit })(
98+
req,
99+
res,
100+
(err: any) => {
101+
if (err) {
102+
next(err);
103+
return;
104+
}
105+
if (typeof req.body !== "string") {
109106
next();
110-
})
111-
.catch((e) => {
112-
res.statusCode = 400;
113-
next(e);
114-
});
115-
});
116-
}
107+
return;
108+
}
109+
xml
110+
.parseStringPromise(req.body)
111+
.then((xmlResult) => {
112+
req.body = xmlResult;
113+
next();
114+
})
115+
.catch((e) => {
116+
res.statusCode = 400;
117+
next(e);
118+
});
119+
},
120+
);
121+
};
117122

118123
public getRouter() {
119124
const router = Router();
@@ -130,10 +135,10 @@ export class GenericWebhooksRouter {
130135
xFrameOptions: { action: "deny" },
131136
crossOriginResourcePolicy: { policy: "same-site" },
132137
}),
133-
GenericWebhooksRouter.xmlHandler,
134-
express.urlencoded({ extended: false }),
135-
express.json(),
136-
express.text({ type: "text/*" }),
138+
this.xmlHandler,
139+
express.urlencoded({ extended: false, limit: this.payloadSizeLimit }),
140+
express.json({ limit: this.payloadSizeLimit }),
141+
express.text({ type: "text/*", limit: this.payloadSizeLimit }),
137142
this.onWebhook.bind(this),
138143
);
139144
return router;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { expect } from "chai";
2+
import { BridgeConfigGenericWebhooks } from "../../../src/config/sections/GenericHooks";
3+
4+
describe("config/sections/GenericHooks", () => {
5+
describe("payloadSizeLimit", () => {
6+
it("with an integer parameter", () => {
7+
new BridgeConfigGenericWebhooks({
8+
enabled: true,
9+
urlPrefix: "https://example.org/foo",
10+
payloadSizeLimit: 100,
11+
});
12+
});
13+
14+
it("throws with a negative integer", () => {
15+
expect(
16+
() =>
17+
new BridgeConfigGenericWebhooks({
18+
enabled: true,
19+
urlPrefix: "https://example.org/foo",
20+
payloadSizeLimit: -1,
21+
}),
22+
).to.throw();
23+
});
24+
25+
it("throws with a NaN integer", () => {
26+
expect(
27+
() =>
28+
new BridgeConfigGenericWebhooks({
29+
enabled: true,
30+
urlPrefix: "https://example.org/foo",
31+
payloadSizeLimit: NaN,
32+
}),
33+
).to.throw();
34+
});
35+
36+
it("throws with a float", () => {
37+
expect(
38+
() =>
39+
new BridgeConfigGenericWebhooks({
40+
enabled: true,
41+
urlPrefix: "https://example.org/foo",
42+
payloadSizeLimit: 50.5,
43+
}),
44+
).to.throw();
45+
});
46+
47+
for (const payloadSizeLimit of ["1mb", "1kb", "1gb"]) {
48+
it(`with an string format parameter ${payloadSizeLimit}`, () => {
49+
new BridgeConfigGenericWebhooks({
50+
enabled: true,
51+
urlPrefix: "https://example.org/foo",
52+
payloadSizeLimit,
53+
});
54+
});
55+
}
56+
});
57+
});

0 commit comments

Comments
 (0)