Use much stricter, whitelist based CSP (#3162)

This commit is contained in:
Vending Machine 2025-06-06 18:30:19 +02:00 committed by GitHub
parent 0ce7772500
commit e7076f5aee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 221 additions and 74 deletions

View file

@ -4,4 +4,8 @@
border: 1px solid #e78284;
border-radius: 5px;
color: var(--text-normal, white);
& a:hover {
text-decoration: underline;
}
}

View file

@ -28,6 +28,9 @@ export function Link(props: React.PropsWithChildren<Props>) {
props.style.pointerEvents = "none";
props["aria-disabled"] = true;
}
props.rel ??= "noreferrer";
return (
<a role="link" target="_blank" {...props}>
{props.children}

View file

@ -18,13 +18,16 @@
import { Settings, useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import { ErrorCard } from "@components/ErrorCard";
import { Flex } from "@components/Flex";
import { DeleteIcon, FolderIcon, PaintbrushIcon, PencilIcon, PlusIcon, RestartIcon } from "@components/Icons";
import { Link } from "@components/Link";
import { openPluginModal } from "@components/PluginSettings/PluginModal";
import type { UserThemeHeader } from "@main/themes";
import { useCspErrors } from "@utils/cspViolations";
import { openInviteModal } from "@utils/discord";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { showItemInFolder } from "@utils/native";
import { useAwaiter } from "@utils/react";
import { findLazy } from "@webpack";
@ -219,6 +222,12 @@ function ThemesTab() {
<Forms.FormText>If using the BD site, click on "Download" and place the downloaded .theme.css file into your themes folder.</Forms.FormText>
</Card>
<Card className="vc-settings-card">
<Forms.FormTitle tag="h5">External Resources</Forms.FormTitle>
<Forms.FormText>For security reasons, loading resources (styles, fonts, images, ...) from most sites is blocked.</Forms.FormText>
<Forms.FormText>Make sure all your assets are hosted on GitHub, GitLab, Codeberg, Imgur, Discord or Google Fonts.</Forms.FormText>
</Card>
<Forms.FormSection title="Local Themes">
<QuickActionCard>
<>
@ -347,10 +356,32 @@ function ThemesTab() {
</TabBar.Item>
</TabBar>
<CspErrorCard />
{currentTab === ThemeTab.LOCAL && renderLocalThemes()}
{currentTab === ThemeTab.ONLINE && renderOnlineThemes()}
</SettingsTab>
);
}
export function CspErrorCard() {
const errors = useCspErrors();
if (!errors.length) return null;
return (
<ErrorCard className="vc-settings-card">
<Forms.FormTitle tag="h5">Blocked Resources</Forms.FormTitle>
<Forms.FormText>Some images, styles, or fonts were blocked because they come from disallowed domains.</Forms.FormText>
<Forms.FormText>Make sure that your themes and custom css only load resources from whitelisted websites, such as GitHub, Imgur and Google Fonts.</Forms.FormText>
<Forms.FormTitle tag="h5" className={classes(Margins.top16, Margins.bottom8)}>Blocked URLs</Forms.FormTitle>
<Flex flexDirection="column" style={{ gap: "0.25em" }}>
{errors.map(url => (
<Link href={url} key={url}>{url}</Link>
))}
</Flex>
</ErrorCard>
);
}
export default wrapTab(ThemesTab, "Themes");

138
src/main/csp.ts Normal file
View file

@ -0,0 +1,138 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { session } from "electron";
type PolicyMap = Record<string, string[]>;
export const ConnectSrc = ["connect-src"];
export const MediaSrc = [...ConnectSrc, "img-src", "media-src"];
export const CssSrc = ["style-src", "font-src"];
export const MediaAndCssSrc = [...MediaSrc, ...CssSrc];
export const MediaScriptsAndCssSrc = [...MediaAndCssSrc, "script-src", "worker-src"];
// Plugins can whitelist their own domains by importing this object in their native.ts
// script and just adding to it. But generally, you should just edit this file instead
export const CspPolicies: PolicyMap = {
"*.github.io": MediaAndCssSrc, // GitHub pages, used by most themes
"raw.githubusercontent.com": MediaAndCssSrc, // GitHub raw, used by some themes
"*.gitlab.io": MediaAndCssSrc, // GitLab pages, used by some themes
"gitlab.com": MediaAndCssSrc, // GitLab raw, used by some themes
"*.codeberg.page": MediaAndCssSrc, // Codeberg pages, used by some themes
"codeberg.org": MediaAndCssSrc, // Codeberg raw, used by some themes
"*.githack.com": MediaAndCssSrc, // githack (namely raw.githack.com), used by some themes
"jsdelivr.net": MediaAndCssSrc, // jsDelivr, used by very few themes
"fonts.googleapis.com": CssSrc, // Google Fonts, used by many themes
"i.imgur.com": MediaSrc, // Imgur, used by some themes
"i.ibb.co": MediaSrc, // ImgBB, used by some themes
"cdn.discordapp.com": MediaAndCssSrc, // Discord CDN, used by Vencord and some themes to load media
"media.discordapp.net": MediaSrc, // Discord media CDN, possible alternative to Discord CDN
// CDNs used for some things by Vencord.
// FIXME: we really should not be using CDNs anymore
"cdnjs.cloudflare.com": MediaScriptsAndCssSrc,
"cdn.jsdelivr.net": MediaScriptsAndCssSrc,
// Function Specific
"api.github.com": ConnectSrc, // used for updating Vencord itself
"ws.audioscrobbler.com": ConnectSrc, // Last.fm API
"translate-pa.googleapis.com": ConnectSrc, // Google Translate API
"*.vencord.dev": MediaSrc, // VenCloud (api.vencord.dev) and Badges (badges.vencord.dev)
"manti.vendicated.dev": MediaSrc, // ReviewDB API
"decor.fieryflames.dev": ConnectSrc, // Decor API
"ugc.decor.fieryflames.dev": MediaSrc, // Decor CDN
"sponsor.ajay.app": ConnectSrc, // Dearrow API
"dearrow-thumb.ajay.app": MediaSrc, // Dearrow Thumbnail CDN
"usrbg.is-hardly.online": MediaSrc, // USRBG API
"icons.duckduckgo.com": MediaSrc, // DuckDuckGo Favicon API (Reverse Image Search)
};
const findHeader = (headers: PolicyMap, headerName: Lowercase<string>) => {
return Object.keys(headers).find(h => h.toLowerCase() === headerName);
};
const parsePolicy = (policy: string): PolicyMap => {
const result: PolicyMap = {};
policy.split(";").forEach(directive => {
const [directiveKey, ...directiveValue] = directive.trim().split(/\s+/g);
if (directiveKey && !Object.prototype.hasOwnProperty.call(result, directiveKey)) {
result[directiveKey] = directiveValue;
}
});
return result;
};
const stringifyPolicy = (policy: PolicyMap): string =>
Object.entries(policy)
.filter(([, values]) => values?.length)
.map(directive => directive.flat().join(" "))
.join("; ");
const patchCsp = (headers: PolicyMap) => {
const reportOnlyHeader = findHeader(headers, "content-security-policy-report-only");
if (reportOnlyHeader)
delete headers[reportOnlyHeader];
const header = findHeader(headers, "content-security-policy");
if (header) {
const csp = parsePolicy(headers[header][0]);
const pushDirective = (directive: string, ...values: string[]) => {
csp[directive] ??= [...(csp["default-src"] ?? [])];
csp[directive].push(...values);
};
pushDirective("style-src", "'unsafe-inline'");
// we could make unsafe-inline safe by using strict-dynamic with a random nonce on our Vencord loader script https://content-security-policy.com/strict-dynamic/
// HOWEVER, at the time of writing (24 Jan 2025), Discord is INSANE and also uses unsafe-inline
// Once they stop using it, we also should
pushDirective("script-src", "'unsafe-inline'", "'unsafe-eval'");
for (const directive of ["style-src", "connect-src", "img-src", "font-src", "media-src", "worker-src"]) {
pushDirective(directive, "blob:", "data:", "vencord:");
}
for (const [host, directives] of Object.entries(CspPolicies)) {
for (const directive of directives) {
pushDirective(directive, host);
}
}
headers[header] = [stringifyPolicy(csp)];
}
};
export function initCsp() {
session.defaultSession.webRequest.onHeadersReceived(({ responseHeaders, resourceType }, cb) => {
if (responseHeaders) {
if (resourceType === "mainFrame")
patchCsp(responseHeaders);
// Fix hosts that don't properly set the css content type, such as
// raw.githubusercontent.com
if (resourceType === "stylesheet") {
const header = findHeader(responseHeaders, "content-type");
if (header)
responseHeaders[header] = ["text/css"];
}
}
cb({ cancel: false, responseHeaders });
});
// assign a noop to onHeadersReceived to prevent other mods from adding their own incompatible ones.
// For instance, OpenAsar adds their own that doesn't fix content-type for stylesheets which makes it
// impossible to load css from github raw despite our fix above
session.defaultSession.webRequest.onHeadersReceived = () => { };
}

View file

@ -16,9 +16,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { app, protocol, session } from "electron";
import { app, protocol } from "electron";
import { join } from "path";
import { initCsp } from "./csp";
import { ensureSafePath } from "./ipcMain";
import { RendererSettings } from "./settings";
import { IS_VANILLA, THEMES_DIR } from "./utils/constants";
@ -63,70 +64,7 @@ if (IS_VESKTOP || !IS_VANILLA) {
} catch { }
const findHeader = (headers: Record<string, string[]>, headerName: Lowercase<string>) => {
return Object.keys(headers).find(h => h.toLowerCase() === headerName);
};
// Remove CSP
type PolicyResult = Record<string, string[]>;
const parsePolicy = (policy: string): PolicyResult => {
const result: PolicyResult = {};
policy.split(";").forEach(directive => {
const [directiveKey, ...directiveValue] = directive.trim().split(/\s+/g);
if (directiveKey && !Object.prototype.hasOwnProperty.call(result, directiveKey)) {
result[directiveKey] = directiveValue;
}
});
return result;
};
const stringifyPolicy = (policy: PolicyResult): string =>
Object.entries(policy)
.filter(([, values]) => values?.length)
.map(directive => directive.flat().join(" "))
.join("; ");
const patchCsp = (headers: Record<string, string[]>) => {
const header = findHeader(headers, "content-security-policy");
if (header) {
const csp = parsePolicy(headers[header][0]);
for (const directive of ["style-src", "connect-src", "img-src", "font-src", "media-src", "worker-src"]) {
csp[directive] ??= [];
csp[directive].push("*", "blob:", "data:", "vencord:", "'unsafe-inline'");
}
// TODO: Restrict this to only imported packages with fixed version.
// Perhaps auto generate with esbuild
csp["script-src"] ??= [];
csp["script-src"].push("'unsafe-eval'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com");
headers[header] = [stringifyPolicy(csp)];
}
};
session.defaultSession.webRequest.onHeadersReceived(({ responseHeaders, resourceType }, cb) => {
if (responseHeaders) {
if (resourceType === "mainFrame")
patchCsp(responseHeaders);
// Fix hosts that don't properly set the css content type, such as
// raw.githubusercontent.com
if (resourceType === "stylesheet") {
const header = findHeader(responseHeaders, "content-type");
if (header)
responseHeaders[header] = ["text/css"];
}
}
cb({ cancel: false, responseHeaders });
});
// assign a noop to onHeadersReceived to prevent other mods from adding their own incompatible ones.
// For instance, OpenAsar adds their own that doesn't fix content-type for stylesheets which makes it
// impossible to load css from github raw despite our fix above
session.defaultSession.webRequest.onHeadersReceived = () => { };
initCsp();
});
}

View file

@ -33,7 +33,7 @@ import definePlugin from "@utils/types";
import { Forms, Toasts, UserStore } from "@webpack/common";
import { User } from "discord-types/general";
const CONTRIBUTOR_BADGE = "https://vencord.dev/assets/favicon.png";
const CONTRIBUTOR_BADGE = "https://cdn.discordapp.com/emojis/1092089799109775453.png?size=64";
const ContributorBadge: ProfileBadge = {
description: "Vencord Contributor",

View file

@ -91,7 +91,7 @@ function parseNode(node: Node) {
function initWs(isManual = false) {
let wasConnected = isManual;
let hasErrored = false;
const ws = socket = new WebSocket(`ws://localhost:${PORT}`);
const ws = socket = new WebSocket(`ws://127.0.0.1:${PORT}`);
ws.addEventListener("open", () => {
wasConnected = true;

View file

@ -53,14 +53,12 @@ function makeSearchItem(src: string) {
<Flex style={{ alignItems: "center", gap: "0.5em" }}>
<img
style={{
borderRadius: i >= 3 // Do not round Google, Yandex & SauceNAO
? "50%"
: void 0
borderRadius: "50%",
}}
aria-hidden="true"
height={16}
width={16}
src={new URL("/favicon.ico", Engines[engine]).toString().replace("lens.", "")}
src={`https://icons.duckduckgo.com/ip3/${new URL(Engines[engine]).host}.ico`}
/>
{engine}
</Flex>

View file

@ -40,7 +40,7 @@ export interface Dev {
*/
export const Devs = /* #__PURE__*/ Object.freeze({
Ven: {
name: "Vee",
name: "V",
id: 343383572805058560n
},
Arjix: {
@ -194,7 +194,7 @@ export const Devs = /* #__PURE__*/ Object.freeze({
},
axyie: {
name: "'ax",
id: 273562710745284628n,
id: 929877747151548487n,
},
pointy: {
name: "pointy",
@ -587,7 +587,7 @@ export const Devs = /* #__PURE__*/ Object.freeze({
},
samsam: {
name: "samsam",
id: 836452332387565589n,
id: 400482410279469056n,
},
Cootshk: {
name: "Cootshk",

View file

@ -0,0 +1,34 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { useLayoutEffect } from "@webpack/common";
import { useForceUpdater } from "./react";
const cssRelevantDirectives = ["style-src", "img-src", "font-src"] as const;
export const CspBlockedUrls = new Set<string>();
const CspErrorListeners = new Set<() => void>();
document.addEventListener("securitypolicyviolation", ({ effectiveDirective, blockedURI }) => {
if (!blockedURI || !cssRelevantDirectives.includes(effectiveDirective as any)) return;
CspBlockedUrls.add(blockedURI);
CspErrorListeners.forEach(listener => listener());
});
export function useCspErrors() {
const forceUpdate = useForceUpdater();
useLayoutEffect(() => {
CspErrorListeners.add(forceUpdate);
return () => void CspErrorListeners.delete(forceUpdate);
}, [forceUpdate]);
return [...CspBlockedUrls] as const;
}

View file

@ -21,6 +21,7 @@ export * from "../shared/onceDefined";
export * from "./ChangeList";
export * from "./clipboard";
export * from "./constants";
export * from "./cspViolations";
export * from "./discord";
export * from "./guards";
export * from "./intlHash";