fix: Add better URL encoding for query params with special characters (#1794)

Adds custom nuqs parsers (parseAsStringEncoded, parseAsJsonEncoded) that pre-encode values with encodeURIComponent, producing %XX sequences instead of + form-encoding. This protects against re-encoding + to %2B when sharing links, which was causing ClickHouse query errors when accessed from tools like Microsoft Teams.

Backward compatible: parsers fall back to plain JSON.parse for old-format URLs.

Test:
- New URLs
- Grab a play.hyperdx.io URL and change the source and domain in the URL. Proves "old" URLS still work.

I verified that links generated from this branch work as intended in Microsoft Teams, which was the original source of this report.

Fixes: HDX-3184
This commit is contained in:
Tom Alexander 2026-03-04 15:34:56 -05:00 committed by GitHub
parent f889c34997
commit d661c80903
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 207 additions and 22 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
fix: Add better URL encoding for query params with special characters

View file

@ -91,7 +91,7 @@ import { Tags } from './components/Tags';
import useDashboardFilters from './hooks/useDashboardFilters';
import { useDashboardRefresh } from './hooks/useDashboardRefresh';
import { useBrandDisplayName } from './theme/ThemeProvider';
import { parseAsStringWithNewLines } from './utils/queryParsers';
import { parseAsStringEncoded } from './utils/queryParsers';
import { buildTableRowSearchUrl, DEFAULT_CHART_CONFIG } from './ChartUtils';
import { IS_LOCAL_MODE } from './config';
import { useDashboard } from './dashboard';
@ -742,7 +742,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
) as [SQLInterval | undefined, (value: SQLInterval | undefined) => void];
const [where, setWhere] = useQueryState(
'where',
parseAsStringWithNewLines.withDefault(''),
parseAsStringEncoded.withDefault(''),
);
const [whereLanguage, setWhereLanguage] = useQueryState(
'whereLanguage',

View file

@ -16,7 +16,6 @@ import router from 'next/router';
import {
parseAsBoolean,
parseAsInteger,
parseAsJson,
parseAsString,
parseAsStringEnum,
useQueryState,
@ -129,8 +128,9 @@ import {
import { useTableMetadata } from './hooks/useMetadata';
import { useSqlSuggestions } from './hooks/useSqlSuggestions';
import {
parseAsJsonEncoded,
parseAsSortingStateString,
parseAsStringWithNewLines,
parseAsStringEncoded,
} from './utils/queryParsers';
import api from './api';
import { LOCAL_STORE_CONNECTIONS_KEY } from './connection';
@ -801,11 +801,11 @@ export function useDefaultOrderBy(sourceID: string | undefined | null) {
// This is outside as it needs to be a stable reference
const queryStateMap = {
source: parseAsString,
where: parseAsStringWithNewLines,
select: parseAsStringWithNewLines,
where: parseAsStringEncoded,
select: parseAsStringEncoded,
whereLanguage: parseAsStringEnum<'sql' | 'lucene'>(['sql', 'lucene']),
filters: parseAsJson<Filter[]>(),
orderBy: parseAsStringWithNewLines,
filters: parseAsJsonEncoded<Filter[]>(),
orderBy: parseAsStringEncoded,
};
function DBSearchPage() {

View file

@ -1,7 +1,7 @@
import { useCallback, useContext, useMemo, useState } from 'react';
import { sq } from 'date-fns/locale';
import ms from 'ms';
import { parseAsString, useQueryState } from 'nuqs';
import { useQueryState } from 'nuqs';
import { useForm, useWatch } from 'react-hook-form';
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
import {
@ -17,6 +17,7 @@ import SearchWhereInput, {
import { RowWhereResult, WithClause } from '@/hooks/useRowWhere';
import { useSource } from '@/source';
import { formatAttributeClause } from '@/utils';
import { parseAsStringEncoded } from '@/utils/queryParsers';
import { ROW_DATA_ALIASES } from './DBRowDataPanel';
import DBRowSidePanel, { RowSidePanelContext } from './DBRowSidePanel';
@ -48,8 +49,10 @@ interface ContextSubpanelProps {
export function useNestedPanelState(isNested?: boolean) {
// Query state (URL-based) for root level
const queryState = {
contextRowId: useQueryState('contextRowId', parseAsString),
contextRowSource: useQueryState('contextRowSource', parseAsString),
contextRowId: useQueryState('contextRowId', parseAsStringEncoded),
// Source IDs are MongoDB ObjectIDs (hex strings) and contain no special
// characters, so no encoding is needed here.
contextRowSource: useQueryState('contextRowSource'),
};
// Local state for nested levels

View file

@ -11,6 +11,7 @@ import { RowWhereResult, WithClause } from '@/hooks/useRowWhere';
import { useSource } from '@/source';
import TabBar from '@/TabBar';
import { useLocalStorage } from '@/utils';
import { parseAsStringEncoded } from '@/utils/queryParsers';
import { useNestedPanelState } from './ContextSidePanel';
import { RowDataPanel } from './DBRowDataPanel';
@ -62,7 +63,7 @@ export default function DBSqlRowTableWithSideBar({
variant,
}: Props) {
const { data: sourceData } = useSource({ id: sourceId });
const [rowId, setRowId] = useQueryState('rowWhere');
const [rowId, setRowId] = useQueryState('rowWhere', parseAsStringEncoded);
const [rowSource, setRowSource] = useQueryState('rowSource');
const [aliasWith, setAliasWith] = useState<WithClause[]>([]);
const { setContextRowId, setContextRowSource } = useNestedPanelState();

View file

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { parseAsJson, useQueryState } from 'nuqs';
import { useQueryState } from 'nuqs';
import { useForm, useWatch } from 'react-hook-form';
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
import { SourceKind } from '@hyperdx/common-utils/dist/types';
@ -20,11 +20,18 @@ import { SQLInlineEditorControlled } from '@/components/SearchInput/SQLInlineEdi
import { WithClause } from '@/hooks/useRowWhere';
import { useSource, useUpdateSource } from '@/source';
import TabBar from '@/TabBar';
import { parseAsJsonEncoded } from '@/utils/queryParsers';
import { RowDataPanel } from './DBRowDataPanel';
import { RowOverviewPanel } from './DBRowOverviewPanel';
import { SourceSelectControlled } from './SourceSelect';
const eventRowWhereParser = parseAsJsonEncoded<{
id: string;
type: string;
aliasWith: WithClause[];
}>();
enum Tab {
Overview = 'overview',
Parsed = 'parsed',
@ -96,7 +103,7 @@ export default function DBTracePanel({
const [eventRowWhere, setEventRowWhere] = useQueryState(
'eventRowWhere',
parseAsJson<{ id: string; type: string; aliasWith: WithClause[] }>(),
eventRowWhereParser,
);
const {

View file

@ -5,12 +5,13 @@ import { TSource } from '@hyperdx/common-utils/dist/types';
import { IconArrowsMaximize, IconChevronRight } from '@tabler/icons-react';
import { INTERNAL_ROW_FIELDS } from '@/hooks/useRowWhere';
import { parseAsStringEncoded } from '@/utils/queryParsers';
import styles from '../../styles/LogTable.module.scss';
// Hook that provides a function to open the sidebar with specific row details
const useSidebarOpener = () => {
const [, setRowId] = useQueryState('rowWhere');
const [, setRowId] = useQueryState('rowWhere', parseAsStringEncoded);
const [, setRowSource] = useQueryState('rowSource');
return useCallback(

View file

@ -1,13 +1,16 @@
import { useCallback, useMemo } from 'react';
import { parseAsJson, useQueryState } from 'nuqs';
import { useQueryState } from 'nuqs';
import { DashboardFilter, Filter } from '@hyperdx/common-utils/dist/types';
import { FilterState, filtersToQuery, parseQuery } from '@/searchFilters';
import { parseAsJsonEncoded } from '@/utils/queryParsers';
const filterQueriesParser = parseAsJsonEncoded<Filter[]>();
const useDashboardFilters = (filters: DashboardFilter[]) => {
const [filterQueries, setFilterQueries] = useQueryState(
'filters',
parseAsJson<Filter[]>(),
filterQueriesParser,
);
const setFilterValue = useCallback(

View file

@ -0,0 +1,105 @@
import { parseAsJsonEncoded, parseAsStringEncoded } from '../queryParsers';
// Helper: extract the parse/serialize functions from the parser object
const stringParser = parseAsStringEncoded;
const jsonParser = parseAsJsonEncoded<unknown>();
describe('parseAsStringEncoded', () => {
describe('parse', () => {
it('decodes a double-encoded value (new format)', () => {
// serialize produces encodeURIComponent(value); nuqs double-encodes % → %25.
// URLSearchParams.get() strips one layer, leaving our encoded value.
expect(stringParser.parse('hello%20world')).toBe('hello world');
});
it('handles plain string with no encoding (old format)', () => {
// decodeURIComponent on a plain string is a no-op
expect(stringParser.parse('hello world')).toBe('hello world');
});
it('decodes special characters', () => {
expect(stringParser.parse('a%3Ab')).toBe('a:b');
expect(stringParser.parse('%5B%5D')).toBe('[]');
});
it('returns value as-is on malformed URI sequence', () => {
// '%zz' is not valid percent-encoding
expect(stringParser.parse('hello%zz')).toBe('hello%zz');
});
});
describe('serialize', () => {
it('encodes spaces as %20', () => {
expect(stringParser.serialize('hello world')).toBe('hello%20world');
});
it('encodes special characters', () => {
expect(stringParser.serialize('a+b')).toBe('a%2Bb');
expect(stringParser.serialize('[1,2]')).toBe('%5B1%2C2%5D');
});
it('round-trips through parse → serialize', () => {
const original = 'foo bar+baz [test]';
expect(stringParser.parse(stringParser.serialize(original))).toBe(
original,
);
});
});
});
describe('parseAsJsonEncoded', () => {
describe('parse', () => {
it('parses a double-encoded JSON value (new format)', () => {
// New format: serialize encodes JSON via encodeURIComponent; after
// URLSearchParams.get() strips one layer our parse receives the once-encoded string.
const value = [{ key: 'hello world' }];
const serialized = jsonParser.serialize(value); // encodeURIComponent(JSON.stringify(value))
expect(jsonParser.parse(serialized)).toEqual(value);
});
it('parses plain JSON string (old format — no double-encoding)', () => {
// Old format: nuqs wrote raw JSON, URLSearchParams.get() decoded + → space already.
const raw = JSON.stringify([{ key: 'value' }]);
expect(jsonParser.parse(raw)).toEqual([{ key: 'value' }]);
});
it('parses old-format URL where nuqs used + for spaces', () => {
// URLSearchParams.get() already turned + into space, so we receive a space.
const raw = JSON.stringify([{ key: 'hello world' }]);
expect(jsonParser.parse(raw)).toEqual([{ key: 'hello world' }]);
});
it('returns null for malformed JSON after successful URI decode', () => {
// A valid percent-sequence that decodes to something that is not JSON.
expect(jsonParser.parse('not-json')).toBeNull();
});
it('returns null for truly malformed input', () => {
// Both decode and parse should fail gracefully.
expect(jsonParser.parse('%zz{broken')).toBeNull();
});
it('does not fall back to raw parse when decode succeeds but JSON is malformed', () => {
// encodeURIComponent('bad json') is a valid URI sequence that decodes to 'bad json'.
// The old fallback would try JSON.parse('bad json') → still null, but with the
// new separated logic this is handled cleanly without masking.
const encoded = encodeURIComponent('bad json');
expect(jsonParser.parse(encoded)).toBeNull();
});
});
describe('serialize', () => {
it('encodes JSON as a URI component', () => {
const value = [{ key: 'a b' }];
const serialized = jsonParser.serialize(value);
expect(serialized).toBe(encodeURIComponent(JSON.stringify(value)));
});
it('round-trips through parse → serialize', () => {
const original = [{ key: 'hello world', num: 42 }];
expect(jsonParser.parse(jsonParser.serialize(original))).toEqual(
original,
);
});
});
});

View file

@ -1,13 +1,73 @@
import { createParser } from 'nuqs';
import { SortingState } from '@tanstack/react-table';
// Note: this can be deleted once we upgrade to nuqs v2.2.3
// https://github.com/47ng/nuqs/pull/783
export const parseAsStringWithNewLines = createParser<string>({
parse: value => value.replace(/%0A/g, '\n'),
serialize: value => value.replace(/\n/g, '%0A'),
/**
* Problem: nuqs serializes spaces as '+' (form-encoding). When
* URLs are shared via Microsoft Teams (and some other systems), they re-encode
* '+' as '%2B'. That makes '%2B' decode as a literal '+' instead of a space,
* breaking lucene queries, SQL expressions, etc.
*
* Fix: pre-encode the value with encodeURIComponent in serialize (spaces
* '%20', brackets, quotes, etc. also encoded). nuqs then double-encodes our
* '%' signs ('%20' '%2520'). Teams sees only '%XX' sequences and leaves
* them alone. On load, URLSearchParams.get() decodes one level and our parse
* function decodes the second level.
*
* Backward compatible: old URLs where nuqs wrote '+' for spaces are still
* handled correctly because URLSearchParams.get() decodes '+' ' ' before
* our parse function runs, and decodeURIComponent of a plain string is a no-op.
*
* Also supersedes parseAsStringWithNewLines (encodeURIComponent encodes \n
* as %0A automatically).
*/
export const parseAsStringEncoded = createParser<string>({
parse: value => {
try {
return decodeURIComponent(value);
} catch {
// Malformed URI sequence return as-is for robustness.
return value;
}
},
serialize: value => encodeURIComponent(value),
});
/**
* Same double-encoding protection as parseAsStringEncoded, but wraps
* JSON.stringify / JSON.parse around the value.
*
* Backward compatible: old URLs where nuqs wrote raw JSON (with '+' for
* spaces, unencoded '[', ']', etc.) are handled via a fallback to plain
* JSON.parse after the decodeURIComponent step naturally resolves '%22'
* '"', '+' ' ', etc. via URLSearchParams.get().
*/
export function parseAsJsonEncoded<T>() {
return createParser<T>({
parse: value => {
let decoded: string;
try {
decoded = decodeURIComponent(value);
} catch {
// Malformed URI sequence — value is likely old-format plain JSON.
try {
return JSON.parse(value);
} catch {
return null;
}
}
// URI decoded successfully; parse the decoded string as JSON.
// This handles both new-format (double-encoded) and old-format URLs,
// since decodeURIComponent is a no-op on plain JSON strings.
try {
return JSON.parse(decoded);
} catch {
return null;
}
},
serialize: value => encodeURIComponent(JSON.stringify(value)),
});
}
export const parseAsSortingStateString = createParser<SortingState[number]>({
parse: value => {
if (!value) {