fix: stronger enforcement of normalizeLink (#6728)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Christopher Chedeau 2023-06-29 03:14:42 -07:00 committed by GitHub
parent b7350f9707
commit b33fa6d6f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 72 additions and 24 deletions

View File

@ -19,6 +19,7 @@
] ]
}, },
"dependencies": { "dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/random-username": "1.0.0", "@excalidraw/random-username": "1.0.0",
"@radix-ui/react-popover": "1.0.3", "@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-tabs": "1.0.2", "@radix-ui/react-tabs": "1.0.2",

View File

@ -291,13 +291,12 @@ import {
} from "../element/textElement"; } from "../element/textElement";
import { isHittingElementNotConsideringBoundingBox } from "../element/collision"; import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
import { import {
normalizeLink,
showHyperlinkTooltip, showHyperlinkTooltip,
hideHyperlinkToolip, hideHyperlinkToolip,
Hyperlink, Hyperlink,
isPointHittingLinkIcon, isPointHittingLinkIcon,
isLocalLink,
} from "../element/Hyperlink"; } from "../element/Hyperlink";
import { isLocalLink, normalizeLink } from "../data/url";
import { shouldShowBoundingBox } from "../element/transformHandles"; import { shouldShowBoundingBox } from "../element/transformHandles";
import { actionUnlockAllElements } from "../actions/actionElementLock"; import { actionUnlockAllElements } from "../actions/actionElementLock";
import { Fonts } from "../scene/Fonts"; import { Fonts } from "../scene/Fonts";
@ -3352,12 +3351,19 @@ class App extends React.Component<AppProps, AppState> {
this.device.isMobile, this.device.isMobile,
); );
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) { if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
const url = this.hitLinkElement.link; let url = this.hitLinkElement.link;
if (url) { if (url) {
url = normalizeLink(url);
let customEvent; let customEvent;
if (this.props.onLinkOpen) { if (this.props.onLinkOpen) {
customEvent = wrapEvent(EVENT.EXCALIDRAW_LINK, event.nativeEvent); customEvent = wrapEvent(EVENT.EXCALIDRAW_LINK, event.nativeEvent);
this.props.onLinkOpen(this.hitLinkElement, customEvent); this.props.onLinkOpen(
{
...this.hitLinkElement,
link: url,
},
customEvent,
);
} }
if (!customEvent?.defaultPrevented) { if (!customEvent?.defaultPrevented) {
const target = isLocalLink(url) ? "_self" : "_blank"; const target = isLocalLink(url) ? "_self" : "_blank";
@ -3365,7 +3371,7 @@ class App extends React.Component<AppProps, AppState> {
// https://mathiasbynens.github.io/rel-noopener/ // https://mathiasbynens.github.io/rel-noopener/
if (newWindow) { if (newWindow) {
newWindow.opener = null; newWindow.opener = null;
newWindow.location = normalizeLink(url); newWindow.location = url;
} }
} }
} }

View File

@ -41,6 +41,7 @@ import {
measureBaseline, measureBaseline,
} from "../element/textElement"; } from "../element/textElement";
import { COLOR_PALETTE } from "../colors"; import { COLOR_PALETTE } from "../colors";
import { normalizeLink } from "./url";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@ -142,7 +143,7 @@ const restoreElementWithProperties = <
? element.boundElementIds.map((id) => ({ type: "arrow", id })) ? element.boundElementIds.map((id) => ({ type: "arrow", id }))
: element.boundElements ?? [], : element.boundElements ?? [],
updated: element.updated ?? getUpdatedTimestamp(), updated: element.updated ?? getUpdatedTimestamp(),
link: element.link ?? null, link: element.link ? normalizeLink(element.link) : null,
locked: element.locked ?? false, locked: element.locked ?? false,
}; };

30
src/data/url.test.tsx Normal file
View File

@ -0,0 +1,30 @@
import { normalizeLink } from "./url";
describe("normalizeLink", () => {
// NOTE not an extensive XSS test suite, just to check if we're not
// regressing in sanitization
it("should sanitize links", () => {
expect(
// eslint-disable-next-line no-script-url
normalizeLink(`javascript://%0aalert(document.domain)`).startsWith(
// eslint-disable-next-line no-script-url
`javascript:`,
),
).toBe(false);
expect(normalizeLink("ola")).toBe("ola");
expect(normalizeLink(" ola")).toBe("ola");
expect(normalizeLink("https://www.excalidraw.com")).toBe(
"https://www.excalidraw.com",
);
expect(normalizeLink("www.excalidraw.com")).toBe("www.excalidraw.com");
expect(normalizeLink("/ola")).toBe("/ola");
expect(normalizeLink("http://test")).toBe("http://test");
expect(normalizeLink("ftp://test")).toBe("ftp://test");
expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
expect(normalizeLink("[[test]]")).toBe("[[test]]");
expect(normalizeLink("<test>")).toBe("<test>");
});
});

9
src/data/url.ts Normal file
View File

@ -0,0 +1,9 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
export const normalizeLink = (link: string) => {
return sanitizeUrl(link);
};
export const isLocalLink = (link: string | null) => {
return !!(link?.includes(location.origin) || link?.startsWith("/"));
};

View File

@ -29,6 +29,7 @@ import { getTooltipDiv, updateTooltipPosition } from "../components/Tooltip";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { isPointHittingElementBoundingBox } from "./collision"; import { isPointHittingElementBoundingBox } from "./collision";
import { getElementAbsoluteCoords } from "./"; import { getElementAbsoluteCoords } from "./";
import { isLocalLink, normalizeLink } from "../data/url";
import "./Hyperlink.scss"; import "./Hyperlink.scss";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
@ -166,7 +167,7 @@ export const Hyperlink = ({
/> />
) : ( ) : (
<a <a
href={element.link || ""} href={normalizeLink(element.link || "")}
className={clsx("excalidraw-hyperlinkContainer-link", { className={clsx("excalidraw-hyperlinkContainer-link", {
"d-none": isEditing, "d-none": isEditing,
})} })}
@ -177,7 +178,13 @@ export const Hyperlink = ({
EVENT.EXCALIDRAW_LINK, EVENT.EXCALIDRAW_LINK,
event.nativeEvent, event.nativeEvent,
); );
onLinkOpen(element, customEvent); onLinkOpen(
{
...element,
link: normalizeLink(element.link),
},
customEvent,
);
if (customEvent.defaultPrevented) { if (customEvent.defaultPrevented) {
event.preventDefault(); event.preventDefault();
} }
@ -231,21 +238,6 @@ const getCoordsForPopover = (
return { x, y }; return { x, y };
}; };
export const normalizeLink = (link: string) => {
link = link.trim();
if (link) {
// prefix with protocol if not fully-qualified
if (!link.includes("://") && !/^[[\\/]/.test(link)) {
link = `https://${link}`;
}
}
return link;
};
export const isLocalLink = (link: string | null) => {
return !!(link?.includes(location.origin) || link?.startsWith("/"));
};
export const actionLink = register({ export const actionLink = register({
name: "hyperlink", name: "hyperlink",
perform: (elements, appState) => { perform: (elements, appState) => {

View File

@ -15,6 +15,7 @@ Please add the latest change on the top under the correct section.
### Features ### Features
- Properly sanitize element `link` urls. [#6728](https://github.com/excalidraw/excalidraw/pull/6728).
- Sidebar component now supports tabs — for more detailed description of new behavior and breaking changes, see the linked PR. [#6213](https://github.com/excalidraw/excalidraw/pull/6213) - Sidebar component now supports tabs — for more detailed description of new behavior and breaking changes, see the linked PR. [#6213](https://github.com/excalidraw/excalidraw/pull/6213)
- Exposed `DefaultSidebar` component to allow modifying the default sidebar, such as adding custom tabs to it. [#6213](https://github.com/excalidraw/excalidraw/pull/6213) - Exposed `DefaultSidebar` component to allow modifying the default sidebar, such as adding custom tabs to it. [#6213](https://github.com/excalidraw/excalidraw/pull/6213)

View File

@ -247,3 +247,5 @@ export { WelcomeScreen };
export { LiveCollaborationTrigger }; export { LiveCollaborationTrigger };
export { DefaultSidebar } from "../../components/DefaultSidebar"; export { DefaultSidebar } from "../../components/DefaultSidebar";
export { normalizeLink } from "../../data/url";

View File

@ -50,6 +50,7 @@ import {
} from "../element/textElement"; } from "../element/textElement";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { getContainingFrame } from "../frame"; import { getContainingFrame } from "../frame";
import { normalizeLink } from "../data/url";
// using a stronger invert (100% vs our regular 93%) and saturate // using a stronger invert (100% vs our regular 93%) and saturate
// as a temp hack to make images in dark theme look closer to original // as a temp hack to make images in dark theme look closer to original
@ -1203,7 +1204,7 @@ export const renderElementToSvg = (
// if the element has a link, create an anchor tag and make that the new root // if the element has a link, create an anchor tag and make that the new root
if (element.link) { if (element.link) {
const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
anchorTag.setAttribute("href", element.link); anchorTag.setAttribute("href", normalizeLink(element.link));
root.appendChild(anchorTag); root.appendChild(anchorTag);
root = anchorTag; root = anchorTag;
} }

View File

@ -1086,6 +1086,11 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@braintree/sanitize-url@6.0.2":
version "6.0.2"
resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz#6110f918d273fe2af8ea1c4398a88774bb9fc12f"
integrity sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==
"@csstools/normalize.css@*": "@csstools/normalize.css@*":
version "12.0.0" version "12.0.0"
resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-12.0.0.tgz#a9583a75c3f150667771f30b60d9f059473e62c4" resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-12.0.0.tgz#a9583a75c3f150667771f30b60d9f059473e62c4"