Add double quotes wrapping and escaping on displating matcher form inputs

This commit is contained in:
Konrad Lalik
2024-02-02 09:20:24 +01:00
parent 49105becb2
commit 820f4717bf
4 changed files with 69 additions and 37 deletions
@@ -48,39 +48,6 @@ export interface AmRoutesExpandedFormProps {
actionButtons: ReactNode;
defaults?: Partial<FormAmRoute>;
}
function unwrapQuotes(value: string): { unquoted: string; hasQuotes: boolean } {
if (value.startsWith('"') && value.endsWith('"')) {
return { unquoted: value.slice(1, -1), hasQuotes: true };
}
return { unquoted: value, hasQuotes: false };
}
const matcherValueValidator = {
containsReservedCharacter: (value: string) => {
const { unquoted, hasQuotes } = unwrapQuotes(value);
const reservedCharacters = ['{', '}', '!', '=', '~', '\\', '"'];
// Reserved characters are allowed ONLY IF wrapped in quotes
if (hasQuotes) {
return true;
}
return reservedCharacters.some((char) => unquoted.includes(char)) ? 'Contains reserved character.' : true;
},
containsUnescapedCharacters: (value: string) => {
const unescapedQuotesRegex = /(?<!\\)"/g;
const unescapedBackslashRegex = /(?<!\\)\\[^\\"]/g;
const { unquoted } = unwrapQuotes(value);
return unescapedQuotesRegex.test(unquoted)
? 'Contains unescaped double quotes.'
: unescapedBackslashRegex.test(unquoted)
? 'Contains unescaped backslash.'
: true;
},
};
export const AmRoutesExpandedForm = ({
actionButtons,
receivers,
@@ -170,7 +137,6 @@ export const AmRoutesExpandedForm = ({
<Input
{...register(`object_matchers.${index}.value`, {
required: 'Field is required',
validate: matcherValueValidator,
})}
defaultValue={field.value}
placeholder="value"
@@ -1,4 +1,4 @@
import { Route } from 'app/plugins/datasource/alertmanager/types';
import { MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types';
import { FormAmRoute } from '../types/amroutes';
@@ -53,6 +53,30 @@ describe('formAmRouteToAmRoute', () => {
expect(amRoute.group_by).toStrictEqual(['SHOULD BE SET']);
});
});
it('should quote and escape matcher values', () => {
// Arrange
const route: FormAmRoute = buildFormAmRoute({
id: '1',
object_matchers: [
{ name: 'foo', operator: MatcherOperator.equal, value: 'bar' },
{ name: 'foo', operator: MatcherOperator.equal, value: 'bar"baz' },
{ name: 'foo', operator: MatcherOperator.equal, value: 'bar\\baz' },
{ name: 'foo', operator: MatcherOperator.equal, value: '\\bar\\baz"\\' },
],
});
// Act
const amRoute = formAmRouteToAmRoute('mimir-am', route, { id: 'root' });
// Assert
expect(amRoute.matchers).toStrictEqual([
'foo="bar"',
'foo="bar\\"baz"',
'foo="bar\\\\baz"',
'foo="\\\\bar\\\\baz\\"\\\\"',
]);
});
});
describe('amRouteToFormAmRoute', () => {
@@ -101,4 +125,23 @@ describe('amRouteToFormAmRoute', () => {
expect(formRoute.overrideGrouping).toBe(true);
});
});
it('should unquote and unescape matchers values', () => {
// Arrange
const amRoute = buildAmRoute({
matchers: ['foo=bar', 'foo="bar"', 'foo="bar"baz"', 'foo="bar\\\\baz"', 'foo="\\\\bar\\\\baz"\\\\"'],
});
// Act
const formRoute = amRouteToFormAmRoute(amRoute);
// Assert
expect(formRoute.object_matchers).toStrictEqual([
{ name: 'foo', operator: MatcherOperator.equal, value: 'bar' },
{ name: 'foo', operator: MatcherOperator.equal, value: 'bar' },
{ name: 'foo', operator: MatcherOperator.equal, value: 'bar"baz' },
{ name: 'foo', operator: MatcherOperator.equal, value: 'bar\\baz' },
{ name: 'foo', operator: MatcherOperator.equal, value: '\\bar\\baz"\\' },
]);
});
});
@@ -9,6 +9,7 @@ import { MatcherFieldValue } from '../types/silence-form';
import { matcherToMatcherField } from './alertmanager';
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
import { normalizeMatchers, parseMatcher } from './matchers';
import { quoteWithEscape, unquoteWithUnescape } from './misc';
import { findExistingRoute } from './routeTree';
import { isValidPrometheusDuration, safeParseDurationstr } from './time';
@@ -94,7 +95,14 @@ export const amRouteToFormAmRoute = (route: RouteWithID | Route | undefined): Fo
const objectMatchers =
route.object_matchers?.map((matcher) => ({ name: matcher[0], operator: matcher[1], value: matcher[2] })) ?? [];
const matchers = route.matchers?.map((matcher) => matcherToMatcherField(parseMatcher(matcher))) ?? [];
const matchers =
route.matchers
?.map((matcher) => matcherToMatcherField(parseMatcher(matcher)))
.map(({ name, operator, value }) => ({
name,
operator,
value: unquoteWithUnescape(value),
})) ?? [];
return {
id,
@@ -149,6 +157,7 @@ export const formAmRouteToAmRoute = (
const overrideRepeatInterval = overrideTimings && repeatIntervalValue;
const repeat_interval = overrideRepeatInterval ? repeatIntervalValue : INHERIT_FROM_PARENT;
const object_matchers: ObjectMatcher[] | undefined = formAmRoute.object_matchers
?.filter((route) => route.name && route.value && route.operator)
.map(({ name, operator, value }) => [name, operator, value]);
@@ -176,7 +185,9 @@ export const formAmRouteToAmRoute = (
// Grafana maintains a fork of AM to support all utf-8 characters in the "object_matchers" property values but this
// does not exist in upstream AlertManager
if (alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME) {
amRoute.matchers = formAmRoute.object_matchers?.map(({ name, operator, value }) => `${name}${operator}${value}`);
amRoute.matchers = formAmRoute.object_matchers?.map(
({ name, operator, value }) => `${name}${operator}${quoteWithEscape(value)}`
);
amRoute.object_matchers = undefined;
} else {
amRoute.object_matchers = normalizeMatchers(amRoute);
@@ -117,6 +117,18 @@ export function wrapWithQuotes(input: string) {
return alreadyWrapped ? escapeQuotes(input) : `"${escapeQuotes(input)}"`;
}
export function quoteWithEscape(input: string) {
const escaped = input.replace(/[\\"]/g, (c) => `\\${c}`);
return `"${escaped}"`;
}
export function unquoteWithUnescape(input: string) {
return input
.replace(/^"(.*)"$/, '$1')
.replace(/\\\\/g, '\\')
.replace(/\\"/g, '"');
}
export function makeRuleBasedSilenceLink(alertManagerSourceName: string, rule: CombinedRule) {
// we wrap the name of the alert with quotes since it might contain starting and trailing spaces
const labels: Labels = {