Skip to content
Snippets Groups Projects
Unverified Commit b8178a84 authored by github-automation-metabase's avatar github-automation-metabase Committed by GitHub
Browse files

:robot: backported "Add allowed iframe host setting" (#49050)

* Add allowed iframe host setting (#48805)

* add allowed iframe host setting wip

* use allowed-iframe-hosts setting in the CSP header

* add a test for the frame-src csp directive

* Update allowed-iframe-hosts setting definition

* Add error state for forbidden iframe url domains

* Move out iframe e2e test suite

* Add e2e test

* Update error message in view mode

* Fix unit tests

* Update setting on the admin page

* Update error state

* Add links to error states

* Update docs links

* Update link anchors

* Add default allowed hosts to public setting

* Update allowed domain check logic

* Fix default value display in admin page

* Don't update setting without changes

* Update error message spacing

* correct the parsing of allowed-hosts string for CSP header entries

* fix test

* fix not handling wildcard ports

* Fix failing e2e test

* Fix subdomain test

* address review

 - the parse-allowed-iframe-hosts fn is now memoized
 - a * entry is handled and doesn't produce a weird *:* entry
 - no more try/catch, errors in parsing will be logged but the list returns all valid entries
 - when www. is encountered, an entry including www. is added
 - trailing / is 'cleaned' and the entry is used as if there was no trailing /

* Fixup test for expecting a few more frame sources

* indentation fix for linter :smiling_face_with_tear:



* Fix type error

---------

Co-authored-by: default avatarAdam James <adam.vermeer2@gmail.com>
Co-authored-by: default avatarAnton Kulyk <kuliks.anton@gmail.com>
Co-authored-by: default avatardan sutton <dan@dpsutton.com>

* Fix type error

---------

Co-authored-by: default avatarAleksandr Lesnenko <alxnddr@users.noreply.github.com>
Co-authored-by: default avatarAdam James <adam.vermeer2@gmail.com>
Co-authored-by: default avatarAnton Kulyk <kuliks.anton@gmail.com>
Co-authored-by: default avatardan sutton <dan@dpsutton.com>
parent 95a26d85
No related branches found
No related tags found
No related merge requests found
Showing
with 525 additions and 81 deletions
......@@ -160,6 +160,20 @@ export function addIFrameWhileEditing(
cy.findByTestId("iframe-card-input").type(embed, options);
}
export function editIFrameWhileEditing(
dashcardIndex = 0,
embed: string,
options: Partial<Cypress.TypeOptions> = {},
) {
getDashboardCard(dashcardIndex)
.realHover()
.findByTestId("dashboardcard-actions-panel")
.should("be.visible")
.icon("pencil")
.click();
cy.findByTestId("iframe-card-input").type(`{selectall}${embed}`, options);
}
export function addTextBoxWhileEditing(
text: string,
options: Partial<Cypress.TypeOptions> = {},
......
......@@ -26,6 +26,7 @@ import {
describeEE,
describeWithSnowplow,
editDashboard,
editIFrameWhileEditing,
enableTracking,
entityPickerModal,
expectGoodSnowplowEvent,
......@@ -54,6 +55,7 @@ import {
sidebar,
sidesheet,
updateDashboardCards,
updateSetting,
visitDashboard,
} from "e2e/support/helpers";
import { GRID_WIDTH } from "metabase/lib/dashboard_grid";
......@@ -326,50 +328,6 @@ describe("scenarios > dashboard", () => {
}
});
describe("iframe cards", () => {
it("should handle various iframe and URL inputs", () => {
const testCases = [
{
input: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
expected: "https://www.youtube.com/embed/dQw4w9WgXcQ",
},
{
input: "https://youtu.be/dQw4w9WgXcQ",
expected: "https://www.youtube.com/embed/dQw4w9WgXcQ",
},
{
input: "https://www.loom.com/share/1234567890abcdef",
expected: "https://www.loom.com/embed/1234567890abcdef",
},
{
input: "https://vimeo.com/123456789",
expected: "https://player.vimeo.com/video/123456789",
},
{
input: "example.com",
expected: "https://example.com",
},
{
input: "https://example.com",
expected: "https://example.com",
},
{
input:
'<iframe src="https://example.com" onload="alert(\'XSS\')"></iframe>',
expected: "https://example.com",
},
];
editDashboard();
testCases.forEach(({ input, expected }, index) => {
addIFrameWhileEditing(input);
cy.button("Done").click();
validateIFrame(expected, index);
});
});
});
it("should hide personal collections when adding questions to a dashboard in public collection", () => {
const collectionInRoot = {
name: "Collection in root collection",
......@@ -698,6 +656,111 @@ describe("scenarios > dashboard", () => {
);
});
describe("iframe cards", () => {
it("should handle various iframe and URL inputs", () => {
const testCases = [
{
input: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
expected: "https://www.youtube.com/embed/dQw4w9WgXcQ",
},
{
input: "https://youtu.be/dQw4w9WgXcQ",
expected: "https://www.youtube.com/embed/dQw4w9WgXcQ",
},
{
input: "https://www.loom.com/share/1234567890abcdef",
expected: "https://www.loom.com/embed/1234567890abcdef",
},
{
input: "https://vimeo.com/123456789",
expected: "https://player.vimeo.com/video/123456789",
},
{
input: "example.com",
expected: "https://example.com",
},
{
input: "https://example.com",
expected: "https://example.com",
},
{
input:
'<iframe src="https://example.com" onload="alert(\'XSS\')"></iframe>',
expected: "https://example.com",
},
];
updateSetting("allowed-iframe-hosts", "*");
cy.createDashboard().then(({ body: { id } }) => {
visitDashboard(id);
});
editDashboard();
testCases.forEach(({ input, expected }, index) => {
addIFrameWhileEditing(input);
cy.button("Done").click();
validateIFrame(expected, index);
});
});
it("should respect allowed-iframe-hosts setting", () => {
const errorMessage = /can not be embedded in iframe cards/;
updateSetting(
"allowed-iframe-hosts",
["youtube.com", "player.videos.com"].join("\n"),
);
cy.createDashboard().then(({ body: { id } }) => visitDashboard(id));
editDashboard();
// Test allowed domain with subdomains
addIFrameWhileEditing("https://youtube.com/watch?v=dQw4w9WgXcQ");
cy.button("Done").click();
validateIFrame("https://www.youtube.com/embed/dQw4w9WgXcQ");
editIFrameWhileEditing(0, "https://www.youtube.com/watch?v=dQw4w9WgXcQ");
cy.button("Done").click();
validateIFrame("https://www.youtube.com/embed/dQw4w9WgXcQ");
// Test allowed subdomain, but no other domains
editIFrameWhileEditing(0, "player.videos.com/video/123456789");
cy.button("Done").click();
validateIFrame("https://player.videos.com/video/123456789");
editIFrameWhileEditing(0, "videos.com/video/123456789");
cy.button("Done").click();
getDashboardCard().within(() => {
cy.findByText(errorMessage).should("be.visible");
cy.get("iframe").should("not.exist");
});
editIFrameWhileEditing(0, "www.videos.com/video");
cy.button("Done").click();
getDashboardCard().within(() => {
cy.findByText(errorMessage).should("be.visible");
cy.get("iframe").should("not.exist");
});
// Test forbidden domain and subdomains
editIFrameWhileEditing(0, "https://example.com");
cy.button("Done").click();
getDashboardCard().within(() => {
cy.findByText(errorMessage).should("be.visible");
cy.get("iframe").should("not.exist");
});
editIFrameWhileEditing(0, "www.example.com");
cy.button("Done").click();
getDashboardCard().within(() => {
cy.findByText(errorMessage).should("be.visible");
cy.get("iframe").should("not.exist");
});
});
});
it("should add a filter", () => {
visitDashboard(ORDERS_DASHBOARD_ID);
editDashboard();
......@@ -1141,6 +1204,7 @@ describeWithSnowplow("scenarios > dashboard", () => {
});
it("should be possible to add an iframe card", () => {
updateSetting("allowed-iframe-hosts", "*");
createDashboard({ name: "iframe card" }).then(({ body: { id } }) => {
visitDashboard(id);
......
......@@ -145,6 +145,7 @@ export const createMockSettings = (
opts?: Partial<Settings | EnterpriseSettings>,
): EnterpriseSettings => ({
"admin-email": "admin@metabase.test",
"allowed-iframe-hosts": "*",
"anon-tracking-enabled": false,
"application-colors": {},
"application-font": "Lato",
......
......@@ -287,6 +287,7 @@ interface SettingsManagerSettings {
type PrivilegedSettings = AdminSettings & SettingsManagerSettings;
interface PublicSettings {
"allowed-iframe-hosts": string;
"anon-tracking-enabled": boolean;
"application-font": string;
"application-font-files": FontFile[] | null;
......
......@@ -23,10 +23,20 @@ const SettingText = ({
[cx(CS.borderError, CS.bgErrorInput)]: errorMessage,
},
)}
defaultValue={setting.value || ""}
defaultValue={setting.value || setting.default || ""}
placeholder={setting.placeholder}
onChange={fireOnChange ? e => onChange(e.target.value) : null}
onBlur={!fireOnChange ? e => onChange(e.target.value) : null}
onBlur={
!fireOnChange
? e => {
const value = setting.value || setting.default || "";
const nextValue = e.target.value;
if (nextValue !== value) {
onChange(nextValue);
}
}
: null
}
autoFocus={autoFocus}
/>
);
......
import { createSelector } from "@reduxjs/toolkit";
import { t } from "ttag";
import { jt, t } from "ttag";
import _ from "underscore";
import { SMTPConnectionForm } from "metabase/admin/settings/components/Email/SMTPConnectionForm";
import { DashboardSelector } from "metabase/components/DashboardSelector";
import ExternalLink from "metabase/core/components/ExternalLink";
import MetabaseSettings from "metabase/lib/settings";
import { newVersionAvailable } from "metabase/lib/utils";
import {
......@@ -13,6 +14,7 @@ import {
PLUGIN_LLM_AUTODESCRIPTION,
} from "metabase/plugins";
import { refreshCurrentUser } from "metabase/redux/user";
import { getDocsUrlForVersion } from "metabase/selectors/settings";
import { getUserIsAdmin } from "metabase/selectors/user";
import {
......@@ -171,6 +173,12 @@ export const ADMIN_SETTINGS_SECTIONS = {
display_name: t`Enable X-ray features`,
type: "boolean",
},
{
key: "allowed-iframe-hosts",
display_name: t`Allowed domains for iframes in dashboards`,
description: jt`You should make sure to trust the sources you allow your users to embed in dashboards. ${(<ExternalLink key="docs" href={getDocsUrl("configuring-metabase/settings", "allowed-domains-for-iframes-in-dashboards")}>{t`Learn more`}</ExternalLink>)}`,
type: "text",
},
],
},
updates: {
......@@ -629,3 +637,8 @@ export const getActiveSection = createSelector(
}
},
);
export function getDocsUrl(page, anchor) {
const version = MetabaseSettings.get("version");
return getDocsUrlForVersion(version, page, anchor);
}
......@@ -60,7 +60,7 @@ export const getDocsUrl = createSelector(
export const getDocsSearchUrl = (query: Record<string, string>) =>
`https://www.metabase.com/search?${new URLSearchParams(query)}`;
const getDocsUrlForVersion = (
export const getDocsUrlForVersion = (
version: Version | undefined,
page = "",
anchor = "",
......
import { css } from "@emotion/react";
import styled from "@emotion/styled";
import { Button, Textarea } from "metabase/ui";
import { Button, Text, type TextProps, Textarea } from "metabase/ui";
export const IFrameWrapper = styled.div<{ fade?: boolean }>`
display: flex;
......@@ -36,3 +36,7 @@ export const StyledInput = styled(Textarea)`
export const SaveButton = styled(Button)`
${interactiveDashcardElementCss}
`;
export const InteractiveText = styled(Text)<TextProps>`
${interactiveDashcardElementCss}
`;
import { useCallback, useMemo } from "react";
import { t } from "ttag";
import { jt, t } from "ttag";
import _ from "underscore";
import { Box, Button, Group, Stack, Text } from "metabase/ui";
import { useDocsUrl, useSetting } from "metabase/common/hooks";
import ExternalLink from "metabase/core/components/ExternalLink";
import Link from "metabase/core/components/Link";
import CS from "metabase/css/core/index.css";
import { useSelector } from "metabase/lib/redux";
import { getUserIsAdmin } from "metabase/selectors/user";
import { Box, Button, Group, Icon, Stack, Text } from "metabase/ui";
import type {
Dashboard,
VirtualDashboardCard,
......@@ -12,10 +18,11 @@ import type {
import {
IFrameEditWrapper,
IFrameWrapper,
InteractiveText,
StyledInput,
} from "./IFrameViz.styled";
import { settings } from "./IFrameVizSettings";
import { getIframeUrl } from "./utils";
import { getIframeUrl, isAllowedIframeUrl } from "./utils";
export interface IFrameVizProps {
dashcard: VirtualDashboardCard;
......@@ -48,6 +55,7 @@ export function IFrameViz({
const { iframe: iframeOrUrl } = settings;
const isNew = !!dashcard?.justAdded;
const allowedHosts = useSetting("allowed-iframe-hosts");
const iframeUrl = useMemo(() => getIframeUrl(iframeOrUrl), [iframeOrUrl]);
const handleIFrameChange = useCallback(
......@@ -98,9 +106,21 @@ export function IFrameViz({
);
}
const hasAllowedIFrameUrl =
iframeUrl && isAllowedIframeUrl(iframeUrl, allowedHosts);
const hasForbiddenIFrameUrl =
iframeUrl && !isAllowedIframeUrl(iframeUrl, allowedHosts);
const renderError = () => {
if (hasForbiddenIFrameUrl && isEditing) {
return <ForbiddenDomainError url={iframeUrl} />;
}
return <GenericError />;
};
return (
<IFrameWrapper data-testid="iframe-card" fade={isEditingParameter}>
{iframeUrl ? (
{hasAllowedIFrameUrl ? (
<iframe
data-testid="iframe-visualization"
src={iframeUrl}
......@@ -110,15 +130,63 @@ export function IFrameViz({
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
/>
) : (
<Box p={12} w="100%">
<Text
color="text-medium"
align={"center"}
>{t`There was a problem loading your iframe`}</Text>
</Box>
renderError()
)}
</IFrameWrapper>
);
}
function ForbiddenDomainError({ url }: { url: string }) {
const isAdmin = useSelector(getUserIsAdmin);
const { url: docsUrl, showMetabaseLinks } = useDocsUrl(
"configuring-metabase/settings",
"allowed-domains-for-iframes-in-dashboards",
);
const domain = useMemo(() => {
try {
const { hostname } = new URL(url);
return hostname;
} catch {
return url;
}
}, [url]);
const renderMessage = () => {
if (isAdmin) {
return jt`If you’re sure you trust this domain, you can add it to your ${(<Link key="link" className={CS.link} to="/admin/settings/general#allowed-iframe-hosts" target="_blank">{t`allowed domains list`}</Link>)} in admin settings.`;
}
return showMetabaseLinks
? jt`If you’re sure you trust this domain, you can ask an admin to add it to the ${(<ExternalLink key="link" className={CS.link} href={docsUrl}>{t`allowed domains list`}</ExternalLink>)}.`
: t`If you’re sure you trust this domain, you can ask an admin to add it to the allowed domains list.`;
};
return (
<Box p={12} w="100%" style={{ textAlign: "center" }}>
<Icon name="lock" color="var(--mb-color-text-dark)" mb="s" />
<Text color="text-dark">
{jt`${(
<Text key="domain" fw="bold" display="inline">
{domain}
</Text>
)} can not be embedded in iframe cards.`}
</Text>
<InteractiveText color="text-dark" px="lg" mt="md">
{renderMessage()}
</InteractiveText>
</Box>
);
}
function GenericError() {
return (
<Box p={12} w="100%" style={{ textAlign: "center" }}>
<Icon name="lock" color="var(--mb-color-text-dark)" mb="s" />
<Text color="text-dark">
{t`There was a problem rendering this content.`}
</Text>
</Box>
);
}
Object.assign(IFrameViz, settings);
......@@ -154,7 +154,7 @@ describe("IFrameViz", () => {
screen.queryByTestId("iframe-visualization"),
).not.toBeInTheDocument();
expect(
screen.getByText("There was a problem loading your iframe"),
screen.getByText("There was a problem rendering this content."),
).toBeInTheDocument();
});
......@@ -189,7 +189,7 @@ describe("IFrameViz", () => {
screen.queryByTestId("iframe-visualization"),
).not.toBeInTheDocument();
expect(
screen.getByText("There was a problem loading your iframe"),
screen.getByText("There was a problem rendering this content."),
).toBeInTheDocument();
});
});
......@@ -142,3 +142,58 @@ export const getIframeUrl = (
return null;
};
const splitPortAndRest = (url: string): [string, string] | [string, null] => {
const portPattern = /:(\d+|\*)$/;
const match = url.match(portPattern);
return [match ? url.slice(0, match.index) : url, match ? match[1] : ""];
};
export const isAllowedIframeUrl = (url: string, allowedIframesSetting = "") => {
if (allowedIframesSetting === "*") {
return true;
}
try {
const rawAllowedDomains = allowedIframesSetting
.replaceAll(",", "")
.split("\n")
.map(host => host.trim());
const parsedUrl = new URL(normalizeUrl(url));
const hostname = parsedUrl.hostname;
const port = parsedUrl.port;
return rawAllowedDomains.some(rawAllowedDomain => {
try {
const [rawAllowedDomainWithoutPort, allowedPort] =
splitPortAndRest(rawAllowedDomain);
const allowedDomain = new URL(
normalizeUrl(rawAllowedDomainWithoutPort),
);
const arePortsMatching = allowedPort === "*" || port === allowedPort;
if (!arePortsMatching) {
return false;
}
if (allowedDomain.hostname.startsWith("*.")) {
const baseDomain = allowedDomain.hostname.slice(2);
return hostname.endsWith("." + baseDomain);
}
return hostname.endsWith(allowedDomain.hostname);
} catch (e) {
console.warn(
`Error while checking against allowed iframe domain ${rawAllowedDomain}`,
);
return false;
}
});
} catch {
return false;
}
};
import { getIframeDomainName, getIframeUrl } from "./utils";
import { getIframeDomainName, getIframeUrl, isAllowedIframeUrl } from "./utils";
describe("getIframeUrl", () => {
describe("share to embed link transformation", () => {
......@@ -153,3 +153,73 @@ describe("getIframeDomainName", () => {
expect(result).toBeNull();
});
});
describe("isAllowedIframeUrl", () => {
const allowedDomains = [
"youtube.com",
"*.vimeo.com",
"docs.google.com",
"localhost:*",
].join("\n");
const _isAllowedIframeUrl = (url: string) =>
isAllowedIframeUrl(url, allowedDomains);
it("should return true if allowed domains are set to '*'", () => {
expect(isAllowedIframeUrl("youtube.com", "*")).toBe(true);
expect(isAllowedIframeUrl("http://localhost:3000", "*")).toBe(true);
expect(isAllowedIframeUrl("https://example.com", "*")).toBe(true);
});
it("should return true for all ports on an URL if allowed", () => {
expect(
isAllowedIframeUrl("http://localhost:3000", "http://localhost:*"),
).toBe(true);
});
it("should return false if ports do not match", () => {
expect(
isAllowedIframeUrl("http://localhost:3000", "http://localhost:3001"),
).toBe(false);
});
it("should return true for allowed domains", () => {
expect(_isAllowedIframeUrl("https://youtube.com/watch?v=dQw4w9WgXcQ")).toBe(
true,
);
});
it("should return false for not listed domains", () => {
expect(_isAllowedIframeUrl("https://loom.com/embed/1234567890abcdef")).toBe(
false,
);
expect(
_isAllowedIframeUrl("https://www.loom.com/embed/1234567890abcdef"),
).toBe(false);
});
it("should return true for allowed domain's subdomains", () => {
expect(
_isAllowedIframeUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ"),
).toBe(true);
});
it("should accept both HTTP and HTTPS for allowed domains", () => {
expect(_isAllowedIframeUrl("http://localhost:3000")).toBe(true);
expect(_isAllowedIframeUrl("https://localhost:3000")).toBe(true);
expect(_isAllowedIframeUrl("youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true);
});
it("shouldn't accept top-level domain when only subdomains allowed", () => {
expect(
_isAllowedIframeUrl("https://player.vimeo.com/video/123456789"),
).toBe(true);
expect(_isAllowedIframeUrl("https://vimeo.com/123456789")).toBe(false);
});
it("shouldn't accept top-level domain and other subdomains when only one subdomain is allowed", () => {
expect(_isAllowedIframeUrl("https://docs.google.com/page")).toBe(true);
expect(_isAllowedIframeUrl("https://sheets.google.com/sheet")).toBe(false);
expect(_isAllowedIframeUrl("https://google.com?s=text")).toBe(false);
});
});
......@@ -175,6 +175,38 @@
:visibility :settings-manager
:export? true)
(def ^:private default-allowed-iframe-hosts
"youtube.com,
youtu.be,
loom.com,
vimeo.com,
docs.google.com,
calendar.google.com,
airtable.com,
typeform.com,
canva.com,
codepen.io,
figma.com,
grafana.com,
miro.com,
excalidraw.com,
notion.com,
atlassian.com,
trello.com,
asana.com,
gist.github.com,
linkedin.com,
twitter.com,
x.com")
(defsetting allowed-iframe-hosts
(deferred-tru "Allowed iframe hosts")
:encryption :no
:default default-allowed-iframe-hosts
:audit :getter
:visibility :public
:export? true)
(defsetting custom-homepage
(deferred-tru "Pick one of your dashboards to serve as homepage. Users without dashboard access will be directed to the default homepage.")
:encryption :no
......
......@@ -55,6 +55,61 @@
the original request was HTTPS; if sent in response to an HTTP request, this is simply ignored)"
{"Strict-Transport-Security" "max-age=31536000"})
(defn parse-url
"Returns an object with protocol, domain and port for the given url"
[url]
(if (= url "*")
{:protocol nil :domain "*" :port "*"}
(let [pattern #"^(?:(https?)://)?([^:/]+)(?::(\d+|\*))?$"
matches (re-matches pattern url)]
(if-not matches
(do (log/errorf "Invalid URL: %s" url) nil)
(let [[_ protocol domain port] matches]
{:protocol protocol
:domain domain
:port port})))))
(defn- add-wildcard-entries
"Adds a wildcard prefix `.*` to the domain part of the given `domain-or-url` string.
Only adds the wildcard entry when the given domain does not have a subdomain already,
with the exception of single name domains like 'localhost' which should not have the wildcard prefix.
This is done because we won't know if the typical iframe src URL will include a www or not.
For example,
youtube.com typically won't work because the iframe src is https://www.youtube.com/whatever. So we add *.youtube.com to cover this case.
But, *.twitter.com won't work for the inverse reason; the iframe src is https://twitter.com/whatever and adding the wildcard fails to match.
So, we'll double things up and include both the wildcard and non-wildcard entry. We still keep the logic of not adding a wildcard when a
subdomain is already specified because we want to treat this case as the user being more specific and thus intentionally less permissive."
[domain-or-url]
(let [cleaned-domain (-> domain-or-url
(str/replace #"/$" "")
(str/replace #"www." ""))
{:keys [protocol domain port]} (parse-url cleaned-domain)]
(when domain
(let [split-domain (str/split domain #"\.")
new-domains (cond-> (if (= (count split-domain) 2)
[domain (format "*.%s" domain)]
[domain])
(str/includes? domain-or-url "www.") (conj (format "www.%s" domain)))]
(for [new-domain new-domains]
(str (when protocol (format "%s://" protocol))
new-domain
(when (and port (not= domain "*")) (format ":%s" port))))))))
(defn- parse-allowed-iframe-hosts*
[hosts-string]
(->> (str/split hosts-string #"[ ,\s\r\n]+")
(remove str/blank?)
(mapcat add-wildcard-entries)
vec))
(def ^{:doc "Parse the string of allowed iframe hosts, adding wildcard prefixes as needed."}
parse-allowed-iframe-hosts
(memoize parse-allowed-iframe-hosts*))
(defn- content-security-policy-header
"`Content-Security-Policy` header. See https://content-security-policy.com for more details."
[nonce]
......@@ -67,14 +122,14 @@
"https://accounts.google.com"
(when (public-settings/anon-tracking-enabled)
"https://www.google-analytics.com")
;; for webpack hot reloading
;; for webpack hot reloading
(when config/is-dev?
"http://localhost:8080")
;; for react dev tools to work in Firefox until resolution of
;; https://github.com/facebook/react/issues/17997
;; for react dev tools to work in Firefox until resolution of
;; https://github.com/facebook/react/issues/17997
(when config/is-dev?
"'unsafe-inline'")]
;; CLJS REPL
;; CLJS REPL
(when config/is-dev?
["'unsafe-eval'"
"http://localhost:9630"])
......@@ -93,7 +148,7 @@
(when config/is-dev?
"http://localhost:9630")
"https://accounts.google.com"]
:frame-src ["*"]
:frame-src (parse-allowed-iframe-hosts (public-settings/allowed-iframe-hosts))
:font-src ["*"]
:img-src ["*"
"'self' data:"]
......@@ -124,20 +179,6 @@
eao
"'none'")))))
(defn parse-url
"Returns an object with protocol, domain and port for the given url"
[url]
(if (= url "*")
{:protocol nil :domain "*" :port "*"}
(let [pattern #"^(?:(https?)://)?([^:/]+)(?::(\d+|\*))?$"
matches (re-matches pattern url)]
(if-not matches
(do (log/errorf "Invalid URL: %s" url) nil)
(let [[_ protocol domain port] matches]
{:protocol protocol
:domain domain
:port port})))))
(defn approved-domain?
"Checks if the domain is compatible with the reference one"
[domain reference-domain]
......
......@@ -6,6 +6,7 @@
[clojure.test :refer :all]
[metabase.config :as config]
[metabase.embed.settings :as embed.settings]
[metabase.public-settings :as public-settings]
[metabase.server.middleware.security :as mw.security]
[metabase.test :as mt]
[metabase.test.util :as tu]
......@@ -56,6 +57,14 @@
(is (= "frame-ancestors 'none'"
(csp-directive "frame-ancestors")))))))
(deftest csp-header-iframe-hosts-tests
(testing "Allowed iframe hosts setting is used in the CSP frame-src directive."
(tu/with-temporary-setting-values [public-settings/allowed-iframe-hosts "https://www.wikipedia.org, https://www.metabase.com https://clojure.org"]
(is (= (str "frame-src https://wikipedia.org https://*.wikipedia.org https://www.wikipedia.org "
"https://metabase.com https://*.metabase.com https://www.metabase.com "
"https://clojure.org https://*.clojure.org")
(csp-directive "frame-src"))))))
(deftest xframeoptions-header-tests
(mt/with-premium-features #{:embedding}
(testing "`DENY` when embedding is disabled"
......@@ -218,3 +227,64 @@
(embed.settings/enable-embedding-sdk)
(embed.settings/embedding-app-origins-sdk))
"Access-Control-Allow-Origin"))))))))
(deftest allowed-iframe-hosts-test
(testing "The allowed iframe hosts parse in the expected way."
(let [default-hosts @#'public-settings/default-allowed-iframe-hosts]
(testing "The defaults hosts parse correctly"
(is (= ["youtube.com"
"*.youtube.com"
"youtu.be"
"*.youtu.be"
"loom.com"
"*.loom.com"
"vimeo.com"
"*.vimeo.com"
"docs.google.com"
"calendar.google.com"
"airtable.com"
"*.airtable.com"
"typeform.com"
"*.typeform.com"
"canva.com"
"*.canva.com"
"codepen.io"
"*.codepen.io"
"figma.com"
"*.figma.com"
"grafana.com"
"*.grafana.com"
"miro.com"
"*.miro.com"
"excalidraw.com"
"*.excalidraw.com"
"notion.com"
"*.notion.com"
"atlassian.com"
"*.atlassian.com"
"trello.com"
"*.trello.com"
"asana.com"
"*.asana.com"
"gist.github.com"
"linkedin.com"
"*.linkedin.com"
"twitter.com"
"*.twitter.com"
"x.com"
"*.x.com"]
(mw.security/parse-allowed-iframe-hosts default-hosts))))
(testing "Additional hosts a user may configure will parse correctly as well"
(is (= ["localhost"
"http://localhost:8000"
"my.domain.local:9876"
"*"
"mysite.com"
"*.mysite.com"
"www.mysite.com"
"mysite.cool.com"
"www.mysite.cool.com"]
(mw.security/parse-allowed-iframe-hosts "localhost, http://localhost:8000, my.domain.local:9876, *, www.mysite.com/, www.mysite.cool.com"))))
(testing "invalid hosts are not included"
(is (= []
(mw.security/parse-allowed-iframe-hosts "asdf/wasd/:8000 */localhost:*")))))))
......@@ -52,3 +52,4 @@ subscription-allowed-domains: null
synchronous-batch-updates: null
unaggregated-query-row-limit: null
update-channel: null
allowed-iframe-hosts: null
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment