Skip to content

Commit dcad8fd

Browse files
authored
fix: add checks for preventing HSL colors from entering React state (#9893)
* fix: remove hsl color from initial form state * chore: add extra color helpers * chore: add extra runtime validation for colors * chore: clean up comments for clarity * chore: add tests for colors
1 parent 20a681a commit dcad8fd

File tree

5 files changed

+189
-11
lines changed

5 files changed

+189
-11
lines changed

site/src/components/Dashboard/DashboardProvider.tsx

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ import {
1010
} from "api/typesGenerated";
1111
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
1212
import {
13+
type FC,
14+
type PropsWithChildren,
1315
createContext,
14-
FC,
15-
PropsWithChildren,
16+
useCallback,
1617
useContext,
1718
useState,
1819
} from "react";
1920
import { appearance } from "api/queries/appearance";
21+
import { hslToHex, isHexColor, isHslColor } from "utils/colors";
22+
import { displayError } from "components/GlobalSnackbar/utils";
2023

2124
interface Appearance {
2225
config: AppearanceConfig;
@@ -45,8 +48,35 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
4548
!entitlementsQuery.data ||
4649
!appearanceQuery.data ||
4750
!experimentsQuery.data;
51+
4852
const [configPreview, setConfigPreview] = useState<AppearanceConfig>();
4953

54+
// Centralizing the logic for catching malformed configs in one spot, just to
55+
// be on the safe side; don't want to expose raw setConfigPreview outside
56+
// the provider
57+
const setPreview = useCallback((newConfig: AppearanceConfig) => {
58+
// Have runtime safety nets in place, just because so much of the codebase
59+
// relies on HSL for formatting, but server expects hex values. Can't catch
60+
// color format mismatches at the type level
61+
const incomingBg = newConfig.service_banner.background_color;
62+
let configForDispatch = newConfig;
63+
64+
if (typeof incomingBg === "string" && isHslColor(incomingBg)) {
65+
configForDispatch = {
66+
...newConfig,
67+
service_banner: {
68+
...newConfig.service_banner,
69+
background_color: hslToHex(incomingBg),
70+
},
71+
};
72+
} else if (typeof incomingBg === "string" && !isHexColor(incomingBg)) {
73+
displayError(`The value ${incomingBg} is not a valid hex string`);
74+
return;
75+
}
76+
77+
setConfigPreview(configForDispatch);
78+
}, []);
79+
5080
if (isLoading) {
5181
return <FullScreenLoader />;
5282
}
@@ -59,7 +89,7 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
5989
experiments: experimentsQuery.data,
6090
appearance: {
6191
config: configPreview ?? appearanceQuery.data,
62-
setPreview: setConfigPreview,
92+
setPreview: setPreview,
6393
isPreview: configPreview !== undefined,
6494
},
6595
}}

site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPage.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,17 @@ const AppearanceSettingsPage: FC = () => {
1717
const { appearance, entitlements } = useDashboard();
1818
const queryClient = useQueryClient();
1919
const updateAppearanceMutation = useMutation(updateAppearance(queryClient));
20-
const isEntitled =
21-
entitlements.features["appearance"].entitlement !== "not_entitled";
2220

2321
const onSaveAppearance = async (
2422
newConfig: Partial<UpdateAppearanceConfig>,
2523
preview: boolean,
2624
) => {
27-
const newAppearance = {
28-
...appearance.config,
29-
...newConfig,
30-
};
25+
const newAppearance = { ...appearance.config, ...newConfig };
3126
if (preview) {
3227
appearance.setPreview(newAppearance);
3328
return;
3429
}
30+
3531
try {
3632
await updateAppearanceMutation.mutateAsync(newAppearance);
3733
displaySuccess("Successfully updated appearance settings!");
@@ -50,8 +46,10 @@ const AppearanceSettingsPage: FC = () => {
5046

5147
<AppearanceSettingsPageView
5248
appearance={appearance.config}
53-
isEntitled={isEntitled}
5449
onSaveAppearance={onSaveAppearance}
50+
isEntitled={
51+
entitlements.features.appearance.entitlement !== "not_entitled"
52+
}
5553
/>
5654
</>
5755
);

site/src/pages/DeploySettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { useFormik } from "formik";
2121
import { useTheme } from "@mui/styles";
2222
import Link from "@mui/material/Link";
2323
import { colors } from "theme/colors";
24+
import { hslToHex } from "utils/colors";
2425

2526
export type AppearanceSettingsPageViewProps = {
2627
appearance: UpdateAppearanceConfig;
@@ -30,13 +31,17 @@ export type AppearanceSettingsPageViewProps = {
3031
preview: boolean,
3132
) => void;
3233
};
34+
35+
const fallbackBgColor = hslToHex(colors.blue[7]);
36+
3337
export const AppearanceSettingsPageView = ({
3438
appearance,
3539
isEntitled,
3640
onSaveAppearance,
3741
}: AppearanceSettingsPageViewProps): JSX.Element => {
3842
const styles = useStyles();
3943
const theme = useTheme();
44+
4045
const logoForm = useFormik<{
4146
logo_url: string;
4247
}>({
@@ -53,7 +58,7 @@ export const AppearanceSettingsPageView = ({
5358
message: appearance.service_banner.message,
5459
enabled: appearance.service_banner.enabled,
5560
background_color:
56-
appearance.service_banner.background_color ?? colors.blue[7],
61+
appearance.service_banner.background_color ?? fallbackBgColor,
5762
},
5863
onSubmit: (values) =>
5964
onSaveAppearance(
@@ -65,9 +70,11 @@ export const AppearanceSettingsPageView = ({
6570
},
6671
);
6772
const serviceBannerFieldHelpers = getFormHelpers(serviceBannerForm);
73+
6874
const [backgroundColor, setBackgroundColor] = useState(
6975
serviceBannerForm.values.background_color,
7076
);
77+
7178
return (
7279
<>
7380
<Header

site/src/utils/colors.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Not testing hslToHex, because it's code directly copied from a reliable
3+
* source
4+
*/
5+
import { isHslColor, isHexColor } from "./colors";
6+
7+
describe(`${isHslColor.name}`, () => {
8+
it("Should reject obviously wrong or malformed inputs", () => {
9+
const wrongInputs = [
10+
"",
11+
"Hi :)",
12+
"hsl",
13+
"#333",
14+
"#444666",
15+
"rgb(255, 300, 299)",
16+
"hsl(255deg, 10%, 20%",
17+
"hsv(255deg, 10%, 20%)",
18+
"hsb(255deg, 10%, 20%)",
19+
"hsl(0%, 10deg, 20)",
20+
];
21+
22+
for (const str of wrongInputs) {
23+
expect(isHslColor(str)).toBe(false);
24+
}
25+
});
26+
27+
it("Should allow strings with or without deg unit", () => {
28+
const base = "hsl(200deg, 100%, 37%)";
29+
expect(isHslColor(base)).toBe(true);
30+
31+
const withoutDeg = base.replace("deg", "");
32+
expect(isHslColor(withoutDeg)).toBe(true);
33+
});
34+
35+
it("Should not care about whether there are spaces separating the inner values", () => {
36+
const inputs = [
37+
"hsl(180deg,20%,97%)",
38+
"hsl(180deg, 20%, 97%)",
39+
"hsl(180deg, 20%, 97%)",
40+
];
41+
42+
for (const str of inputs) {
43+
expect(isHslColor(str)).toBe(true);
44+
}
45+
});
46+
47+
it("Should reject HSL strings that don't have percents for saturation/luminosity values", () => {
48+
expect(isHslColor("hsl(20%, 45, 92)")).toBe(false);
49+
});
50+
51+
it("Should catch HSL strings that follow the correct pattern, but have impossible values", () => {
52+
const inputs = [
53+
"hsl(360deg, 120%, 240%)", // Impossible hue
54+
"hsl(100deg, 120%, 84%)", // Impossible saturation
55+
"hsl(257, 12%, 110%)", // Impossible luminosity
56+
"hsl(360deg, 120%, 240%)", // Impossible everything
57+
];
58+
59+
for (const str of inputs) {
60+
expect(isHslColor(str)).toBe(false);
61+
}
62+
});
63+
});
64+
65+
describe(`${isHexColor.name}`, () => {
66+
it("Should reject obviously wrong or malformed inputs", () => {
67+
const inputs = ["", "#", "#bananas", "#ZZZZZZ", "AB179C", "#55555"];
68+
69+
for (const str of inputs) {
70+
expect(isHexColor(str)).toBe(false);
71+
}
72+
});
73+
74+
it("Should be fully case-insensitive", () => {
75+
const inputs = ["#aaaaaa", "#BBBBBB", "#CcCcCc"];
76+
77+
for (const str of inputs) {
78+
expect(isHexColor(str)).toBe(true);
79+
}
80+
});
81+
});

site/src/utils/colors.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,65 @@
1+
/**
2+
* Does not support shorthand hex strings (e.g., #fff), just to maximize
3+
* compatibility with server, which also doesn't support shorthand
4+
*/
5+
const hexMatcher = /^#[0-9A-Fa-f]{6}$/;
6+
7+
/**
8+
* Determines whether a string is a hex color string. Returns false for
9+
* shorthand hex strings.
10+
*
11+
* Mainly here to validate input before sending it to the server.
12+
*/
13+
export function isHexColor(input: string): boolean {
14+
// Length check isn't necessary; it's just an fast way to validate before
15+
// kicking things off to the slower regex check
16+
return input.length === 7 && hexMatcher.test(input);
17+
}
18+
19+
/**
20+
* Regex written and tested via Regex101. This doesn't catch every invalid HSL
21+
* string and still requires some other checks, but it can do a lot by itself.
22+
*
23+
* Setup:
24+
* - Supports capture groups for all three numeric values. Regex tries to fail
25+
* the input as quickly as possible.
26+
* - Regex is all-or-nothing – there is some tolerance for extra spaces, but
27+
* this regex will fail any string that is missing any part of the format.
28+
* - String is case-insensitive
29+
* - String must start with HSL and have both parentheses
30+
* - All three numeric values must be comma-delimited
31+
* - Hue can be 1-3 digits. Rightmost digit (if it exists) can only be 1-3;
32+
* other digits have no constraints. The degree unit ("deg") is optional
33+
* - Both saturation and luminosity can be 1-3 digits. Rightmost digit (if it
34+
* exists) can only ever be 1. Other digits have no constraints.
35+
*/
36+
const hslMatcher =
37+
/^hsl\(((?:[1-3]?\d)?\d)(?:deg)?, *((?:1?\d)?\d)%, *((?:1?\d)?\d)%\)$/i;
38+
39+
export function isHslColor(input: string): boolean {
40+
const [, hue, sat, lum] = hslMatcher.exec(input) ?? [];
41+
if (hue === undefined || sat === undefined || lum === undefined) {
42+
return false;
43+
}
44+
45+
const hueN = Number(hue);
46+
if (!Number.isInteger(hueN) || hueN < 0 || hueN > 359) {
47+
return false;
48+
}
49+
50+
const satN = Number(sat);
51+
if (!Number.isInteger(satN) || satN < 0 || satN > 100) {
52+
return false;
53+
}
54+
55+
const lumN = Number(lum);
56+
if (!Number.isInteger(lumN) || lumN < 0 || lumN > 100) {
57+
return false;
58+
}
59+
60+
return true;
61+
}
62+
163
// Used to convert our theme colors to Hex since monaco theme only support hex colors
264
// From https://www.jameslmilner.com/posts/converting-rgb-hex-hsl-colors/
365
export function hslToHex(hsl: string): string {

0 commit comments

Comments
 (0)