Skip to content

Commit 6f061a1

Browse files
fix(analytics): DATA-13050 Generate text correctly when product name has number sign in name
1 parent 2cd4478 commit 6f061a1

4 files changed

Lines changed: 128 additions & 7 deletions

File tree

jest.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { Config } from 'jest'
22
import nextJest from 'next/jest.js'
33

4+
// Allow unit tests to run without requiring full runtime env vars.
5+
process.env.SKIP_ENV_VALIDATION ??= '1';
6+
47
const createJestConfig = nextJest({
58
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
69
dir: './',

src/app/api/app/load/route.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { z } from 'zod';
33
import { NextResponse, type NextRequest } from 'next/server';
44
import { env } from '~/env.mjs';
55
import * as db from '~/lib/db';
6+
import { sanitizeQueryParamValueInPath } from '~/utils/sanitizeQueryParam';
67

78
const queryParamSchema = z.object({
89
signed_payload_jwt: z.string(),
@@ -30,12 +31,6 @@ const jwtSchema = z.object({
3031
});
3132

3233
export async function GET(request: NextRequest) {
33-
function appendExchangeToken(url: string, token: string): string {
34-
const delimiter = new URL(url, env.APP_ORIGIN).search ? '&' : '?';
35-
36-
return `${url}${delimiter}exchangeToken=${token}`;
37-
}
38-
3934
const parsedParams = queryParamSchema.safeParse(
4035
Object.fromEntries(request.nextUrl.searchParams)
4136
);
@@ -64,8 +59,14 @@ export async function GET(request: NextRequest) {
6459
});
6560

6661
const exchangeToken = await db.saveClientToken(clientToken);
62+
const safePath = sanitizeQueryParamValueInPath(
63+
sanitizeQueryParamValueInPath(path, 'product_name'),
64+
'productName'
65+
);
66+
const redirectUrl = new URL(safePath, env.APP_ORIGIN);
67+
redirectUrl.searchParams.set('exchangeToken', exchangeToken);
6768

68-
return NextResponse.redirect(new URL(appendExchangeToken(path, exchangeToken), env.APP_ORIGIN), {
69+
return NextResponse.redirect(redirectUrl, {
6970
status: 302,
7071
statusText: 'Found',
7172
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {
2+
sanitizeQueryParamValue,
3+
sanitizeQueryParamValueInPath,
4+
} from './sanitizeQueryParam';
5+
6+
describe('sanitizeQueryParamValue', () => {
7+
it('encodes reserved characters that break parsing (#, &, +, space)', () => {
8+
expect(sanitizeQueryParamValue('Widget #2')).toBe('Widget%20%232');
9+
expect(sanitizeQueryParamValue('AT&T')).toBe('AT%26T');
10+
expect(sanitizeQueryParamValue('plus+sign')).toBe('plus%2Bsign');
11+
expect(sanitizeQueryParamValue('has space')).toBe('has%20space');
12+
});
13+
14+
it('preserves valid percent-escapes but encodes stray %', () => {
15+
expect(sanitizeQueryParamValue('already%23encoded')).toBe(
16+
'already%23encoded'
17+
);
18+
expect(sanitizeQueryParamValue('100% legit')).toBe('100%25%20legit');
19+
});
20+
});
21+
22+
describe('sanitizeQueryParamValueInPath', () => {
23+
const origin = 'https://example.com';
24+
25+
it('keeps full product_name when it contains #', () => {
26+
const safePath = sanitizeQueryParamValueInPath(
27+
'/p?product_name=Widget #2',
28+
'product_name'
29+
);
30+
const url = new URL(safePath, origin);
31+
32+
expect(url.searchParams.get('product_name')).toBe('Widget #2');
33+
expect(url.hash).toBe('');
34+
});
35+
36+
it('keeps full product_name when it contains &', () => {
37+
const safePath = sanitizeQueryParamValueInPath(
38+
'/p?product_name=AT&T',
39+
'product_name'
40+
);
41+
const url = new URL(safePath, origin);
42+
43+
expect(url.searchParams.get('product_name')).toBe('AT&T');
44+
// Ensure it didn't accidentally create an extra param
45+
expect(Array.from(url.searchParams.keys())).toEqual(['product_name']);
46+
});
47+
48+
it('keeps plus sign as literal + (not space)', () => {
49+
const safePath = sanitizeQueryParamValueInPath(
50+
'/p?product_name=plus+sign',
51+
'product_name'
52+
);
53+
const url = new URL(safePath, origin);
54+
55+
expect(url.searchParams.get('product_name')).toBe('plus+sign');
56+
});
57+
58+
it('does not modify other params while sanitizing the target param', () => {
59+
const safePath = sanitizeQueryParamValueInPath(
60+
'/p?exchangeToken=abc&product_name=Widget #2',
61+
'product_name'
62+
);
63+
const url = new URL(safePath, origin);
64+
65+
expect(url.searchParams.get('exchangeToken')).toBe('abc');
66+
expect(url.searchParams.get('product_name')).toBe('Widget #2');
67+
});
68+
69+
it('returns the original path if param is missing', () => {
70+
expect(sanitizeQueryParamValueInPath('/p?foo=bar', 'product_name')).toBe(
71+
'/p?foo=bar'
72+
);
73+
});
74+
});

src/utils/sanitizeQueryParam.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* BigCommerce can provide URLs where a query param value contains raw reserved characters
3+
* (e.g. `product_name=Widget #2`, `product_name=AT&T`, `product_name=plus+sign`).
4+
*
5+
* These can be interpreted as URL delimiters:
6+
* - `#` fragment delimiter (truncates the query string)
7+
* - `&` query separator (splits into new params)
8+
* - `+` is treated as space in x-www-form-urlencoded semantics
9+
*
10+
* We sanitize values in-place so URL parsing retains the full intended value.
11+
*/
12+
export function sanitizeQueryParamValue(value: string): string {
13+
const withSafePercents = value.replace(/%(?![0-9A-Fa-f]{2})/g, '%25');
14+
15+
return withSafePercents
16+
.replaceAll('+', '%2B')
17+
.replaceAll('#', '%23')
18+
.replaceAll('&', '%26')
19+
.replaceAll(' ', '%20');
20+
}
21+
22+
export function sanitizeQueryParamValueInPath(
23+
path: string,
24+
paramName: string
25+
): string {
26+
const key = `${paramName}=`;
27+
const keyIdx = path.indexOf(key);
28+
if (keyIdx === -1) return path;
29+
30+
const valueStart = keyIdx + key.length;
31+
const remainder = path.slice(valueStart);
32+
const delimiterMatch = remainder.match(/&[A-Za-z0-9_.~-]+=/);
33+
const endIdx =
34+
delimiterMatch && typeof delimiterMatch.index === 'number'
35+
? valueStart + delimiterMatch.index
36+
: path.length;
37+
38+
const before = path.slice(0, valueStart);
39+
const value = path.slice(valueStart, endIdx);
40+
const after = path.slice(endIdx);
41+
42+
return `${before}${sanitizeQueryParamValue(value)}${after}`;
43+
}

0 commit comments

Comments
 (0)