Implement signed cdn urls

This commit is contained in:
Puyodead1 2025-04-16 23:28:43 -04:00 committed by Rory&
parent 1d8e081fd8
commit 080b2c7d38
14 changed files with 13067 additions and 3009 deletions

View File

@ -212,6 +212,48 @@
} }
} }
}, },
"GuildSubscriptionSchema": {
"type": "object",
"properties": {
"channels": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "integer"
}
}
}
},
"activities": {
"type": "boolean"
},
"threads": {
"type": "boolean"
},
"typing": {
"enum": [
true
],
"type": "boolean"
},
"members": {
"type": "array",
"items": {
"type": "string"
}
},
"member_updates": {
"type": "boolean"
},
"thread_member_lists": {
"type": "array",
"items": {}
}
}
},
"ActivitySchema": { "ActivitySchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -3972,6 +4014,21 @@
"widget_enabled" "widget_enabled"
] ]
}, },
"RefreshedUrl": {
"type": "object",
"properties": {
"original": {
"type": "string"
},
"refreshed": {
"type": "string"
}
},
"required": [
"original",
"refreshed"
]
},
"TenorGifResponse": { "TenorGifResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -4137,6 +4194,12 @@
"$ref": "#/components/schemas/Channel" "$ref": "#/components/schemas/Channel"
} }
}, },
"members": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Member"
}
},
"region": { "region": {
"type": "string" "type": "string"
}, },
@ -4215,12 +4278,6 @@
"presence_count": { "presence_count": {
"type": "integer" "type": "integer"
}, },
"members": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Member"
}
},
"template_id": { "template_id": {
"type": "string" "type": "string"
}, },
@ -4620,9 +4677,7 @@
"channel": { "channel": {
"$ref": "#/components/schemas/RateLimitOptions" "$ref": "#/components/schemas/RateLimitOptions"
}, },
"auth": { "auth": {}
"$ref": "#/components/schemas/AuthRateLimit"
}
}, },
"required": [ "required": [
"auth", "auth",
@ -4631,21 +4686,6 @@
"webhook" "webhook"
] ]
}, },
"AuthRateLimit": {
"type": "object",
"properties": {
"login": {
"$ref": "#/components/schemas/RateLimitOptions"
},
"register": {
"$ref": "#/components/schemas/RateLimitOptions"
}
},
"required": [
"login",
"register"
]
},
"GlobalRateLimits": { "GlobalRateLimits": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -5390,6 +5430,68 @@
} }
} }
}, },
"LazyRequestSchema": {
"type": "object",
"properties": {
"guild_id": {
"type": "string"
},
"channels": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "integer"
}
}
}
},
"activities": {
"type": "boolean"
},
"threads": {
"type": "boolean"
},
"typing": {
"enum": [
true
],
"type": "boolean"
},
"members": {
"type": "array",
"items": {
"type": "string"
}
},
"member_updates": {
"type": "boolean"
},
"thread_member_lists": {
"type": "array",
"items": {}
}
},
"required": [
"guild_id"
]
},
"GuildSubscriptionsBulkSchema": {
"type": "object",
"properties": {
"subscriptions": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/GuildSubscriptionSchema"
}
}
},
"required": [
"subscriptions"
]
},
"GuildTemplateCreateSchema": { "GuildTemplateCreateSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -5740,51 +5842,6 @@
} }
} }
}, },
"LazyRequestSchema": {
"type": "object",
"properties": {
"guild_id": {
"type": "string"
},
"channels": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "integer"
}
}
}
},
"activities": {
"type": "boolean"
},
"threads": {
"type": "boolean"
},
"typing": {
"enum": [
true
],
"type": "boolean"
},
"members": {
"type": "array",
"items": {
"type": "string"
}
},
"thread_member_lists": {
"type": "array",
"items": {}
}
},
"required": [
"guild_id"
]
},
"LoginSchema": { "LoginSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -6249,6 +6306,20 @@
"before" "before"
] ]
}, },
"RefreshUrlsRequestSchema": {
"type": "object",
"properties": {
"attachment_urls": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"attachment_urls"
]
},
"RegisterSchema": { "RegisterSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -7090,6 +7161,20 @@
"location" "location"
] ]
}, },
"RefreshUrlsResponse": {
"type": "object",
"properties": {
"refreshed_urls": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RefreshedUrl"
}
}
},
"required": [
"refreshed_urls"
]
},
"TeamListResponse": { "TeamListResponse": {
"type": "object", "type": "object",
"patternProperties": { "patternProperties": {
@ -9055,6 +9140,9 @@
{ {
"name": "applications" "name": "applications"
}, },
{
"name": "attachments"
},
{ {
"name": "auth" "name": "auth"
}, },
@ -11652,6 +11740,25 @@
] ]
} }
}, },
"/policies/instance/config/": {
"get": {
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Object"
}
}
}
}
},
"tags": [
"policies"
]
}
},
"/ping/": { "/ping/": {
"get": { "get": {
"responses": { "responses": {
@ -18302,6 +18409,50 @@
] ]
} }
}, },
"/attachments/refresh-urls/": {
"post": {
"security": [
{
"bearer": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RefreshUrlsRequestSchema"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RefreshUrlsResponse"
}
}
}
},
"400": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/APIErrorResponse"
}
}
}
}
},
"tags": [
"attachments"
]
}
},
"/applications/": { "/applications/": {
"get": { "get": {
"security": [ "security": [

File diff suppressed because it is too large Load Diff

1
package-lock.json generated
View File

@ -36,6 +36,7 @@
"missing-native-js-functions": "^1.4.3", "missing-native-js-functions": "^1.4.3",
"module-alias": "^2.2.3", "module-alias": "^2.2.3",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"ms": "^2.1.3",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"murmurhash-js": "^1.0.0", "murmurhash-js": "^1.0.0",
"node-2fa": "^2.0.3", "node-2fa": "^2.0.3",

View File

@ -96,6 +96,7 @@
"missing-native-js-functions": "^1.4.3", "missing-native-js-functions": "^1.4.3",
"module-alias": "^2.2.3", "module-alias": "^2.2.3",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"ms": "^2.1.3",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"murmurhash-js": "^1.0.0", "murmurhash-js": "^1.0.0",
"node-2fa": "^2.0.3", "node-2fa": "^2.0.3",

View File

@ -0,0 +1,48 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { route } from "@spacebar/api";
import { RefreshUrlsRequestSchema, resignUrl } from "@spacebar/util";
import { Request, Response, Router } from "express";
const router = Router();
router.post(
"/",
route({
requestBody: "RefreshUrlsRequestSchema",
responses: {
200: {
body: "RefreshUrlsResponse",
},
400: {
body: "APIErrorResponse",
},
},
}),
async (req: Request, res: Response) => {
const { attachment_urls } = req.body as RefreshUrlsRequestSchema;
const refreshed_urls = attachment_urls.map(resignUrl);
return res.status(200).json({
refreshed_urls,
});
},
);
export default router;

View File

@ -35,6 +35,7 @@ import {
emitEvent, emitEvent,
getPermission, getPermission,
isTextChannel, isTextChannel,
resignUrl,
uploadFile, uploadFile,
} from "@spacebar/util"; } from "@spacebar/util";
import { Request, Response, Router } from "express"; import { Request, Response, Router } from "express";
@ -199,16 +200,18 @@ router.get(
? y.proxy_url ? y.proxy_url
: `https://example.org${y.proxy_url}`; : `https://example.org${y.proxy_url}`;
let pathname = new URL(uri).pathname; const url = new URL(uri);
while ( if (endpoint) {
pathname.split("/")[0] != "attachments" && const newBase = new URL(endpoint);
pathname.length > 30 url.protocol = newBase.protocol;
) { url.hostname = newBase.hostname;
pathname = pathname.split("/").slice(1).join("/"); url.port = newBase.port;
} }
if (!endpoint?.endsWith("/")) pathname = "/" + pathname;
y.proxy_url = `${endpoint == null ? "" : endpoint}${pathname}`; y.proxy_url = url.toString();
y.proxy_url = resignUrl(y.proxy_url);
y.url = resignUrl(y.url);
}); });
/** /**

View File

@ -16,13 +16,18 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Router, Response, Request } from "express"; import {
import { Config, Snowflake } from "@spacebar/util"; Config,
import { storage } from "../util/Storage"; getUrlSignature,
hasValidSignature,
Snowflake,
} from "@spacebar/util";
import { Request, Response, Router } from "express";
import FileType from "file-type"; import FileType from "file-type";
import imageSize from "image-size";
import { HTTPError } from "lambert-server"; import { HTTPError } from "lambert-server";
import { multer } from "../util/multer"; import { multer } from "../util/multer";
import imageSize from "image-size"; import { storage } from "../util/Storage";
const router = Router(); const router = Router();
@ -39,6 +44,7 @@ router.post(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
if (req.headers.signature !== Config.get().security.requestSignature) if (req.headers.signature !== Config.get().security.requestSignature)
throw new HTTPError("Invalid request signature"); throw new HTTPError("Invalid request signature");
if (!req.file) throw new HTTPError("file missing"); if (!req.file) throw new HTTPError("file missing");
const { buffer, mimetype, size, originalname } = req.file; const { buffer, mimetype, size, originalname } = req.file;
@ -63,12 +69,20 @@ router.post(
} }
} }
let finalUrl = `${endpoint}/${path}`;
if (Config.get().security.cdnSignUrls) {
const signatureData = getUrlSignature(path);
console.log(signatureData);
finalUrl = `${finalUrl}?ex=${signatureData.expiresAt}&is=${signatureData.issuedAt}&hm=${signatureData.hash}&`;
}
const file = { const file = {
id, id,
content_type: mimetype, content_type: mimetype,
filename: filename, filename: filename,
size, size,
url: `${endpoint}/${path}`, url: finalUrl,
width, width,
height, height,
}; };
@ -84,6 +98,14 @@ router.get(
// const { format } = req.query; // const { format } = req.query;
const path = `attachments/${channel_id}/${id}/${filename}`; const path = `attachments/${channel_id}/${id}/${filename}`;
if (
Config.get().security.cdnSignUrls &&
!hasValidSignature(path, req.query)
) {
return res.status(404).send("This content is no longer available.");
}
const file = await storage.get(path); const file = await storage.get(path);
if (!file) throw new HTTPError("File not found"); if (!file) throw new HTTPError("File not found");
const type = await FileType.fromBuffer(file); const type = await FileType.fromBuffer(file);

136
src/util/Signing.ts Normal file
View File

@ -0,0 +1,136 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Config } from "@spacebar/util";
import { createHmac, timingSafeEqual } from "crypto";
import ms, { StringValue } from "ms";
import { ParsedQs } from "qs";
export const getUrlSignature = (path: string) => {
const { cdnSignatureKey, cdnSignatureDuration } = Config.get().security;
// calculate the expiration time
const now = Date.now();
const issuedAt = now.toString(16);
const expiresAt = (now + ms(cdnSignatureDuration as StringValue)).toString(
16,
);
// hash the url with the cdnSignatureKey
const hash = createHmac("sha256", cdnSignatureKey as string)
.update(path)
.update(issuedAt)
.update(expiresAt)
.digest("hex");
return {
hash,
issuedAt,
expiresAt,
};
};
export const calculateHash = (
url: string,
issuedAt: string,
expiresAt: string,
) => {
const { cdnSignatureKey } = Config.get().security;
const hash = createHmac("sha256", cdnSignatureKey as string)
.update(url)
.update(issuedAt)
.update(expiresAt)
.digest("hex");
return hash;
};
export const isExpired = (ex: string, is: string) => {
// convert issued at
const issuedAt = parseInt(is, 16);
const expiresAt = parseInt(ex, 16);
if (Number.isNaN(issuedAt) || Number.isNaN(expiresAt)) {
// console.debug("Invalid timestamps in query");
return true;
}
const currentTime = Date.now();
const isExpired = expiresAt < currentTime;
const isValidIssuedAt = issuedAt < currentTime;
if (isExpired || !isValidIssuedAt) {
// console.debug("Signature expired");
return true;
}
return false;
};
export const hasValidSignature = (path: string, query: ParsedQs) => {
// get url path
const { ex, is, hm } = query;
// if the required query parameters are not present, return false
if (!ex || !is || !hm) return false;
// check if the signature is expired
if (isExpired(ex as string, is as string)) {
return false;
}
const calcd = calculateHash(path, is as string, ex as string);
const calculated = Buffer.from(calcd);
const received = Buffer.from(hm as string);
const isHashValid =
calculated.length === received.length &&
timingSafeEqual(calculated, received);
// if (!isHashValid) {
// console.debug("Invalid signature");
// console.debug(calcd, hm);
// }
return isHashValid;
};
export const resignUrl = (attachmentUrl: string) => {
const url = new URL(attachmentUrl);
// if theres an existing signature, check if its expired or not. no reason to resign if its not expired
if (url.searchParams.has("ex") && url.searchParams.has("is")) {
// extract the ex and is
const ex = url.searchParams.get("ex");
const is = url.searchParams.get("is");
if (!isExpired(ex as string, is as string)) {
// if the signature is not expired, return the url as is
return attachmentUrl;
}
}
let path = url.pathname;
// strip / from the start
if (path.startsWith("/")) {
path = path.slice(1);
}
const { hash, issuedAt, expiresAt } = getUrlSignature(path);
url.searchParams.set("ex", expiresAt);
url.searchParams.set("is", issuedAt);
url.searchParams.set("hm", hash);
return url.toString();
};

View File

@ -37,4 +37,8 @@ export class SecurityConfiguration {
mfaBackupCodeCount: number = 10; mfaBackupCodeCount: number = 10;
statsWorldReadable: boolean = true; statsWorldReadable: boolean = true;
defaultRegistrationTokenExpiration: number = 1000 * 60 * 60 * 24 * 7; //1 week defaultRegistrationTokenExpiration: number = 1000 * 60 * 60 * 24 * 7; //1 week
// cdn signed urls
cdnSignUrls: boolean = false;
cdnSignatureKey: string = crypto.randomBytes(32).toString("base64");
cdnSignatureDuration: string = "24h";
} }

View File

@ -16,6 +16,8 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
// NOTE: !! DO NOT REORDER THE IMPORTS !!
import "reflect-metadata"; import "reflect-metadata";
export * from "./util/index"; export * from "./util/index";
@ -26,3 +28,4 @@ export * from "./schemas";
export * from "./imports"; export * from "./imports";
export * from "./config"; export * from "./config";
export * from "./connections"; export * from "./connections";
export * from "./Signing"

View File

@ -0,0 +1,21 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export interface RefreshUrlsRequestSchema {
attachment_urls: string[];
}

View File

@ -59,6 +59,7 @@ export * from "./MfaCodesSchema";
export * from "./ModifyGuildStickerSchema"; export * from "./ModifyGuildStickerSchema";
export * from "./PasswordResetSchema"; export * from "./PasswordResetSchema";
export * from "./PurgeSchema"; export * from "./PurgeSchema";
export * from "./RefreshUrlsRequestSchema";
export * from "./RegisterSchema"; export * from "./RegisterSchema";
export * from "./RelationshipPostSchema"; export * from "./RelationshipPostSchema";
export * from "./RelationshipPutSchema"; export * from "./RelationshipPutSchema";

View File

@ -0,0 +1,26 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export interface RefreshedUrl {
original: string;
refreshed: string;
}
export interface RefreshUrlsResponse {
refreshed_urls: RefreshedUrl[];
}

View File

@ -44,6 +44,7 @@ export * from "./InstanceStatsResponse";
export * from "./LocationMetadataResponse"; export * from "./LocationMetadataResponse";
export * from "./MemberJoinGuildResponse"; export * from "./MemberJoinGuildResponse";
export * from "./OAuthAuthorizeResponse"; export * from "./OAuthAuthorizeResponse";
export * from "./RefreshUrlsResponse";
export * from "./TeamListResponse"; export * from "./TeamListResponse";
export * from "./Tenor"; export * from "./Tenor";
export * from "./TokenResponse"; export * from "./TokenResponse";