@oneuptime/common 12.0.26 → 12.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/AnalyticsModels/RumSessionChunk.ts +52 -0
- package/Models/DatabaseModels/CloudResource.ts +17 -1
- package/Models/DatabaseModels/RumApplication.ts +35 -2
- package/Server/API/TelemetryAPI.ts +104 -15
- package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +12 -5
- package/Server/Utils/SessionReplay/SessionReplayIdentity.ts +102 -0
- package/Server/Utils/SessionReplay/SessionReplayReadService.ts +11 -2
- package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +81 -0
- package/Tests/Server/API/SessionReplayAPI.test.ts +232 -0
- package/Tests/Types/API/HostnameAuthorityParsing.test.ts +1 -0
- package/Tests/Types/API/URLMailtoQueryString.test.ts +439 -0
- package/Tests/Types/API/URLQueryString.test.ts +33 -24
- package/Tests/Types/API/URLRouteQuerySeparation.test.ts +491 -0
- package/Tests/UI/Components/LogsHistogramDragTooltip.test.tsx +393 -0
- package/Tests/UI/Components/LogsViewerHistogramZoom.test.tsx +248 -0
- package/Tests/UI/Components/TelemetryHistogramDragTooltip.test.tsx +205 -0
- package/Tests/UI/Components/UseHistogramZoom.test.tsx +262 -0
- package/Types/API/URL.ts +55 -18
- package/Types/Rum/SessionReplay.ts +14 -0
- package/UI/Components/Charts/Utils/useHistogramZoom.ts +101 -0
- package/UI/Components/LogsViewer/LogsViewer.tsx +19 -3
- package/UI/Components/LogsViewer/components/LogsHistogram.tsx +52 -1
- package/UI/Components/TelemetryViewer/TelemetryViewer.tsx +18 -2
- package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +52 -1
- package/build/dist/Models/AnalyticsModels/RumSessionChunk.js +48 -0
- package/build/dist/Models/AnalyticsModels/RumSessionChunk.js.map +1 -1
- package/build/dist/Models/DatabaseModels/CloudResource.js +19 -2
- package/build/dist/Models/DatabaseModels/CloudResource.js.map +1 -1
- package/build/dist/Models/DatabaseModels/RumApplication.js +37 -3
- package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
- package/build/dist/Server/API/TelemetryAPI.js +67 -14
- package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
- package/build/dist/Server/Utils/SessionReplay/SessionReplayIdentity.js +59 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayIdentity.js.map +1 -0
- package/build/dist/Server/Utils/SessionReplay/SessionReplayReadService.js +9 -2
- package/build/dist/Server/Utils/SessionReplay/SessionReplayReadService.js.map +1 -1
- package/build/dist/Types/API/URL.js +48 -17
- package/build/dist/Types/API/URL.js.map +1 -1
- package/build/dist/UI/Components/Charts/Utils/useHistogramZoom.js +47 -0
- package/build/dist/UI/Components/Charts/Utils/useHistogramZoom.js.map +1 -0
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js +15 -3
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +33 -5
- package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js +14 -2
- package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +33 -5
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
- package/package.json +1 -1
|
@@ -235,6 +235,9 @@ describe("URL query string — round trip is lossless", () => {
|
|
|
235
235
|
'https://example.com/p?json={"where":"a=b"}&page=2',
|
|
236
236
|
"https://example.com/p?q=a%3Db",
|
|
237
237
|
"http://localhost:5000/api/test?a=1&b=x=y",
|
|
238
|
+
"https://example.com/p?next=/a/b",
|
|
239
|
+
"https://example.com/?next=/a/b",
|
|
240
|
+
"https://example.com/?redirect=https://app.example.com/home?tab=1",
|
|
238
241
|
"tel:+15555550123?ref=a=b",
|
|
239
242
|
"sms:+15555550123?body=x=1&y=2",
|
|
240
243
|
];
|
|
@@ -400,12 +403,36 @@ describe("URL query string — constructed rather than parsed", () => {
|
|
|
400
403
|
});
|
|
401
404
|
|
|
402
405
|
/*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
406
|
+
* A "/" inside a query value used to be read as a path segment whenever the
|
|
407
|
+
* URL had no path of its own, because fromString split the authority off on
|
|
408
|
+
* the first "/" before the query was ever separated. The query is now cut off
|
|
409
|
+
* first, so no path is invented. URLRouteQuerySeparation.test.ts covers this
|
|
410
|
+
* in full; these two stay here because this is where the behaviour was pinned
|
|
411
|
+
* as a known limitation of the "=" fix.
|
|
412
|
+
*/
|
|
413
|
+
describe("URL query string — a '/' inside a value", () => {
|
|
414
|
+
test("no path is fabricated when the URL has none of its own", () => {
|
|
415
|
+
const url: URL = URL.fromString("https://example.com?next=/a/b");
|
|
416
|
+
|
|
417
|
+
expect(url.getQueryParam("next")).toBe("/a/b");
|
|
418
|
+
expect(url.route.toString()).toBe("");
|
|
419
|
+
expect(url.toString()).toBe("https://example.com/?next=/a/b");
|
|
420
|
+
expect(url.toString()).not.toBe("https://example.com/a/b?next=/a/b");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("and a '/' in a value is harmless once the URL has a path", () => {
|
|
424
|
+
const original: string = "https://example.com/p?next=/a/b";
|
|
425
|
+
|
|
426
|
+
expect(URL.fromString(original).toString()).toBe(original);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
/*
|
|
431
|
+
* Round-tripping is lossless for the cases above, but not universally. The gap
|
|
432
|
+
* below is older than the "=" fix, predates it, and is not reachable from the
|
|
433
|
+
* Jira/OData/signed-URL shapes it set out to fix, which all carry string keys.
|
|
434
|
+
* It is pinned so the round-trip guarantee is not read as broader than it is;
|
|
435
|
+
* if it is fixed later, this test is the one that should change.
|
|
409
436
|
*/
|
|
410
437
|
describe("URL query string — known round-trip limitations", () => {
|
|
411
438
|
test("an integer-like key is hoisted to the front on re-serialization", () => {
|
|
@@ -420,24 +447,6 @@ describe("URL query string — known round-trip limitations", () => {
|
|
|
420
447
|
expect(url.params).toEqual({ b: "1", "1": "x", a: "2" });
|
|
421
448
|
expect(url.toString()).toBe("https://example.com/p?1=x&b=1&a=2");
|
|
422
449
|
});
|
|
423
|
-
|
|
424
|
-
test("a '/' in a value fabricates a path when the URL has no path", () => {
|
|
425
|
-
/*
|
|
426
|
-
* fromString splits the authority off on the first "/" before the query is
|
|
427
|
-
* ever separated, so a "/" inside a query value is read as a path segment.
|
|
428
|
-
* Only bites a URL with no path of its own; "…/p?next=/a/b" is fine.
|
|
429
|
-
*/
|
|
430
|
-
const url: URL = URL.fromString("https://example.com?next=/a/b");
|
|
431
|
-
|
|
432
|
-
expect(url.getQueryParam("next")).toBe("/a/b");
|
|
433
|
-
expect(url.toString()).toBe("https://example.com/a/b?next=/a/b");
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
test("but a '/' in a value is harmless once the URL has a path", () => {
|
|
437
|
-
const original: string = "https://example.com/p?next=/a/b";
|
|
438
|
-
|
|
439
|
-
expect(URL.fromString(original).toString()).toBe(original);
|
|
440
|
-
});
|
|
441
450
|
});
|
|
442
451
|
|
|
443
452
|
/*
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import Hostname from "../../../Types/API/Hostname";
|
|
2
|
+
import Protocol from "../../../Types/API/Protocol";
|
|
3
|
+
import Route from "../../../Types/API/Route";
|
|
4
|
+
import URL from "../../../Types/API/URL";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* fromString used to split the authority off on the FIRST "/" before the query
|
|
8
|
+
* was ever separated, so a "/" inside a query VALUE was read as a path segment
|
|
9
|
+
* on any URL that had no path of its own:
|
|
10
|
+
*
|
|
11
|
+
* URL.fromString("https://example.com?next=/a/b").toString()
|
|
12
|
+
* => "https://example.com/a/b?next=/a/b" <-- "/a/b" invented
|
|
13
|
+
*
|
|
14
|
+
* "https://example.com/p?next=/a/b" was fine, because the real path consumed
|
|
15
|
+
* the split. The bug therefore hid behind every URL that happened to have a
|
|
16
|
+
* path, which is most of them.
|
|
17
|
+
*
|
|
18
|
+
* It matters because Common/Server/Types/Workflow/Components/API/Utils.ts
|
|
19
|
+
* validates the RAW string for SSRF and then dispatches URL.fromString(url):
|
|
20
|
+
* a workflow author whose URL had no path and a "/" in a query value sent a
|
|
21
|
+
* request to a path they never wrote.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
* The path this type would put on the wire, in the shape WHATWG reports it, so
|
|
26
|
+
* the two parsers can be compared directly. An empty route means "no path",
|
|
27
|
+
* which is "/" to WHATWG. Only meaningful for URLs without a fragment: a
|
|
28
|
+
* fragment rides on the end of the route here (this type has no fragment
|
|
29
|
+
* field) and WHATWG keeps it out of pathname.
|
|
30
|
+
*/
|
|
31
|
+
const pathOf: (url: URL) => string = (url: URL): string => {
|
|
32
|
+
const route: string = url.route.toString();
|
|
33
|
+
|
|
34
|
+
if (!route) {
|
|
35
|
+
return "/";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return route.startsWith("/") ? route : "/" + route;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe("URL route/query separation — '/' in a value, no path of its own", () => {
|
|
42
|
+
test("no path is fabricated from the query value", () => {
|
|
43
|
+
const url: URL = URL.fromString("https://example.com?next=/a/b");
|
|
44
|
+
|
|
45
|
+
expect(url.route.toString()).toBe("");
|
|
46
|
+
expect(url.getQueryParam("next")).toBe("/a/b");
|
|
47
|
+
expect(url.toString()).toBe("https://example.com/?next=/a/b");
|
|
48
|
+
expect(url.toString()).not.toBe("https://example.com/a/b?next=/a/b");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a deeply nested value contributes no path segments", () => {
|
|
52
|
+
const url: URL = URL.fromString("https://example.com?next=/a/b/c/d/e");
|
|
53
|
+
|
|
54
|
+
expect(url.route.toString()).toBe("");
|
|
55
|
+
expect(url.getQueryParam("next")).toBe("/a/b/c/d/e");
|
|
56
|
+
expect(url.toString()).toBe("https://example.com/?next=/a/b/c/d/e");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("an absolute URL used as a value does not become the path", () => {
|
|
60
|
+
const url: URL = URL.fromString(
|
|
61
|
+
"https://auth.example.com?redirect=https://app.example.com/home",
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(url.hostname.hostname).toBe("auth.example.com");
|
|
65
|
+
expect(url.route.toString()).toBe("");
|
|
66
|
+
expect(url.getQueryParam("redirect")).toBe("https://app.example.com/home");
|
|
67
|
+
expect(url.toString()).toBe(
|
|
68
|
+
"https://auth.example.com/?redirect=https://app.example.com/home",
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a value that is a single '/' invents nothing", () => {
|
|
73
|
+
const url: URL = URL.fromString("https://example.com?next=/");
|
|
74
|
+
|
|
75
|
+
expect(url.route.toString()).toBe("");
|
|
76
|
+
expect(url.getQueryParam("next")).toBe("/");
|
|
77
|
+
expect(url.toString()).toBe("https://example.com/?next=/");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("params after the slash-bearing one are still parsed", () => {
|
|
81
|
+
const url: URL = URL.fromString("https://example.com?next=/a/b&state=xyz");
|
|
82
|
+
|
|
83
|
+
expect(url.params).toEqual({ next: "/a/b", state: "xyz" });
|
|
84
|
+
expect(url.route.toString()).toBe("");
|
|
85
|
+
expect(url.toString()).toBe("https://example.com/?next=/a/b&state=xyz");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("the slash-bearing param need not be the first one", () => {
|
|
89
|
+
const url: URL = URL.fromString("https://example.com?state=xyz&next=/a/b");
|
|
90
|
+
|
|
91
|
+
expect(url.params).toEqual({ state: "xyz", next: "/a/b" });
|
|
92
|
+
expect(url.route.toString()).toBe("");
|
|
93
|
+
expect(url.toString()).toBe("https://example.com/?state=xyz&next=/a/b");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("several values may carry slashes at once", () => {
|
|
97
|
+
const url: URL = URL.fromString(
|
|
98
|
+
"https://example.com?from=/x/y&to=/z/w&mode=copy",
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(url.params).toEqual({ from: "/x/y", to: "/z/w", mode: "copy" });
|
|
102
|
+
expect(url.route.toString()).toBe("");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a value-less param containing '/' invents no path", () => {
|
|
106
|
+
const url: URL = URL.fromString("https://example.com?/a/b");
|
|
107
|
+
|
|
108
|
+
expect(url.route.toString()).toBe("");
|
|
109
|
+
expect(url.params).toEqual({ "/a/b": "" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("a port on the authority is kept and still gets no path", () => {
|
|
113
|
+
const url: URL = URL.fromString("https://example.com:8080?next=/a/b");
|
|
114
|
+
|
|
115
|
+
expect(url.hostname.hostname).toBe("example.com:8080");
|
|
116
|
+
expect(url.route.toString()).toBe("");
|
|
117
|
+
expect(url.toString()).toBe("https://example.com:8080/?next=/a/b");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("an http (not https) URL is fixed the same way", () => {
|
|
121
|
+
const url: URL = URL.fromString("http://localhost:5000?next=/a/b");
|
|
122
|
+
|
|
123
|
+
expect(url.protocol).toBe(Protocol.HTTP);
|
|
124
|
+
expect(url.route.toString()).toBe("");
|
|
125
|
+
expect(url.toString()).toBe("http://localhost:5000/?next=/a/b");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("an encoded '%2F' was never affected and still is not", () => {
|
|
129
|
+
const url: URL = URL.fromString("https://example.com?next=%2Fa%2Fb");
|
|
130
|
+
|
|
131
|
+
expect(url.route.toString()).toBe("");
|
|
132
|
+
expect(url.getQueryParam("next")).toBe("%2Fa%2Fb");
|
|
133
|
+
expect(url.toString()).toBe("https://example.com/?next=%2Fa%2Fb");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("URL route/query separation — a real path is untouched", () => {
|
|
138
|
+
test("a one-segment path survives a slash-bearing value", () => {
|
|
139
|
+
const url: URL = URL.fromString("https://example.com/p?next=/a/b");
|
|
140
|
+
|
|
141
|
+
expect(url.route.toString()).toBe("p");
|
|
142
|
+
expect(url.getQueryParam("next")).toBe("/a/b");
|
|
143
|
+
expect(url.toString()).toBe("https://example.com/p?next=/a/b");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("a deep path survives a slash-bearing value", () => {
|
|
147
|
+
const url: URL = URL.fromString(
|
|
148
|
+
"https://example.com/api/v1/items?next=/a/b",
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(url.route.toString()).toBe("api/v1/items");
|
|
152
|
+
expect(url.route.toString()).not.toContain("?");
|
|
153
|
+
expect(url.toString()).toBe("https://example.com/api/v1/items?next=/a/b");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a trailing slash on the path is preserved", () => {
|
|
157
|
+
const url: URL = URL.fromString("https://example.com/p/?next=/a/b");
|
|
158
|
+
|
|
159
|
+
expect(url.route.toString()).toBe("p/");
|
|
160
|
+
expect(url.toString()).toBe("https://example.com/p/?next=/a/b");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("a path with no query at all is unchanged", () => {
|
|
164
|
+
const url: URL = URL.fromString("https://example.com/a/b/c");
|
|
165
|
+
|
|
166
|
+
expect(url.route.toString()).toBe("a/b/c");
|
|
167
|
+
expect(url.params).toEqual({});
|
|
168
|
+
expect(url.toString()).toBe("https://example.com/a/b/c");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("the path stops at the first '?', whatever the value holds", () => {
|
|
172
|
+
const url: URL = URL.fromString(
|
|
173
|
+
"https://example.com/api/v1/items?next=/a/b&other=/c/d",
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
expect(url.route.toString()).toBe("api/v1/items");
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("URL route/query separation — '/' in a value plus an embedded '?'", () => {
|
|
181
|
+
test("a path-less URL keeps the whole value and gains no path", () => {
|
|
182
|
+
const url: URL = URL.fromString("https://example.com?next=/a?b=c");
|
|
183
|
+
|
|
184
|
+
expect(url.route.toString()).toBe("");
|
|
185
|
+
expect(url.getQueryParam("next")).toBe("/a?b=c");
|
|
186
|
+
expect(url.toString()).toBe("https://example.com/?next=/a?b=c");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("a redirect target carrying its own query is not turned into a path", () => {
|
|
190
|
+
const url: URL = URL.fromString(
|
|
191
|
+
"https://auth.example.com?redirect=https://app.example.com/home?tab=1",
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
expect(url.hostname.hostname).toBe("auth.example.com");
|
|
195
|
+
expect(url.route.toString()).toBe("");
|
|
196
|
+
expect(url.getQueryParam("redirect")).toBe(
|
|
197
|
+
"https://app.example.com/home?tab=1",
|
|
198
|
+
);
|
|
199
|
+
expect(url.toString()).toBe(
|
|
200
|
+
"https://auth.example.com/?redirect=https://app.example.com/home?tab=1",
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("params after the embedded '?' are still parsed on a path-less URL", () => {
|
|
205
|
+
const url: URL = URL.fromString("https://example.com?next=/a?b=c&state=x");
|
|
206
|
+
|
|
207
|
+
expect(url.params).toEqual({ next: "/a?b=c", state: "x" });
|
|
208
|
+
expect(url.route.toString()).toBe("");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("the same shape with a real path keeps the path and the value", () => {
|
|
212
|
+
const url: URL = URL.fromString(
|
|
213
|
+
"https://auth.example.com/login?redirect=https://app.example.com/home?tab=1&state=x",
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
expect(url.route.toString()).toBe("login");
|
|
217
|
+
expect(url.getQueryParam("redirect")).toBe(
|
|
218
|
+
"https://app.example.com/home?tab=1",
|
|
219
|
+
);
|
|
220
|
+
expect(url.getQueryParam("state")).toBe("x");
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
/*
|
|
225
|
+
* The authority was already cut at "?" and "#" before this change, so the host
|
|
226
|
+
* never moved - the fabricated path was a path bug, not a host bypass. These
|
|
227
|
+
* pin that: the workflow SSRF check reads the host off the RAW string with the
|
|
228
|
+
* WHATWG parser, and both parsers agree on the host for every shape here. What
|
|
229
|
+
* used to diverge between them was the PATH, which is what the request line
|
|
230
|
+
* carried.
|
|
231
|
+
*/
|
|
232
|
+
describe("URL route/query separation — the host never moved", () => {
|
|
233
|
+
const hostCases: Array<string> = [
|
|
234
|
+
"https://example.com?next=/a/b",
|
|
235
|
+
"https://example.com?redirect=https://evil.example.net/x",
|
|
236
|
+
"https://example.com?next=/a?b=c",
|
|
237
|
+
"https://example.com/p?next=/a/b",
|
|
238
|
+
"https://example.com?next=/a/b&state=x",
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
test.each(hostCases)("the host of %s is example.com", (raw: string) => {
|
|
242
|
+
expect(URL.fromString(raw).hostname.hostname).toBe("example.com");
|
|
243
|
+
expect(new globalThis.URL(raw).host).toBe("example.com");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("a value naming another host does not become the host", () => {
|
|
247
|
+
const url: URL = URL.fromString(
|
|
248
|
+
"https://example.com?redirect=https://169.254.169.254/latest/meta-data",
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
expect(url.hostname.hostname).toBe("example.com");
|
|
252
|
+
expect(url.toString()).toContain("https://example.com/?redirect=");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test.each(hostCases)(
|
|
256
|
+
"the path of %s now agrees with the WHATWG parser",
|
|
257
|
+
(raw: string) => {
|
|
258
|
+
expect(pathOf(URL.fromString(raw))).toBe(
|
|
259
|
+
new globalThis.URL(raw).pathname,
|
|
260
|
+
);
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
test("the path used to diverge from what SSRF validation saw", () => {
|
|
265
|
+
/*
|
|
266
|
+
* The exact divergence the fix closes, stated as the two parsers seeing
|
|
267
|
+
* the same request line: WHATWG (which the SSRF check uses) reads "/" for
|
|
268
|
+
* a path-less URL, and this type used to read "/a/b".
|
|
269
|
+
*/
|
|
270
|
+
const raw: string = "https://example.com?next=/a/b";
|
|
271
|
+
|
|
272
|
+
expect(new globalThis.URL(raw).pathname).toBe("/");
|
|
273
|
+
expect(pathOf(URL.fromString(raw))).toBe("/");
|
|
274
|
+
expect(pathOf(URL.fromString(raw))).not.toBe("/a/b");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("userinfo in the authority is still carried whole", () => {
|
|
278
|
+
const url: URL = URL.fromString("https://user:pw@example.com?next=/a/b");
|
|
279
|
+
|
|
280
|
+
expect(url.hostname.hostname).toBe("user:pw@example.com");
|
|
281
|
+
expect(url.route.toString()).toBe("");
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
/*
|
|
286
|
+
* A fragment is not a field on this type; it rides on the end of the route.
|
|
287
|
+
* Cutting the route at "#" as well as "?" - without carrying the fragment
|
|
288
|
+
* across - would have silently dropped it, so these pin that it survives.
|
|
289
|
+
*/
|
|
290
|
+
describe("URL route/query separation — fragments survive", () => {
|
|
291
|
+
test("a fragment after a path is kept", () => {
|
|
292
|
+
const url: URL = URL.fromString("https://example.com/docs#section");
|
|
293
|
+
|
|
294
|
+
expect(url.route.toString()).toBe("docs#section");
|
|
295
|
+
expect(url.toString()).toBe("https://example.com/docs#section");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("a fragment on a path-less URL is no longer dropped", () => {
|
|
299
|
+
const url: URL = URL.fromString("https://example.com#section");
|
|
300
|
+
|
|
301
|
+
expect(url.hostname.hostname).toBe("example.com");
|
|
302
|
+
expect(url.route.toString()).toBe("#section");
|
|
303
|
+
expect(url.toString()).toBe("https://example.com/#section");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("a '/' inside a fragment no longer fabricates a path", () => {
|
|
307
|
+
/*
|
|
308
|
+
* The fragment-side twin of the query bug: "#a/b" used to split as a path
|
|
309
|
+
* and come back out as "https://example.com/b", losing the fragment and
|
|
310
|
+
* inventing a path in one step.
|
|
311
|
+
*/
|
|
312
|
+
const url: URL = URL.fromString("https://example.com#a/b");
|
|
313
|
+
|
|
314
|
+
expect(url.route.toString()).toBe("#a/b");
|
|
315
|
+
expect(url.toString()).toBe("https://example.com/#a/b");
|
|
316
|
+
expect(url.toString()).not.toBe("https://example.com/b");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("a fragment coexists with a slash-bearing query value", () => {
|
|
320
|
+
const url: URL = URL.fromString("https://example.com/p?next=/a/b#frag");
|
|
321
|
+
|
|
322
|
+
expect(url.route.toString()).toBe("p");
|
|
323
|
+
expect(url.getQueryParam("next")).toBe("/a/b#frag");
|
|
324
|
+
expect(url.toString()).toBe("https://example.com/p?next=/a/b#frag");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("a fragment before the query keeps both halves", () => {
|
|
328
|
+
const url: URL = URL.fromString("https://example.com/p#frag?x=1");
|
|
329
|
+
|
|
330
|
+
expect(url.route.toString()).toBe("p#frag");
|
|
331
|
+
expect(url.getQueryParam("x")).toBe("1");
|
|
332
|
+
expect(url.toString()).toBe("https://example.com/p#frag?x=1");
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe("URL route/query separation — round trip is lossless", () => {
|
|
337
|
+
const roundTripCases: Array<string> = [
|
|
338
|
+
"https://example.com/?next=/a/b",
|
|
339
|
+
"https://example.com/?next=/a/b/c/d/e",
|
|
340
|
+
"https://example.com/?next=/",
|
|
341
|
+
"https://example.com/?next=/a/b&state=xyz",
|
|
342
|
+
"https://example.com/?state=xyz&next=/a/b",
|
|
343
|
+
"https://example.com/?next=/a?b=c",
|
|
344
|
+
"https://example.com/?next=/a?b=c&state=x",
|
|
345
|
+
"https://example.com/?redirect=https://app.example.com/home",
|
|
346
|
+
"https://example.com/?redirect=https://app.example.com/home?tab=1",
|
|
347
|
+
"https://example.com:8080/?next=/a/b",
|
|
348
|
+
"http://localhost:5000/?next=/a/b",
|
|
349
|
+
"https://example.com/p?next=/a/b",
|
|
350
|
+
"https://example.com/p/?next=/a/b",
|
|
351
|
+
"https://example.com/api/v1/items?next=/a/b",
|
|
352
|
+
"https://example.com/api/v1/items?next=/a/b&other=/c/d",
|
|
353
|
+
"https://auth.example.com/login?redirect=https://app.example.com/home?tab=1&state=x",
|
|
354
|
+
"https://example.com/docs#section",
|
|
355
|
+
"https://example.com/#section",
|
|
356
|
+
"https://example.com/#a/b",
|
|
357
|
+
"https://example.com/p?next=/a/b#frag",
|
|
358
|
+
"https://example.com/p#frag?x=1",
|
|
359
|
+
];
|
|
360
|
+
|
|
361
|
+
test.each(roundTripCases)(
|
|
362
|
+
"URL.fromString(%s).toString() returns it unchanged",
|
|
363
|
+
(original: string) => {
|
|
364
|
+
expect(URL.fromString(original).toString()).toBe(original);
|
|
365
|
+
},
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
test.each(roundTripCases)(
|
|
369
|
+
"a second parse of %s is stable",
|
|
370
|
+
(original: string) => {
|
|
371
|
+
const once: string = URL.fromString(original).toString();
|
|
372
|
+
|
|
373
|
+
expect(URL.fromString(once).toString()).toBe(once);
|
|
374
|
+
},
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
test.each(roundTripCases)(
|
|
378
|
+
"fromURL re-parses %s unchanged",
|
|
379
|
+
(original: string) => {
|
|
380
|
+
expect(URL.fromURL(URL.fromString(original)).toString()).toBe(original);
|
|
381
|
+
},
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
/*
|
|
385
|
+
* An authority with no path re-serializes with the "/" RFC 3986 6.2.3 makes
|
|
386
|
+
* equivalent to an empty path - "https://example.com" has always come back
|
|
387
|
+
* as "https://example.com/", with or without a query. That normalization
|
|
388
|
+
* predates this change and is why the round-trip list above spells the "/"
|
|
389
|
+
* out; parsing either form twice is stable.
|
|
390
|
+
*/
|
|
391
|
+
const normalizedCases: Array<[string, string]> = [
|
|
392
|
+
["https://example.com?next=/a/b", "https://example.com/?next=/a/b"],
|
|
393
|
+
["https://example.com?next=/a?b=c", "https://example.com/?next=/a?b=c"],
|
|
394
|
+
["https://example.com#section", "https://example.com/#section"],
|
|
395
|
+
["https://example.com", "https://example.com/"],
|
|
396
|
+
["https://example.com?token=x", "https://example.com/?token=x"],
|
|
397
|
+
];
|
|
398
|
+
|
|
399
|
+
test.each(normalizedCases)(
|
|
400
|
+
"%s re-serializes as %s and then holds still",
|
|
401
|
+
(original: string, normalized: string) => {
|
|
402
|
+
expect(URL.fromString(original).toString()).toBe(normalized);
|
|
403
|
+
expect(URL.fromString(normalized).toString()).toBe(normalized);
|
|
404
|
+
},
|
|
405
|
+
);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
/*
|
|
409
|
+
* The workflow "API Get (JSON)" and "API Delete (JSON)" components hand the
|
|
410
|
+
* author's URL to URL.fromString and dispatch whatever comes back, so these
|
|
411
|
+
* are the strings that used to go out to the wrong path.
|
|
412
|
+
*/
|
|
413
|
+
describe("URL route/query separation — the workflow API component path", () => {
|
|
414
|
+
const componentUrls: Array<string> = [
|
|
415
|
+
"https://hooks.example.com/?target=/deploy/rollback",
|
|
416
|
+
"https://api.example.com/?path=/v1/incidents&limit=50",
|
|
417
|
+
"https://example.com/?callback=https://oneuptime.com/api/webhook",
|
|
418
|
+
"https://api.example.com/v1/incidents?callback=/done",
|
|
419
|
+
];
|
|
420
|
+
|
|
421
|
+
test.each(componentUrls)(
|
|
422
|
+
"a component URL is dispatched unchanged: %s",
|
|
423
|
+
(componentUrl: string) => {
|
|
424
|
+
expect(URL.fromString(componentUrl).toString()).toBe(componentUrl);
|
|
425
|
+
},
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
test("a path-less component URL is no longer dispatched to an invented path", () => {
|
|
429
|
+
const dispatched: string = URL.fromString(
|
|
430
|
+
"https://hooks.example.com?target=/deploy/rollback",
|
|
431
|
+
).toString();
|
|
432
|
+
|
|
433
|
+
expect(dispatched).not.toBe(
|
|
434
|
+
"https://hooks.example.com/deploy/rollback?target=/deploy/rollback",
|
|
435
|
+
);
|
|
436
|
+
expect(dispatched).toBe(
|
|
437
|
+
"https://hooks.example.com/?target=/deploy/rollback",
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe("URL route/query separation — other entry points agree", () => {
|
|
443
|
+
test("the constructor produces the same URL as the parser", () => {
|
|
444
|
+
const constructed: URL = new URL(
|
|
445
|
+
Protocol.HTTPS,
|
|
446
|
+
new Hostname("example.com"),
|
|
447
|
+
new Route(""),
|
|
448
|
+
"next=/a/b",
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
expect(constructed.toString()).toBe("https://example.com/?next=/a/b");
|
|
452
|
+
expect(constructed.toString()).toBe(
|
|
453
|
+
URL.fromString("https://example.com?next=/a/b").toString(),
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("fromStringLenient parses the fixed shape rather than falling back", () => {
|
|
458
|
+
const url: URL = URL.fromStringLenient("https://example.com?next=/a/b");
|
|
459
|
+
|
|
460
|
+
expect(url.isMalformed()).toBe(false);
|
|
461
|
+
expect(url.route.toString()).toBe("");
|
|
462
|
+
expect(url.toString()).toBe("https://example.com/?next=/a/b");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("removeQueryString leaves no fabricated path behind", () => {
|
|
466
|
+
const url: URL = URL.fromString(
|
|
467
|
+
"https://example.com?next=/a/b",
|
|
468
|
+
).removeQueryString();
|
|
469
|
+
|
|
470
|
+
expect(url.route.toString()).toBe("");
|
|
471
|
+
expect(url.toString()).toBe("https://example.com/");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("toJSON/fromJSON round trip a slash-bearing value", () => {
|
|
475
|
+
const url: URL = URL.fromString("https://example.com?next=/a/b");
|
|
476
|
+
|
|
477
|
+
expect(URL.fromJSON(url.toJSON()).toString()).toBe(
|
|
478
|
+
"https://example.com/?next=/a/b",
|
|
479
|
+
);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("addQueryParam on a path-less URL adds no path", () => {
|
|
483
|
+
const url: URL = URL.fromString("https://example.com").addQueryParam(
|
|
484
|
+
"next",
|
|
485
|
+
"/a/b",
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
expect(url.route.toString()).toBe("");
|
|
489
|
+
expect(url.toString()).toBe("https://example.com/?next=/a/b");
|
|
490
|
+
});
|
|
491
|
+
});
|