mirror of
https://github.com/Equicord/Equicord.git
synced 2025-06-07 13:43:03 -04:00
Use much stricter, whitelist based CSP (#3162)
This commit is contained in:
parent
0ce7772500
commit
e7076f5aee
11 changed files with 221 additions and 74 deletions
|
@ -4,4 +4,8 @@
|
|||
border: 1px solid #e78284;
|
||||
border-radius: 5px;
|
||||
color: var(--text-normal, white);
|
||||
|
||||
& a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
138
src/main/csp.ts
Normal 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 = () => { };
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
34
src/utils/cspViolations.ts
Normal file
34
src/utils/cspViolations.ts
Normal 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;
|
||||
}
|
|
@ -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";
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue