Skip to content

Commit dc77d9c

Browse files
committed
More thorough handling of edge cases/pathologies
1 parent 7f0cf73 commit dc77d9c

File tree

2 files changed

+139
-6
lines changed

2 files changed

+139
-6
lines changed

src/shared/uriTemplate.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,4 +164,79 @@ describe("UriTemplate", () => {
164164
expect(template.match("/users")).toBeNull();
165165
});
166166
});
167+
168+
describe("security and edge cases", () => {
169+
it("should handle extremely long input strings", () => {
170+
const longString = "x".repeat(100000);
171+
const template = new UriTemplate(`/api/{param}`);
172+
expect(template.expand({ param: longString })).toBe(`/api/${longString}`);
173+
expect(template.match(`/api/${longString}`)).toEqual({ param: longString });
174+
});
175+
176+
it("should handle deeply nested template expressions", () => {
177+
const template = new UriTemplate("{a}{b}{c}{d}{e}{f}{g}{h}{i}{j}".repeat(1000));
178+
expect(() => template.expand({
179+
a: "1", b: "2", c: "3", d: "4", e: "5",
180+
f: "6", g: "7", h: "8", i: "9", j: "0"
181+
})).not.toThrow();
182+
});
183+
184+
it("should handle malformed template expressions", () => {
185+
expect(() => new UriTemplate("{unclosed")).toThrow();
186+
expect(() => new UriTemplate("{}")).not.toThrow();
187+
expect(() => new UriTemplate("{,}")).not.toThrow();
188+
expect(() => new UriTemplate("{a}{")).toThrow();
189+
});
190+
191+
it("should handle pathological regex patterns", () => {
192+
const template = new UriTemplate("/api/{param}");
193+
// Create a string that could cause catastrophic backtracking
194+
const input = "/api/" + "a".repeat(100000);
195+
expect(() => template.match(input)).not.toThrow();
196+
});
197+
198+
it("should handle invalid UTF-8 sequences", () => {
199+
const template = new UriTemplate("/api/{param}");
200+
const invalidUtf8 = "���";
201+
expect(() => template.expand({ param: invalidUtf8 })).not.toThrow();
202+
expect(() => template.match(`/api/${invalidUtf8}`)).not.toThrow();
203+
});
204+
205+
it("should handle template/URI length mismatches", () => {
206+
const template = new UriTemplate("/api/{param}");
207+
expect(template.match("/api/")).toBeNull();
208+
expect(template.match("/api")).toBeNull();
209+
expect(template.match("/api/value/extra")).toBeNull();
210+
});
211+
212+
it("should handle repeated operators", () => {
213+
const template = new UriTemplate("{?a}{?b}{?c}");
214+
expect(template.expand({ a: "1", b: "2", c: "3" })).toBe("?a=1&b=2&c=3");
215+
});
216+
217+
it("should handle overlapping variable names", () => {
218+
const template = new UriTemplate("{var}{vara}");
219+
expect(template.expand({ var: "1", vara: "2" })).toBe("12");
220+
});
221+
222+
it("should handle empty segments", () => {
223+
const template = new UriTemplate("///{a}////{b}////");
224+
expect(template.expand({ a: "1", b: "2" })).toBe("///1////2////");
225+
expect(template.match("///1////2////")).toEqual({ a: "1", b: "2" });
226+
});
227+
228+
it("should handle maximum template expression limit", () => {
229+
// Create a template with many expressions
230+
const expressions = Array(10000).fill("{param}").join("");
231+
expect(() => new UriTemplate(expressions)).not.toThrow();
232+
});
233+
234+
it("should handle maximum variable name length", () => {
235+
const longName = "a".repeat(10000);
236+
const template = new UriTemplate(`{${longName}}`);
237+
const vars: Record<string, string> = {};
238+
vars[longName] = "value";
239+
expect(() => template.expand(vars)).not.toThrow();
240+
});
241+
});
167242
});

src/shared/uriTemplate.ts

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,26 @@
22

33
type Variables = Record<string, string | string[]>;
44

5+
const MAX_TEMPLATE_LENGTH = 1000000; // 1MB
6+
const MAX_VARIABLE_LENGTH = 1000000; // 1MB
7+
const MAX_TEMPLATE_EXPRESSIONS = 10000;
8+
const MAX_REGEX_LENGTH = 1000000; // 1MB
9+
510
export class UriTemplate {
11+
private static validateLength(str: string, max: number, context: string): void {
12+
if (str.length > max) {
13+
throw new Error(
14+
`${context} exceeds maximum length of ${max} characters (got ${str.length})`,
15+
);
16+
}
17+
}
618
private readonly parts: Array<
719
| string
820
| { name: string; operator: string; names: string[]; exploded: boolean }
921
>;
1022

1123
constructor(template: string) {
24+
UriTemplate.validateLength(template, MAX_TEMPLATE_LENGTH, "Template");
1225
this.parts = this.parse(template);
1326
}
1427

@@ -24,6 +37,7 @@ export class UriTemplate {
2437
> = [];
2538
let currentText = "";
2639
let i = 0;
40+
let expressionCount = 0;
2741

2842
while (i < template.length) {
2943
if (template[i] === "{") {
@@ -34,11 +48,28 @@ export class UriTemplate {
3448
const end = template.indexOf("}", i);
3549
if (end === -1) throw new Error("Unclosed template expression");
3650

51+
expressionCount++;
52+
if (expressionCount > MAX_TEMPLATE_EXPRESSIONS) {
53+
throw new Error(
54+
`Template contains too many expressions (max ${MAX_TEMPLATE_EXPRESSIONS})`,
55+
);
56+
}
57+
3758
const expr = template.slice(i + 1, end);
3859
const operator = this.getOperator(expr);
3960
const exploded = expr.includes("*");
4061
const names = this.getNames(expr);
4162
const name = names[0];
63+
64+
// Validate variable name length
65+
for (const name of names) {
66+
UriTemplate.validateLength(
67+
name,
68+
MAX_VARIABLE_LENGTH,
69+
"Variable name",
70+
);
71+
}
72+
4273
parts.push({ name, operator, names, exploded });
4374
i = end + 1;
4475
} else {
@@ -69,6 +100,7 @@ export class UriTemplate {
69100
}
70101

71102
private encodeValue(value: string, operator: string): string {
103+
UriTemplate.validateLength(value, MAX_VARIABLE_LENGTH, "Variable value");
72104
if (operator === "+" || operator === "#") {
73105
return encodeURI(value);
74106
}
@@ -132,12 +164,31 @@ export class UriTemplate {
132164
}
133165

134166
expand(variables: Variables): string {
135-
return this.parts
136-
.map((part) => {
137-
if (typeof part === "string") return part;
138-
return this.expandPart(part, variables);
139-
})
140-
.join("");
167+
let result = "";
168+
let hasQueryParam = false;
169+
170+
for (const part of this.parts) {
171+
if (typeof part === "string") {
172+
result += part;
173+
continue;
174+
}
175+
176+
const expanded = this.expandPart(part, variables);
177+
if (!expanded) continue;
178+
179+
// Convert ? to & if we already have a query parameter
180+
if ((part.operator === "?" || part.operator === "&") && hasQueryParam) {
181+
result += expanded.replace("?", "&");
182+
} else {
183+
result += expanded;
184+
}
185+
186+
if (part.operator === "?" || part.operator === "&") {
187+
hasQueryParam = true;
188+
}
189+
}
190+
191+
return result;
141192
}
142193

143194
private escapeRegExp(str: string): string {
@@ -152,6 +203,11 @@ export class UriTemplate {
152203
}): Array<{ pattern: string; name: string }> {
153204
const patterns: Array<{ pattern: string; name: string }> = [];
154205

206+
// Validate variable name length for matching
207+
for (const name of part.names) {
208+
UriTemplate.validateLength(name, MAX_VARIABLE_LENGTH, "Variable name");
209+
}
210+
155211
if (part.operator === "?" || part.operator === "&") {
156212
for (let i = 0; i < part.names.length; i++) {
157213
const name = part.names[i];
@@ -190,6 +246,7 @@ export class UriTemplate {
190246
}
191247

192248
match(uri: string): Variables | null {
249+
UriTemplate.validateLength(uri, MAX_TEMPLATE_LENGTH, "URI");
193250
let pattern = "^";
194251
const names: Array<{ name: string; exploded: boolean }> = [];
195252

@@ -206,6 +263,7 @@ export class UriTemplate {
206263
}
207264

208265
pattern += "$";
266+
UriTemplate.validateLength(pattern, MAX_REGEX_LENGTH, "Generated regex pattern");
209267
const regex = new RegExp(pattern);
210268
const match = uri.match(regex);
211269

0 commit comments

Comments
 (0)