From e7076f5aeeae02ae7f1793408a1b2995b199ee2b Mon Sep 17 00:00:00 2001 From: Vending Machine Date: Fri, 6 Jun 2025 18:30:19 +0200 Subject: [PATCH 1/2] Use much stricter, whitelist based CSP (#3162) --- src/components/ErrorCard.css | 4 + src/components/Link.tsx | 3 + src/components/VencordSettings/ThemesTab.tsx | 31 +++++ src/main/csp.ts | 138 +++++++++++++++++++ src/main/index.ts | 68 +-------- src/plugins/_api/badges/index.tsx | 2 +- src/plugins/devCompanion.dev/index.tsx | 2 +- src/plugins/reverseImageSearch/index.tsx | 6 +- src/utils/constants.ts | 6 +- src/utils/cspViolations.ts | 34 +++++ src/utils/index.ts | 1 + 11 files changed, 221 insertions(+), 74 deletions(-) create mode 100644 src/main/csp.ts create mode 100644 src/utils/cspViolations.ts diff --git a/src/components/ErrorCard.css b/src/components/ErrorCard.css index 5146aa03..6401c59c 100644 --- a/src/components/ErrorCard.css +++ b/src/components/ErrorCard.css @@ -4,4 +4,8 @@ border: 1px solid #e78284; border-radius: 5px; color: var(--text-normal, white); + + & a:hover { + text-decoration: underline; + } } diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 0f4eb07d..2eb7ab00 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -28,6 +28,9 @@ export function Link(props: React.PropsWithChildren) { props.style.pointerEvents = "none"; props["aria-disabled"] = true; } + + props.rel ??= "noreferrer"; + return ( {props.children} diff --git a/src/components/VencordSettings/ThemesTab.tsx b/src/components/VencordSettings/ThemesTab.tsx index f718ab11..c507ef88 100644 --- a/src/components/VencordSettings/ThemesTab.tsx +++ b/src/components/VencordSettings/ThemesTab.tsx @@ -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() { If using the BD site, click on "Download" and place the downloaded .theme.css file into your themes folder. + + External Resources + For security reasons, loading resources (styles, fonts, images, ...) from most sites is blocked. + Make sure all your assets are hosted on GitHub, GitLab, Codeberg, Imgur, Discord or Google Fonts. + + <> @@ -347,10 +356,32 @@ function ThemesTab() { + {currentTab === ThemeTab.LOCAL && renderLocalThemes()} {currentTab === ThemeTab.ONLINE && renderOnlineThemes()} ); } +export function CspErrorCard() { + const errors = useCspErrors(); + + if (!errors.length) return null; + + return ( + + Blocked Resources + Some images, styles, or fonts were blocked because they come from disallowed domains. + Make sure that your themes and custom css only load resources from whitelisted websites, such as GitHub, Imgur and Google Fonts. + + Blocked URLs + + {errors.map(url => ( + {url} + ))} + + + ); +} + export default wrapTab(ThemesTab, "Themes"); diff --git a/src/main/csp.ts b/src/main/csp.ts new file mode 100644 index 00000000..b35a11a8 --- /dev/null +++ b/src/main/csp.ts @@ -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; + +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) => { + 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 = () => { }; +} diff --git a/src/main/index.ts b/src/main/index.ts index 4cc2e0db..a001a490 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -16,9 +16,10 @@ * along with this program. If not, see . */ -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, headerName: Lowercase) => { - return Object.keys(headers).find(h => h.toLowerCase() === headerName); - }; - - // Remove CSP - type PolicyResult = Record; - - 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) => { - 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(); }); } diff --git a/src/plugins/_api/badges/index.tsx b/src/plugins/_api/badges/index.tsx index 8745584e..b00df6b0 100644 --- a/src/plugins/_api/badges/index.tsx +++ b/src/plugins/_api/badges/index.tsx @@ -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", diff --git a/src/plugins/devCompanion.dev/index.tsx b/src/plugins/devCompanion.dev/index.tsx index 4ac2e993..7a9a1930 100644 --- a/src/plugins/devCompanion.dev/index.tsx +++ b/src/plugins/devCompanion.dev/index.tsx @@ -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; diff --git a/src/plugins/reverseImageSearch/index.tsx b/src/plugins/reverseImageSearch/index.tsx index 17fdb180..a2e7b7e6 100644 --- a/src/plugins/reverseImageSearch/index.tsx +++ b/src/plugins/reverseImageSearch/index.tsx @@ -53,14 +53,12 @@ function makeSearchItem(src: string) { = 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} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 7bcadb42..afe1651f 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -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", diff --git a/src/utils/cspViolations.ts b/src/utils/cspViolations.ts new file mode 100644 index 00000000..9477bcaa --- /dev/null +++ b/src/utils/cspViolations.ts @@ -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(); +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; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 70ac9d42..3e7aa0e1 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -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"; From fae15dbdfe6e1044b740c82c38e0f971022537f3 Mon Sep 17 00:00:00 2001 From: Vendicated Date: Fri, 6 Jun 2025 18:48:56 +0200 Subject: [PATCH 2/2] avoid showing ugly red error cards to users --- src/api/MessageAccessories.tsx | 2 +- src/components/ErrorBoundary.tsx | 7 ++++++- src/components/PluginSettings/PluginModal.tsx | 2 +- src/plugins/decor/index.tsx | 2 +- src/plugins/favGifSearch/index.tsx | 2 +- src/plugins/mentionAvatars/index.tsx | 2 +- src/plugins/messageLinkEmbeds/index.tsx | 13 +++++-------- src/plugins/mutualGroupDMs/index.tsx | 2 +- src/plugins/pauseInvitesForever/index.tsx | 2 +- src/plugins/sortFriendRequests/index.tsx | 2 +- src/plugins/vencordToolbox/index.tsx | 2 +- 11 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/api/MessageAccessories.tsx b/src/api/MessageAccessories.tsx index 71664e93..d2bc081e 100644 --- a/src/api/MessageAccessories.tsx +++ b/src/api/MessageAccessories.tsx @@ -48,7 +48,7 @@ export function _modifyAccessories( ) { for (const [key, accessory] of accessories.entries()) { const res = ( - + ); diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 0ca20440..7058b5fd 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -75,10 +75,15 @@ const ErrorBoundary = LazyComponent(() => { logger.error(`${this.props.message || "A component threw an Error"}\n`, error, errorInfo.componentStack); } + get isNoop() { + if (IS_DEV) return false; + return this.props.noop; + } + render() { if (this.state.error === NO_ERROR) return this.props.children; - if (this.props.noop) return null; + if (this.isNoop) return null; if (this.props.fallback) return ( diff --git a/src/components/PluginSettings/PluginModal.tsx b/src/components/PluginSettings/PluginModal.tsx index 7baeba08..17ab2662 100644 --- a/src/components/PluginSettings/PluginModal.tsx +++ b/src/components/PluginSettings/PluginModal.tsx @@ -270,7 +270,7 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti {!!plugin.settingsAboutComponent && (
- + diff --git a/src/plugins/decor/index.tsx b/src/plugins/decor/index.tsx index 63963d09..0a6dd85d 100644 --- a/src/plugins/decor/index.tsx +++ b/src/plugins/decor/index.tsx @@ -150,5 +150,5 @@ export default definePlugin({ } }, - DecorSection: ErrorBoundary.wrap(DecorSection) + DecorSection: ErrorBoundary.wrap(DecorSection, { noop: true }) }); diff --git a/src/plugins/favGifSearch/index.tsx b/src/plugins/favGifSearch/index.tsx index d71f5679..5e302912 100644 --- a/src/plugins/favGifSearch/index.tsx +++ b/src/plugins/favGifSearch/index.tsx @@ -118,7 +118,7 @@ export default definePlugin({ renderSearchBar(instance: Instance, SearchBarComponent: TSearchBarComponent) { this.instance = instance; return ( - + ); diff --git a/src/plugins/mentionAvatars/index.tsx b/src/plugins/mentionAvatars/index.tsx index c4a3adce..5466a9e2 100644 --- a/src/plugins/mentionAvatars/index.tsx +++ b/src/plugins/mentionAvatars/index.tsx @@ -99,7 +99,7 @@ export default definePlugin({ src={`${location.protocol}//${window.GLOBAL_ENV.CDN_HOST}/role-icons/${roleId}/${role.icon}.webp?size=24&quality=lossless`} /> ); - }), + }, { noop: true }), }); function getUsernameString(username: string) { diff --git a/src/plugins/messageLinkEmbeds/index.tsx b/src/plugins/messageLinkEmbeds/index.tsx index c248167f..69727862 100644 --- a/src/plugins/messageLinkEmbeds/index.tsx +++ b/src/plugins/messageLinkEmbeds/index.tsx @@ -20,7 +20,6 @@ import { addMessageAccessory, removeMessageAccessory } from "@api/MessageAccesso import { updateMessage } from "@api/MessageUpdater"; import { definePluginSettings } from "@api/Settings"; import { getUserSettingLazy } from "@api/UserSettings"; -import ErrorBoundary from "@components/ErrorBoundary"; import { Devs } from "@utils/constants.js"; import { classes } from "@utils/misc"; import { Queue } from "@utils/Queue"; @@ -373,7 +372,7 @@ export default definePlugin({ settings, start() { - addMessageAccessory("messageLinkEmbed", props => { + addMessageAccessory("MessageLinkEmbeds", props => { if (!messageLinkRegex.test(props.message.content)) return null; @@ -381,16 +380,14 @@ export default definePlugin({ messageLinkRegex.lastIndex = 0; return ( - - - + ); }, 4 /* just above rich embeds */); }, stop() { - removeMessageAccessory("messageLinkEmbed"); + removeMessageAccessory("MessageLinkEmbeds"); } }); diff --git a/src/plugins/mutualGroupDMs/index.tsx b/src/plugins/mutualGroupDMs/index.tsx index 1058410f..796a91db 100644 --- a/src/plugins/mutualGroupDMs/index.tsx +++ b/src/plugins/mutualGroupDMs/index.tsx @@ -204,5 +204,5 @@ export default definePlugin({ /> ); - }) + }, { noop: true }) }); diff --git a/src/plugins/pauseInvitesForever/index.tsx b/src/plugins/pauseInvitesForever/index.tsx index b648f92e..432d1c1c 100644 --- a/src/plugins/pauseInvitesForever/index.tsx +++ b/src/plugins/pauseInvitesForever/index.tsx @@ -75,5 +75,5 @@ export default definePlugin({ }}> Pause Indefinitely.}
); - }) + }, { noop: true }) }); diff --git a/src/plugins/sortFriendRequests/index.tsx b/src/plugins/sortFriendRequests/index.tsx index 5f45902e..d8b64cf9 100644 --- a/src/plugins/sortFriendRequests/index.tsx +++ b/src/plugins/sortFriendRequests/index.tsx @@ -86,5 +86,5 @@ export default definePlugin({ )} ; - }) + }, { noop: true }) }); diff --git a/src/plugins/vencordToolbox/index.tsx b/src/plugins/vencordToolbox/index.tsx index 754af009..2f671a23 100644 --- a/src/plugins/vencordToolbox/index.tsx +++ b/src/plugins/vencordToolbox/index.tsx @@ -125,7 +125,7 @@ function VencordPopoutButton() { function ToolboxFragmentWrapper({ children }: { children: ReactNode[]; }) { children.splice( children.length - 1, 0, - + );