mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
f889c34997
commit
d661c80903
10 changed files with 207 additions and 22 deletions
5
.changeset/sixty-years-jog.md
Normal file
5
.changeset/sixty-years-jog.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
fix: Add better URL encoding for query params with special characters
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
105
packages/app/src/utils/__tests__/queryParsers.test.ts
Normal file
105
packages/app/src/utils/__tests__/queryParsers.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue