Skip to content

Commit cd6b053

Browse files
committed
Refactor not to use URLSearchParams, because it led to double decoding
1 parent caf00fe commit cd6b053

File tree

3 files changed

+37
-25
lines changed

3 files changed

+37
-25
lines changed

assets/js/dashboard/query.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ describe(`${maybeGetRedirectTargetFromLegacySearchParams.name}`, () => {
1212
])('for modern search %p returns null', (search) => {
1313
expect(
1414
maybeGetRedirectTargetFromLegacySearchParams({
15-
pathname: '/example.com%2Fdeeper',
15+
pathname: '/example.com%2Fdeep%2Fpath',
1616
search
1717
} as Location)
1818
).toBeNull()

assets/js/dashboard/util/url-search-params.test.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ describe(`${isSearchEntryDefined.name}`, () => {
4545
})
4646
})
4747

48-
describe(`${serializeLabelsEntry.name} and decodeURIComponent(${parseLabelsEntry.name}(...)) are opposite of each other`, () => {
48+
describe(`${serializeLabelsEntry.name} and ${parseLabelsEntry.name}(...) are opposite of each other`, () => {
4949
test.each<[[string, string], string]>([
5050
[['US', 'United States'], 'US,United%20States'],
5151
[['FR-IDF', 'Île-de-France'], 'FR-IDF,%C3%8Ele-de-France'],
@@ -55,23 +55,27 @@ describe(`${serializeLabelsEntry.name} and decodeURIComponent(${parseLabelsEntry
5555
(entry, expected) => {
5656
const serialized = serializeLabelsEntry(entry)
5757
expect(serialized).toEqual(expected)
58-
expect(parseLabelsEntry(decodeURIComponent(serialized))).toEqual(entry)
58+
expect(parseLabelsEntry(serialized)).toEqual(entry)
5959
}
6060
)
6161
})
6262

63-
describe(`${serializeFilter.name} and decodeURIComponent(${parseFilter.name}(...)) are opposite of each other`, () => {
63+
describe(`${serializeFilter.name} and ${parseFilter.name}(...) are opposite of each other`, () => {
6464
test.each<[Filter, string]>([
6565
[
66-
['contains', 'entry_page', ['/forecast/:city', 'ü']],
67-
'contains,entry_page,/forecast/:city,%C3%BC'
66+
['contains', 'entry_page', ['/forecast/:city', ',"\'']],
67+
"contains,entry_page,/forecast/:city,%2C%22'"
68+
],
69+
[
70+
['is', 'props:complex/prop-with-comma-etc,$#%', ['(none)']],
71+
'is,props:complex/prop-with-comma-etc%2C%24%23%25,(none)'
6872
]
6973
])(
7074
'filter %p serializes to %p, parses back to original',
7175
(filter, expected) => {
7276
const serialized = serializeFilter(filter)
7377
expect(serialized).toEqual(expected)
74-
expect(parseFilter(decodeURIComponent(serialized))).toEqual(filter)
78+
expect(parseFilter(serialized)).toEqual(filter)
7579
}
7680
)
7781
})

assets/js/dashboard/util/url-search-params.ts

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,34 +32,38 @@ export function stringifySearch(
3232
}
3333

3434
export function parseSearch(searchString: string): Record<string, unknown> {
35-
const urlSearchParams = new URLSearchParams(searchString)
3635
const searchRecord: Record<string, string | boolean> = {}
3736
const filters: Filter[] = []
3837
const labels: FilterClauseLabels = {}
39-
urlSearchParams.forEach((value, key) => {
38+
39+
for (const param of searchString.startsWith('?')
40+
? searchString.slice(1).split('&')
41+
: searchString.split('&')) {
42+
const [key, rawValue] = param.split('=')
4043
switch (key) {
4144
case FILTER_URL_PARAM_NAME: {
42-
const filter = parseFilter(value)
45+
const filter = parseFilter(rawValue)
4346
if (filter.length === 3 && filter[2].length) {
4447
filters.push(filter)
4548
}
4649
break
4750
}
4851
case LABEL_URL_PARAM_NAME: {
49-
const [labelKey, labelValue] = parseLabelsEntry(value)
52+
const [labelKey, labelValue] = parseLabelsEntry(rawValue)
5053
if (labelKey.length && labelValue.length) {
5154
labels[labelKey] = labelValue
5255
}
5356
break
5457
}
5558
default: {
56-
const parsedValue = parseSimpleSearchEntry(value)
59+
const parsedValue = parseSimpleSearchEntry(rawValue)
5760
if (parsedValue !== null) {
58-
searchRecord[key] = parsedValue
61+
searchRecord[decodeURIComponent(key)] = parsedValue
5962
}
6063
}
6164
}
62-
})
65+
}
66+
6367
return {
6468
...searchRecord,
6569
...(filters.length && { filters }),
@@ -79,14 +83,13 @@ export function serializeLabelsEntry([labelKey, labelValue]: [string, string]) {
7983
}
8084

8185
/**
82-
* Parses the output of @see serializeLabelsEntry back to labels object entry,
83-
* once it has gone through URL decoding via new URLSearchParams(location.search).
86+
* Parses the output of @see serializeLabelsEntry back to labels object entry.
8487
*/
8588
export function parseLabelsEntry(
8689
labelKeyValueString: string
8790
): [string, string] {
8891
const [key, value] = labelKeyValueString.split(',')
89-
return [key, value]
92+
return [decodeURIComponent(key), decodeURIComponent(value)]
9093
}
9194

9295
/**
@@ -96,8 +99,8 @@ export function parseLabelsEntry(
9699
*/
97100
export function serializeFilter([operator, dimension, clauses]: Filter) {
98101
const serializedFilter = [
99-
operator,
100-
dimension,
102+
encodeURIComponentPermissive(operator, ':/'),
103+
encodeURIComponentPermissive(dimension, ':/'),
101104
...clauses.map((clause) =>
102105
encodeURIComponentPermissive(clause.toString(), ':/')
103106
)
@@ -106,17 +109,23 @@ export function serializeFilter([operator, dimension, clauses]: Filter) {
106109
}
107110

108111
/**
109-
* Parses the output of @see serializeFilter back to filters array item,
110-
* once it has gone through URL decoding via new URLSearchParams(location.search).
112+
* Parses the output of @see serializeFilter back to filters array item.
111113
*/
112114
export function parseFilter(filterString: string): Filter {
113115
const [operator, dimension, ...unparsedClauses] = filterString.split(',')
114-
return [operator, dimension, unparsedClauses]
116+
return [
117+
decodeURIComponent(operator),
118+
decodeURIComponent(dimension),
119+
unparsedClauses.map(decodeURIComponent)
120+
]
115121
}
116122

117123
/**
118124
* Encodes for URL simple search param values.
125+
* Encodes numbers and number-like strings as indistinguishable strings. Parse treats them as strings.
126+
* Encodes booleans and strings "true" and "false" as indistinguishable strings. Parse treats these as booleans.
119127
* Unifies unhandleable complex search entries like undefined, null, objects and arrays as undefined.
128+
* Complex URL params must be handled separately.
120129
*/
121130
export function serializeSimpleSearchEntry([key, value]: [string, unknown]): [
122131
string,
@@ -132,8 +141,7 @@ export function serializeSimpleSearchEntry([key, value]: [string, unknown]): [
132141
}
133142

134143
/**
135-
* Parses output of @see serializeSimpleSearchEntry,
136-
* once it has gone through URL decoding via new URLSearchParams(location.search)
144+
* Parses output of @see serializeSimpleSearchEntry.
137145
*/
138146
export function parseSimpleSearchEntry(
139147
searchParamValue: string
@@ -144,7 +152,7 @@ export function parseSimpleSearchEntry(
144152
if (searchParamValue === 'false') {
145153
return false
146154
}
147-
return searchParamValue
155+
return decodeURIComponent(searchParamValue)
148156
}
149157

150158
export function encodeURIComponentPermissive(

0 commit comments

Comments
 (0)