Skip to content

fix: add checks for preventing HSL colors from entering React state #9893

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Sep 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions site/src/components/Dashboard/DashboardProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ import {
} from "api/typesGenerated";
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
import {
type FC,
type PropsWithChildren,
createContext,
FC,
PropsWithChildren,
useCallback,
useContext,
useState,
} from "react";
import { appearance } from "api/queries/appearance";
import { hslToHex, isHexColor, isHslColor } from "utils/colors";
import { displayError } from "components/GlobalSnackbar/utils";

interface Appearance {
config: AppearanceConfig;
Expand Down Expand Up @@ -45,8 +48,35 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
!entitlementsQuery.data ||
!appearanceQuery.data ||
!experimentsQuery.data;

const [configPreview, setConfigPreview] = useState<AppearanceConfig>();

// Centralizing the logic for catching malformed configs in one spot, just to
// be on the safe side; don't want to expose raw setConfigPreview outside
// the provider
const setPreview = useCallback((newConfig: AppearanceConfig) => {
// Have runtime safety nets in place, just because so much of the codebase
// relies on HSL for formatting, but server expects hex values. Can't catch
// color format mismatches at the type level
const incomingBg = newConfig.service_banner.background_color;
let configForDispatch = newConfig;

if (typeof incomingBg === "string" && isHslColor(incomingBg)) {
configForDispatch = {
...newConfig,
service_banner: {
...newConfig.service_banner,
background_color: hslToHex(incomingBg),
},
};
} else if (typeof incomingBg === "string" && !isHexColor(incomingBg)) {
displayError(`The value ${incomingBg} is not a valid hex string`);
return;
}

setConfigPreview(configForDispatch);
}, []);

if (isLoading) {
return <FullScreenLoader />;
}
Expand All @@ -59,7 +89,7 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
experiments: experimentsQuery.data,
appearance: {
config: configPreview ?? appearanceQuery.data,
setPreview: setConfigPreview,
setPreview: setPreview,
isPreview: configPreview !== undefined,
},
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,17 @@ const AppearanceSettingsPage: FC = () => {
const { appearance, entitlements } = useDashboard();
const queryClient = useQueryClient();
const updateAppearanceMutation = useMutation(updateAppearance(queryClient));
const isEntitled =
entitlements.features["appearance"].entitlement !== "not_entitled";

const onSaveAppearance = async (
newConfig: Partial<UpdateAppearanceConfig>,
preview: boolean,
) => {
const newAppearance = {
...appearance.config,
...newConfig,
};
const newAppearance = { ...appearance.config, ...newConfig };
if (preview) {
appearance.setPreview(newAppearance);
return;
}

try {
await updateAppearanceMutation.mutateAsync(newAppearance);
displaySuccess("Successfully updated appearance settings!");
Expand All @@ -50,8 +46,10 @@ const AppearanceSettingsPage: FC = () => {

<AppearanceSettingsPageView
appearance={appearance.config}
isEntitled={isEntitled}
onSaveAppearance={onSaveAppearance}
isEntitled={
entitlements.features.appearance.entitlement !== "not_entitled"
}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { useFormik } from "formik";
import { useTheme } from "@mui/styles";
import Link from "@mui/material/Link";
import { colors } from "theme/colors";
import { hslToHex } from "utils/colors";

export type AppearanceSettingsPageViewProps = {
appearance: UpdateAppearanceConfig;
Expand All @@ -30,13 +31,17 @@ export type AppearanceSettingsPageViewProps = {
preview: boolean,
) => void;
};

const fallbackBgColor = hslToHex(colors.blue[7]);
Copy link
Member

@mtojek mtojek Sep 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KISS-related question: why do we need other changes in the PR? Did you identify any places where we can face HSL related issue?

EDIT:

I'm thinking about changes around the DashboardProvider.

Copy link
Member Author

@Parkreiner Parkreiner Sep 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, KISS is definitely something I need to keep in mind more, but at least here, I feel like some kind of defensive changes (maybe not necessarily these) made sense.

The way I see it, there's a few risks right now:

  • DashboardProvider is already a top-level component that can be imported by anything else from the /components folder. It's also a context provider, so any importing component's sub-children would be able to use it indirectly, as well
  • The component is already being globally available to some degree because it's directly integrated into the RequireAuth component
  • So many constants in the codebase are set up as HSL strings for readability. We could get rid of these in favor of hex strings, but they're a lot less intuitive to work with

I felt like having the provider be in charge of validating incoming state changes would be the best way to prevent any future mismatches from popping up

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the explanation! Sounds good to me 👍


export const AppearanceSettingsPageView = ({
appearance,
isEntitled,
onSaveAppearance,
}: AppearanceSettingsPageViewProps): JSX.Element => {
const styles = useStyles();
const theme = useTheme();

const logoForm = useFormik<{
logo_url: string;
}>({
Expand All @@ -53,7 +58,7 @@ export const AppearanceSettingsPageView = ({
message: appearance.service_banner.message,
enabled: appearance.service_banner.enabled,
background_color:
appearance.service_banner.background_color ?? colors.blue[7],
appearance.service_banner.background_color ?? fallbackBgColor,
},
onSubmit: (values) =>
onSaveAppearance(
Expand All @@ -65,9 +70,11 @@ export const AppearanceSettingsPageView = ({
},
);
const serviceBannerFieldHelpers = getFormHelpers(serviceBannerForm);

const [backgroundColor, setBackgroundColor] = useState(
serviceBannerForm.values.background_color,
);

return (
<>
<Header
Expand Down
81 changes: 81 additions & 0 deletions site/src/utils/colors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Not testing hslToHex, because it's code directly copied from a reliable
* source
*/
import { isHslColor, isHexColor } from "./colors";

describe(`${isHslColor.name}`, () => {
it("Should reject obviously wrong or malformed inputs", () => {
const wrongInputs = [
"",
"Hi :)",
"hsl",
"#333",
"#444666",
"rgb(255, 300, 299)",
"hsl(255deg, 10%, 20%",
"hsv(255deg, 10%, 20%)",
"hsb(255deg, 10%, 20%)",
"hsl(0%, 10deg, 20)",
];

for (const str of wrongInputs) {
expect(isHslColor(str)).toBe(false);
}
});

it("Should allow strings with or without deg unit", () => {
const base = "hsl(200deg, 100%, 37%)";
expect(isHslColor(base)).toBe(true);

const withoutDeg = base.replace("deg", "");
expect(isHslColor(withoutDeg)).toBe(true);
});

it("Should not care about whether there are spaces separating the inner values", () => {
const inputs = [
"hsl(180deg,20%,97%)",
"hsl(180deg, 20%, 97%)",
"hsl(180deg, 20%, 97%)",
];

for (const str of inputs) {
expect(isHslColor(str)).toBe(true);
}
});

it("Should reject HSL strings that don't have percents for saturation/luminosity values", () => {
expect(isHslColor("hsl(20%, 45, 92)")).toBe(false);
});

it("Should catch HSL strings that follow the correct pattern, but have impossible values", () => {
const inputs = [
"hsl(360deg, 120%, 240%)", // Impossible hue
"hsl(100deg, 120%, 84%)", // Impossible saturation
"hsl(257, 12%, 110%)", // Impossible luminosity
"hsl(360deg, 120%, 240%)", // Impossible everything
];

for (const str of inputs) {
expect(isHslColor(str)).toBe(false);
}
});
});

describe(`${isHexColor.name}`, () => {
it("Should reject obviously wrong or malformed inputs", () => {
const inputs = ["", "#", "#bananas", "#ZZZZZZ", "AB179C", "#55555"];

for (const str of inputs) {
expect(isHexColor(str)).toBe(false);
}
});

it("Should be fully case-insensitive", () => {
const inputs = ["#aaaaaa", "#BBBBBB", "#CcCcCc"];

for (const str of inputs) {
expect(isHexColor(str)).toBe(true);
}
});
});
62 changes: 62 additions & 0 deletions site/src/utils/colors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,65 @@
/**
* Does not support shorthand hex strings (e.g., #fff), just to maximize
* compatibility with server, which also doesn't support shorthand
*/
const hexMatcher = /^#[0-9A-Fa-f]{6}$/;

/**
* Determines whether a string is a hex color string. Returns false for
* shorthand hex strings.
*
* Mainly here to validate input before sending it to the server.
*/
export function isHexColor(input: string): boolean {
// Length check isn't necessary; it's just an fast way to validate before
// kicking things off to the slower regex check
return input.length === 7 && hexMatcher.test(input);
}

/**
* Regex written and tested via Regex101. This doesn't catch every invalid HSL
* string and still requires some other checks, but it can do a lot by itself.
*
* Setup:
* - Supports capture groups for all three numeric values. Regex tries to fail
* the input as quickly as possible.
* - Regex is all-or-nothing – there is some tolerance for extra spaces, but
* this regex will fail any string that is missing any part of the format.
* - String is case-insensitive
* - String must start with HSL and have both parentheses
* - All three numeric values must be comma-delimited
* - Hue can be 1-3 digits. Rightmost digit (if it exists) can only be 1-3;
* other digits have no constraints. The degree unit ("deg") is optional
* - Both saturation and luminosity can be 1-3 digits. Rightmost digit (if it
* exists) can only ever be 1. Other digits have no constraints.
*/
const hslMatcher =
/^hsl\(((?:[1-3]?\d)?\d)(?:deg)?, *((?:1?\d)?\d)%, *((?:1?\d)?\d)%\)$/i;

export function isHslColor(input: string): boolean {
const [, hue, sat, lum] = hslMatcher.exec(input) ?? [];
if (hue === undefined || sat === undefined || lum === undefined) {
return false;
}

const hueN = Number(hue);
if (!Number.isInteger(hueN) || hueN < 0 || hueN > 359) {
return false;
}

const satN = Number(sat);
if (!Number.isInteger(satN) || satN < 0 || satN > 100) {
return false;
}

const lumN = Number(lum);
if (!Number.isInteger(lumN) || lumN < 0 || lumN > 100) {
return false;
}

return true;
}

// Used to convert our theme colors to Hex since monaco theme only support hex colors
// From https://www.jameslmilner.com/posts/converting-rgb-hex-hsl-colors/
export function hslToHex(hsl: string): string {
Expand Down