Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use much stricter, whitelist based CSP #3162

Draft
wants to merge 8 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions src/main/csp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* 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[]>;

const ConnectSrc = ["connect-src"];
const MediaSrc = [...ConnectSrc, "img-src", "media-src"];
const CssSrc = ["style-src", "font-src"];
const MediaAndCssSrc = [...MediaSrc, ...CssSrc];
const MediaScriptsAndCssSrc = [...MediaAndCssSrc, "script-src", "worker-src"];

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, // jsdeliver, 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,
"unpkg.com": MediaScriptsAndCssSrc,

// Function Specific
"api.github.com": ConnectSrc, // used for updating Vencord itself
"ws.audioscrobbler.com": ConnectSrc, // last.fm API
"translate.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
};

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: Record<string, string[]>) => {
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 = () => { };
}
68 changes: 3 additions & 65 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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://unpkg.com", "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();
});
}

Expand Down
2 changes: 1 addition & 1 deletion src/plugins/_api/badges/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/devCompanion.dev/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading