Merge remote-tracking branch 'upstream/dev' into dev

This commit is contained in:
thororen1234 2025-06-12 02:11:34 -04:00
commit 035f75bbea
No known key found for this signature in database
15 changed files with 253 additions and 101 deletions

View file

@ -114,4 +114,5 @@ window.VencordNative = {
},
pluginHelpers: {} as any,
csp: {} as any,
};

View file

@ -5,6 +5,7 @@
*/
import type { Settings } from "@api/Settings";
import { CspRequestResult } from "@main/csp/manager";
import { PluginIpcMappings } from "@main/ipcPlugins";
import { IpcEvents } from "@shared/IpcEvents";
import { IpcRes } from "@utils/types";
@ -72,5 +73,17 @@ export default {
openExternal: (url: string) => invoke<void>(IpcEvents.OPEN_EXTERNAL, url)
},
csp: {
/**
* Note: Only supports full explicit matches, not wildcards.
*
* If `*.example.com` is allowed, `isDomainAllowed("https://sub.example.com")` will return false.
*/
isDomainAllowed: (url: string, directives: string[]) => invoke<boolean>(IpcEvents.CSP_IS_DOMAIN_ALLOWED, url, directives),
removeOverride: (url: string) => invoke<boolean>(IpcEvents.CSP_REMOVE_OVERRIDE, url),
requestAddOverride: (url: string, directives: string[], callerName: string) =>
invoke<CspRequestResult>(IpcEvents.CSP_REQUEST_ADD_OVERRIDE, url, directives, callerName),
},
pluginHelpers: PluginHelpers
};

View file

@ -21,7 +21,7 @@ import { Settings, useSettings } from "@api/Settings";
import { CheckedTextInput } from "@components/CheckedTextInput";
import { Grid } from "@components/Grid";
import { Link } from "@components/Link";
import { authorizeCloud, cloudLogger, deauthorizeCloud, getCloudAuth, getCloudUrl } from "@utils/cloud";
import { authorizeCloud, checkCloudUrlCsp, cloudLogger, deauthorizeCloud, getCloudAuth, getCloudUrl } from "@utils/cloud";
import { Margins } from "@utils/margins";
import { deleteCloudSettings, getCloudSettings, putCloudSettings } from "@utils/settingsSync";
import { Alerts, Button, Forms, Switch, Tooltip } from "@webpack/common";
@ -38,6 +38,8 @@ function validateUrl(url: string) {
}
async function eraseAllData() {
if (!await checkCloudUrlCsp()) return;
const res = await fetch(new URL("/v1/", getCloudUrl()), {
method: "DELETE",
headers: { Authorization: await getCloudAuth() }

View file

@ -28,11 +28,25 @@
content: "by ";
}
.vc-settings-theme-link-input {
width: 100%;
.vc-settings-csp-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.vc-settings-theme-add-card {
padding: 1em;
margin-bottom: 16px;
.vc-settings-csp-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
& a {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
line-height: 1.2em;
}
--custom-button-button-md-height: 26px;
}

View file

@ -4,59 +4,63 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { NativeSettings } from "@main/settings";
import { session } from "electron";
type PolicyMap = Record<string, string[]>;
export const ConnectSrc = ["connect-src"];
export const MediaSrc = [...ConnectSrc, "img-src", "media-src"];
export const ImageSrc = [...ConnectSrc, "img-src"];
export const CssSrc = ["style-src", "font-src"];
export const MediaAndCssSrc = [...MediaSrc, ...CssSrc];
export const MediaScriptsAndCssSrc = [...MediaAndCssSrc, "script-src", "worker-src", "frame-src"];
export const ImageAndCssSrc = [...ImageSrc, ...CssSrc];
export const ImageScriptsAndCssSrc = [...ImageAndCssSrc, "script-src", "worker-src", "frame-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
"github.com": MediaAndCssSrc, // GitHub content (stuff uploaded to markdown forms), 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
"localhost": ImageAndCssSrc,
"127.0.0.1": ImageAndCssSrc,
"*.githack.com": MediaAndCssSrc, // githack (namely raw.githack.com), used by some themes
"jsdelivr.net": MediaAndCssSrc, // jsDelivr, used by very few themes
"*.github.io": ImageAndCssSrc, // GitHub pages, used by most themes
"github.com": ImageAndCssSrc, // GitHub content (stuff uploaded to markdown forms), used by most themes
"raw.githubusercontent.com": ImageAndCssSrc, // GitHub raw, used by some themes
"*.gitlab.io": ImageAndCssSrc, // GitLab pages, used by some themes
"gitlab.com": ImageAndCssSrc, // GitLab raw, used by some themes
"*.codeberg.page": ImageAndCssSrc, // Codeberg pages, used by some themes
"codeberg.org": ImageAndCssSrc, // Codeberg raw, used by some themes
"*.githack.com": ImageAndCssSrc, // githack (namely raw.githack.com), used by some themes
"jsdelivr.net": ImageAndCssSrc, // 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
"i.pinimg.com": MediaSrc, // Pinterest, used by some themes
"*.tenor.com": MediaSrc, // Tenor, used by some themes
"files.catbox.moe": MediaSrc, // Catbox, used by some themes
"i.imgur.com": ImageSrc, // Imgur, used by some themes
"i.ibb.co": ImageSrc, // ImgBB, used by some themes
"i.pinimg.com": ImageSrc, // Pinterest, used by some themes
"*.tenor.com": ImageSrc, // Tenor, used by some themes
"files.catbox.moe": ImageAndCssSrc, // Catbox, 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
"cdn.discordapp.com": ImageAndCssSrc, // Discord CDN, used by Vencord and some themes to load media
"media.discordapp.net": ImageSrc, // 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,
"cdnjs.cloudflare.com": ImageScriptsAndCssSrc,
"cdn.jsdelivr.net": ImageScriptsAndCssSrc,
// 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
"*.vencord.dev": ImageSrc, // VenCloud (api.vencord.dev) and Badges (badges.vencord.dev)
"manti.vendicated.dev": ImageSrc, // ReviewDB API
"decor.fieryflames.dev": ConnectSrc, // Decor API
"ugc.decor.fieryflames.dev": MediaSrc, // Decor CDN
"ugc.decor.fieryflames.dev": ImageSrc, // 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)
"dearrow-thumb.ajay.app": ImageSrc, // Dearrow Thumbnail CDN
"usrbg.is-hardly.online": ImageSrc, // USRBG API
"icons.duckduckgo.com": ImageSrc, // DuckDuckGo Favicon API (Reverse Image Search)
};
const findHeader = (headers: PolicyMap, headerName: Lowercase<string>) => {
@ -107,6 +111,12 @@ const patchCsp = (headers: PolicyMap) => {
pushDirective(directive, "blob:", "data:", "vencord:");
}
for (const [host, directives] of Object.entries(NativeSettings.store.customCspRules)) {
for (const directive of directives) {
pushDirective(directive, host);
}
}
for (const [host, directives] of Object.entries(CspPolicies)) {
for (const directive of directives) {
pushDirective(directive, host);

125
src/main/csp/manager.ts Normal file
View file

@ -0,0 +1,125 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { NativeSettings } from "@main/settings";
import { IpcEvents } from "@shared/IpcEvents";
import { dialog, ipcMain, IpcMainInvokeEvent } from "electron";
import { CspPolicies, ImageAndCssSrc } from ".";
export type CspRequestResult = "invalid" | "cancelled" | "unchecked" | "ok" | "conflict";
export function registerCspIpcHandlers() {
ipcMain.handle(IpcEvents.CSP_REMOVE_OVERRIDE, removeCspRule);
ipcMain.handle(IpcEvents.CSP_REQUEST_ADD_OVERRIDE, addCspRule);
ipcMain.handle(IpcEvents.CSP_IS_DOMAIN_ALLOWED, isDomainAllowed);
}
function validate(url: string, directives: string[]) {
try {
const { hostname } = new URL(url);
if (/[;'"\\]/.test(hostname)) return false;
} catch {
return false;
}
if (directives.length === 0) return false;
if (directives.some(d => !ImageAndCssSrc.includes(d))) return false;
return true;
}
function getMessage(url: string, directives: string[], callerName: string) {
const domain = new URL(url).hostname;
const message = `${callerName} wants to allow connections to ${domain}`;
let detail =
`Unless you recognise and fully trust ${domain}, you should cancel this request!\n\n` +
`You will have to fully close and restart ${IS_DISCORD_DESKTOP ? "Discord" : "Vesktop"} for the changes to take effect.`;
if (directives.length === 1 && directives[0] === "connect-src") {
return { message, detail };
}
const contentTypes = directives
.filter(type => type !== "connect-src")
.map(type => {
switch (type) {
case "img-src":
return "Images";
case "style-src":
return "CSS & Themes";
case "font-src":
return "Fonts";
default:
throw new Error(`Illegal CSP directive: ${type}`);
}
})
.sort()
.join(", ");
detail = `The following types of content will be allowed to load from ${domain}:\n${contentTypes}\n\n${detail}`;
return { message, detail };
}
async function addCspRule(_: IpcMainInvokeEvent, url: string, directives: string[], callerName: string): Promise<CspRequestResult> {
if (!validate(url, directives)) {
return "invalid";
}
const domain = new URL(url).hostname;
if (domain in NativeSettings.store.customCspRules) {
return "conflict";
}
const { checkboxChecked, response } = await dialog.showMessageBox({
...getMessage(url, directives, callerName),
type: callerName ? "info" : "warning",
title: "Vencord Host Permissions",
buttons: ["Cancel", "Allow"],
defaultId: 0,
cancelId: 0,
checkboxLabel: `I fully trust ${domain} and understand the risks of allowing connections to it.`,
checkboxChecked: false,
});
if (response !== 1) {
return "cancelled";
}
if (!checkboxChecked) {
return "unchecked";
}
NativeSettings.store.customCspRules[domain] = directives;
return "ok";
}
function removeCspRule(_: IpcMainInvokeEvent, domain: string) {
if (domain in NativeSettings.store.customCspRules) {
delete NativeSettings.store.customCspRules[domain];
return true;
}
return false;
}
function isDomainAllowed(_: IpcMainInvokeEvent, url: string, directives: string[]) {
try {
const domain = new URL(url).hostname;
const ruleForDomain = CspPolicies[domain] ?? NativeSettings.store.customCspRules[domain];
if (!ruleForDomain) return false;
return directives.every(d => ruleForDomain.includes(d));
} catch (e) {
return false;
}
}

View file

@ -28,11 +28,14 @@ import { FSWatcher, mkdirSync, watch, writeFileSync } from "fs";
import { open, readdir, readFile } from "fs/promises";
import { join, normalize } from "path";
import { registerCspIpcHandlers } from "./csp/manager";
import { ALLOWED_PROTOCOLS, QUICKCSS_PATH, THEMES_DIR } from "./utils/constants";
import { makeLinksOpenExternally } from "./utils/externalLinks";
mkdirSync(THEMES_DIR, { recursive: true });
registerCspIpcHandlers();
export function ensureSafePath(basePath: string, path: string) {
const normalizedBasePath = normalize(basePath + "/");
const newPath = join(basePath, path);

View file

@ -49,16 +49,18 @@ export interface NativeSettings {
[setting: string]: any;
};
};
customCspRules: Record<string, string[]>;
}
const DefaultNativeSettings: NativeSettings = {
plugins: {}
plugins: {},
customCspRules: {}
};
const nativeSettings = readSettings<NativeSettings>("native", NATIVE_SETTINGS_FILE);
mergeDefaults(nativeSettings, DefaultNativeSettings);
export const NativeSettings = new SettingsStore(nativeSettings);
export const NativeSettings = new SettingsStore(nativeSettings as NativeSettings);
NativeSettings.addGlobalChangeListener(() => {
try {

View file

@ -1,49 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
const settings = definePluginSettings({
source: {
description: "Source to replace ban GIF with (Video or Gif)",
type: OptionType.STRING,
default: "https://i.imgur.com/wp5q52C.mp4",
restartNeeded: true,
}
});
export default definePlugin({
name: "BANger",
description: "Replaces the GIF in the ban dialogue with a custom one.",
authors: [Devs.Xinto, Devs.Glitch],
settings,
patches: [
{
find: "#{intl::jeKpoq::raw}", // BAN_CONFIRM_TITLE
replacement: {
match: /src:\i\("?\d+"?\)/g,
replace: "src:$self.source"
}
}
],
get source() {
return settings.store.source;
}
});

View file

@ -69,7 +69,7 @@ export default definePlugin({
},
// Change top right chat toolbar button from the help one to the dev one
{
find: ".GLOBAL_DISCOVERY)?",
find: '"M9 3v18"',
replacement: {
match: /hasBugReporterAccess:(\i)/,
replace: "_hasBugReporterAccess:$1=true"

View file

@ -233,7 +233,7 @@ function ServerInfoTab({ guild }: GuildProps) {
"Vanity Link": guild.vanityURLCode ? (<a>{`discord.gg/${guild.vanityURLCode}`}</a>) : "-", // Making the anchor href valid would cause Discord to reload
"Preferred Locale": guild.preferredLocale || "-",
"Verification Level": ["None", "Low", "Medium", "High", "Highest"][guild.verificationLevel] || "?",
"Nitro Boosts": `${guild.premiumSubscriberCount ?? 0} (Level ${guild.premiumTier ?? 0})`,
"Server Boosts": `${guild.premiumSubscriberCount ?? 0} (Level ${guild.premiumTier ?? 0})`,
"Channels": GuildChannelStore.getChannels(guild.id)?.count - 1 || "?", // - null category
"Roles": Object.keys(GuildStore.getRoles(guild.id)).length - 1, // - @everyone
};

View file

@ -15,6 +15,8 @@
margin-top: 0.5em;
font-style: italic;
font-weight: 400;
line-height: 1.2rem;
white-space: break-spaces;
}
.vc-trans-accessory-icon {

View file

@ -42,4 +42,8 @@ export const enum IpcEvents {
OPEN_IN_APP__RESOLVE_REDIRECT = "VencordOIAResolveRedirect",
VOICE_MESSAGES_READ_RECORDING = "VencordVMReadRecording",
CSP_IS_DOMAIN_ALLOWED = "VencordCspIsDomainAllowed",
CSP_REMOVE_OVERRIDE = "VencordCspRemoveOverride",
CSP_REQUEST_ADD_OVERRIDE = "VencordCspRequestAddOverride",
}

View file

@ -19,23 +19,40 @@
import * as DataStore from "@api/DataStore";
import { showNotification } from "@api/Notifications";
import { Settings } from "@api/Settings";
import { OAuth2AuthorizeModal, UserStore } from "@webpack/common";
import { Alerts, OAuth2AuthorizeModal, UserStore } from "@webpack/common";
import { Logger } from "./Logger";
import { openModal } from "./modal";
import { relaunch } from "./native";
export const cloudLogger = new Logger("Cloud", "#39b7e0");
export const cloudUrl = () => {
if (Settings.cloud.url.includes("https://equicord.thororen.com") || Settings.cloud.url.includes("https://cloud.equicord.fyi")) {
Settings.cloud.url = "https://cloud.equicord.org";
Settings.cloud.authenticated = false;
deauthorizeCloud();
}
return Settings.cloud.url;
};
export const getCloudUrl = () => new URL(cloudUrl());
const cloudUrlOrigin = () => getCloudUrl().origin;
export const getCloudUrl = () => new URL(Settings.cloud.url);
const getCloudUrlOrigin = () => getCloudUrl().origin;
export async function checkCloudUrlCsp() {
if (IS_WEB) return true;
const { host } = getCloudUrl();
if (host === "api.vencord.dev") return true;
if (await VencordNative.csp.isDomainAllowed(Settings.cloud.url, ["connect-src"])) {
return true;
}
const res = await VencordNative.csp.requestAddOverride(Settings.cloud.url, ["connect-src"], "Cloud Sync");
if (res === "ok") {
Alerts.show({
title: "Cloud Integration enabled",
body: `${host} has been added to the whitelist. Please restart the app for the changes to take effect.`,
confirmText: "Restart now",
cancelText: "Later!",
onConfirm: relaunch
});
}
return false;
}
const getUserId = () => {
const id = UserStore.getCurrentUser()?.id;
if (!id) throw new Error("User not yet logged in");
@ -45,7 +62,7 @@ const getUserId = () => {
export async function getAuthorization() {
const secrets = await DataStore.get<Record<string, string>>("Vencord_cloudSecret") ?? {};
const origin = cloudUrlOrigin();
const origin = getCloudUrlOrigin();
// we need to migrate from the old format here
if (secrets[origin]) {
@ -67,7 +84,7 @@ export async function getAuthorization() {
async function setAuthorization(secret: string) {
await DataStore.update<Record<string, string>>("Vencord_cloudSecret", secrets => {
secrets ??= {};
secrets[`${cloudUrlOrigin()}:${getUserId()}`] = secret;
secrets[`${getCloudUrlOrigin()}:${getUserId()}`] = secret;
return secrets;
});
}
@ -75,7 +92,7 @@ async function setAuthorization(secret: string) {
export async function deauthorizeCloud() {
await DataStore.update<Record<string, string>>("Vencord_cloudSecret", secrets => {
secrets ??= {};
delete secrets[`${cloudUrlOrigin()}:${getUserId()}`];
delete secrets[`${getCloudUrlOrigin()}:${getUserId()}`];
return secrets;
});
}
@ -86,6 +103,8 @@ export async function authorizeCloud() {
return;
}
if (!await checkCloudUrlCsp()) return;
try {
const oauthConfiguration = await fetch(new URL("/v1/oauth/settings", getCloudUrl()));
var { clientId, redirectUri } = await oauthConfiguration.json();

View file

@ -22,7 +22,7 @@ import { PlainSettings, Settings } from "@api/Settings";
import { moment, SettingsRouter, Toasts } from "@webpack/common";
import { deflateSync, inflateSync } from "fflate";
import { getCloudAuth, getCloudUrl } from "./cloud";
import { checkCloudUrlCsp, getCloudAuth, getCloudUrl } from "./cloud";
import { Logger } from "./Logger";
import { relaunch } from "./native";
import { chooseFile, saveFile } from "./web";
@ -118,6 +118,8 @@ const cloudSettingsLogger = new Logger("Cloud:Settings", "#39b7e0");
export async function putCloudSettings(manual?: boolean) {
const settings = await exportSettings({ minify: true });
if (!await checkCloudUrlCsp()) return;
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "PUT",
@ -162,6 +164,8 @@ export async function putCloudSettings(manual?: boolean) {
}
export async function getCloudSettings(shouldNotify = true, force = false) {
if (!await checkCloudUrlCsp()) return;
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "GET",
@ -264,6 +268,8 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
}
export async function deleteCloudSettings() {
if (!await checkCloudUrlCsp()) return;
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "DELETE",