Make IP/user-agent validation in CDN work

This commit is contained in:
Rory& 2025-07-04 09:10:42 +02:00
parent 8bf45dcdc1
commit 2e196573ea
10 changed files with 264 additions and 122 deletions

View File

@ -17,7 +17,7 @@
*/ */
import { route } from "@spacebar/api"; import { route } from "@spacebar/api";
import { RefreshUrlsRequestSchema, resignUrl } from "@spacebar/util"; import { RefreshUrlsRequestSchema, getUrlSignature, NewUrlSignatureData } from "@spacebar/util";
import { Request, Response, Router } from "express"; import { Request, Response, Router } from "express";
const router = Router(); const router = Router();
@ -37,7 +37,13 @@ router.post(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const { attachment_urls } = req.body as RefreshUrlsRequestSchema; const { attachment_urls } = req.body as RefreshUrlsRequestSchema;
const refreshed_urls = attachment_urls.map(url => resignUrl(url, req)); const refreshed_urls = attachment_urls.map(url => {
return getUrlSignature(new NewUrlSignatureData({
url: url,
ip: req.ip,
userAgent: req.headers["user-agent"] as string
})).applyToUrl(url).toString();
});
return res.status(200).json({ return res.status(200).json({
refreshed_urls, refreshed_urls,

View File

@ -30,7 +30,7 @@ import {
emitEvent, emitEvent,
getPermission, getPermission,
getRights, getRights,
uploadFile, Config, getUrlSignature, uploadFile
} from "@spacebar/util"; } from "@spacebar/util";
import { Request, Response, Router } from "express"; import { Request, Response, Router } from "express";
import { HTTPError } from "lambert-server"; import { HTTPError } from "lambert-server";

View File

@ -35,8 +35,8 @@ import {
emitEvent, emitEvent,
getPermission, getPermission,
isTextChannel, isTextChannel,
resignUrl, getUrlSignature,
uploadFile, uploadFile, NewUrlSignatureData,
} from "@spacebar/util"; } from "@spacebar/util";
import { Request, Response, Router } from "express"; import { Request, Response, Router } from "express";
import { HTTPError } from "lambert-server"; import { HTTPError } from "lambert-server";
@ -210,8 +210,17 @@ router.get(
y.proxy_url = url.toString(); y.proxy_url = url.toString();
y.proxy_url = resignUrl(y.proxy_url, req); y.proxy_url = getUrlSignature(new NewUrlSignatureData({
y.url = resignUrl(y.url, req); url: y.proxy_url,
userAgent: req.headers["user-agent"],
ip: req.ip,
})).applyToUrl(y.proxy_url).toString();
y.url = getUrlSignature(new NewUrlSignatureData({
url: y.url,
userAgent: req.headers["user-agent"],
ip: req.ip,
})).applyToUrl(y.url).toString();
}); });
/** /**

View File

@ -18,9 +18,8 @@
import { import {
Config, Config,
getUrlSignature, hasValidSignature, NewUrlUserSignatureData,
hasValidSignature, Snowflake, UrlSignResult,
Snowflake,
} from "@spacebar/util"; } from "@spacebar/util";
import { Request, Response, Router } from "express"; import { Request, Response, Router } from "express";
import FileType from "file-type"; import FileType from "file-type";
@ -94,9 +93,16 @@ router.get(
const path = `attachments/${channel_id}/${id}/${filename}`; const path = `attachments/${channel_id}/${id}/${filename}`;
const fullUrl = (req.headers["x-forwarded-proto"] ?? req.protocol) + "://"
+ (req.headers["x-forwarded-host"] ?? req.hostname)
+ req.originalUrl;
if ( if (
Config.get().security.cdnSignUrls && Config.get().security.cdnSignUrls &&
!hasValidSignature(path, req.query, req) !hasValidSignature(new NewUrlUserSignatureData({
ip: req.ip,
userAgent: req.headers["user-agent"] as string,
}), UrlSignResult.fromUrl(fullUrl))
) { ) {
return res.status(404).send("This content is no longer available."); return res.status(404).send("This content is no longer available.");
} }

View File

@ -28,7 +28,6 @@ import { Message } from "./Message";
import { Deflate, Inflate } from "fast-zlib"; import { Deflate, Inflate } from "fast-zlib";
import { URL } from "url"; import { URL } from "url";
import { Config, ErlpackType } from "@spacebar/util"; import { Config, ErlpackType } from "@spacebar/util";
import { Request } from "express";
let erlpack: ErlpackType | null = null; let erlpack: ErlpackType | null = null;
try { try {
@ -48,11 +47,28 @@ export async function Connection(
) { ) {
const forwardedFor = Config.get().security.forwardedFor; const forwardedFor = Config.get().security.forwardedFor;
const ipAddress = forwardedFor const ipAddress = forwardedFor
? (request.headers[forwardedFor] as string) ? (request.headers[forwardedFor.toLowerCase()] as string)
: request.socket.remoteAddress; : request.socket.remoteAddress;
socket.ipAddress = ipAddress; socket.ipAddress = ipAddress;
socket.request = request as unknown as Request; socket.userAgent = request.headers["user-agent"] as string;
if (!ipAddress && Config.get().security.cdnSignatureIncludeIp) {
return socket.close(
CLOSECODES.Decode_error,
"Gateway connection rejected: IP address is required.",
);
}
if (
!socket.userAgent &&
Config.get().security.cdnSignatureIncludeUserAgent
) {
return socket.close(
CLOSECODES.Decode_error,
"Gateway connection rejected: User-Agent header is required.",
);
}
//Create session ID when the connection is opened. This allows gateway dump to group the initial websocket messages with the rest of the conversation. //Create session ID when the connection is opened. This allows gateway dump to group the initial websocket messages with the rest of the conversation.
const session_id = genSessionId(); const session_id = genSessionId();
@ -66,9 +82,9 @@ export async function Connection(
socket.on("error", (err) => console.error("[Gateway]", err)); socket.on("error", (err) => console.error("[Gateway]", err));
// console.log( console.log(
// `[Gateway] New connection from ${socket.ipAddress}, total ${this.clients.size}`, `[Gateway] New connection from ${ipAddress}, total ${this.clients.size}`,
// ); );
if (process.env.WS_LOGEVENTS) if (process.env.WS_LOGEVENTS)
[ [

View File

@ -27,7 +27,7 @@ import {
EVENTEnum, EVENTEnum,
Relationship, Relationship,
RelationshipType, RelationshipType,
Message, Message, NewUrlUserSignatureData,
} from "@spacebar/util"; } from "@spacebar/util";
import { OPCODES } from "../util/Constants"; import { OPCODES } from "../util/Constants";
import { Send } from "../util/Send"; import { Send } from "../util/Send";
@ -290,8 +290,12 @@ async function consume(this: WebSocket, opts: EventOpts) {
switch (event) { switch (event) {
case "MESSAGE_CREATE": case "MESSAGE_CREATE":
case "MESSAGE_UPDATE": case "MESSAGE_UPDATE":
// console.log(this.request)
if(data["attachments"]) if(data["attachments"])
data["attachments"] = Message.prototype.withSignedAttachments.call(data, this.request).attachments; data["attachments"] = Message.prototype.withSignedAttachments.call(data, new NewUrlUserSignatureData({
ip: this.ipAddress,
userAgent: this.userAgent
})).attachments;
break; break;
default: default:
break; break;

View File

@ -29,6 +29,7 @@ export interface WebSocket extends WS {
encoding: "etf" | "json"; encoding: "etf" | "json";
compress?: "zlib-stream"; compress?: "zlib-stream";
ipAddress?: string; ipAddress?: string;
userAgent?: string; // for cdn request signing
shard_count?: bigint; shard_count?: bigint;
shard_id?: bigint; shard_id?: bigint;
deflate?: Deflate; deflate?: Deflate;
@ -43,9 +44,4 @@ export interface WebSocket extends WS {
listen_options: ListenEventOpts; listen_options: ListenEventOpts;
capabilities?: Capabilities; capabilities?: Capabilities;
large_threshold: number; large_threshold: number;
/**
* The request object for the WebSocket connection.
* WARNING: This is not a proper Express Request object, it may be missing expected properties.
*/
request: Request; // For signed attachment URLs
} }

View File

@ -19,14 +19,105 @@
import { Config } from "@spacebar/util"; import { Config } from "@spacebar/util";
import { createHmac, timingSafeEqual } from "crypto"; import { createHmac, timingSafeEqual } from "crypto";
import ms, { StringValue } from "ms"; import ms, { StringValue } from "ms";
import { ParsedQs } from "qs"; import * as console from "node:console";
import { Request } from "express";
import attachments from "../cdn/routes/attachments";
export const getUrlSignature = ( export class NewUrlUserSignatureData {
path: string, ip?: string;
req: Request, userAgent?: string;
) => { constructor(data: NewUrlUserSignatureData) {
this.ip = data.ip;
this.userAgent = data.userAgent;
}
}
export class NewUrlSignatureData extends NewUrlUserSignatureData {
path?: string;
url?: string;
constructor(data: NewUrlSignatureData) {
super(data);
this.path = data.path;
this.url = data.url;
if (!this.path && !this.url) {
throw new Error("Either path or url must be provided for URL signing");
}
if (this.path && this.url) {
console.warn(
"[Signing] Both path and url are provided, using path for signing",
);
}
if (this.url) {
try {
const parsedUrl = new URL(this.url);
this.path = parsedUrl.pathname;
} catch (e) {
throw new Error("Invalid URL provided for signing: " + this.url);
}
}
}
}
export class UrlSignatureData extends NewUrlSignatureData {
issuedAt: string;
expiresAt: string;
constructor(data: UrlSignatureData) {
super(data);
this.issuedAt = data.issuedAt;
this.expiresAt = data.expiresAt;
}
}
export class UrlSignResult {
path: string;
hash: string;
issuedAt: string;
expiresAt: string;
/*
* @param data {UrlSignResult}
*/
constructor(data: any) {
for (const key in data) {
// @ts-ignore TS7053 - We dont care about string indexing a class
this[key] = data[key];
}
}
applyToUrl(url: URL | string): URL {
if (typeof url === "string") {
url = new URL(url);
}
url.searchParams.set("ex", this.expiresAt);
url.searchParams.set("is", this.issuedAt);
url.searchParams.set("hm", this.hash);
return url;
}
static fromUrl(url: URL | string): UrlSignResult {
if (typeof url === "string") {
console.debug("[Signing] Parsing URL from string:", url);
url = new URL(url);
}
console.debug("[Signing] Parsing URL from URL object:", url.toString());
const ex = url.searchParams.get("ex");
const is = url.searchParams.get("is");
const hm = url.searchParams.get("hm");
if (!ex || !is || !hm) {
throw new Error("Invalid URL signature parameters");
}
return new UrlSignResult({
path: url.pathname,
issuedAt: is,
expiresAt: ex,
hash: hm,
});
}
}
export const getUrlSignature = (data: NewUrlSignatureData): UrlSignResult => {
const { cdnSignatureKey, cdnSignatureDuration } = Config.get().security; const { cdnSignatureKey, cdnSignatureDuration } = Config.get().security;
// calculate the expiration time // calculate the expiration time
@ -37,135 +128,142 @@ export const getUrlSignature = (
); );
// hash the url with the cdnSignatureKey // hash the url with the cdnSignatureKey
const hash = calculateHash(path, issuedAt, expiresAt, req); return calculateHash(
new UrlSignatureData({
return { ...data,
hash, issuedAt,
issuedAt, expiresAt,
expiresAt, }),
}; );
}; };
export const calculateHash = ( function calculateHash(request: UrlSignatureData): UrlSignResult {
url: string,
issuedAt: string,
expiresAt: string,
req: Request,
) => {
const { cdnSignatureKey } = Config.get().security; const { cdnSignatureKey } = Config.get().security;
const data = createHmac("sha256", cdnSignatureKey as string) const data = createHmac("sha256", cdnSignatureKey as string)
.update(url) .update(request.path!)
.update(issuedAt) .update(request.issuedAt)
.update(expiresAt); .update(request.expiresAt);
if (Config.get().security.cdnSignatureIncludeIp) { if (Config.get().security.cdnSignatureIncludeIp) {
if (!req || !req.ip) if (!request.ip)
console.log( console.log(
"[Signing] CDN Signature IP is enabled but no request object was provided. This may cause issues with signature validation. Please report this to the Spacebar team!", "[Signing] CDN Signature IP is enabled but we couldn't find the IP field in the request. This may cause issues with signature validation. Please report this to the Spacebar team!",
); );
console.log("[Signing] CDN Signature IP is enabled, adding IP to hash:", req.ip); else {
data.update(req.ip!); console.log(
"[Signing] CDN Signature IP is enabled, adding IP to hash:",
request.ip,
);
data.update(request.ip!);
}
} }
if (Config.get().security.cdnSignatureIncludeUserAgent) { if (Config.get().security.cdnSignatureIncludeUserAgent) {
if (!req || !req.headers || !req.headers["user-agent"]) if (!request.userAgent)
console.log( console.log(
"[Signing] CDN Signature User-Agent is enabled but no request object was provided. This may cause issues with signature validation. Please report this to the Spacebar team!", "[Signing] CDN Signature User-Agent is enabled but we couldn't find the user-agent header in the request. This may cause issues with signature validation. Please report this to the Spacebar team!",
); );
data.update(req.headers["user-agent"] as string); else {
console.log(
"[Signing] CDN Signature User-Agent is enabled, adding User-Agent to hash:",
request.userAgent,
);
data.update(request.userAgent!);
}
} }
const hash = data.digest("hex"); const hash = data.digest("hex");
const result = new UrlSignResult({
path: request.path,
issuedAt: request.issuedAt,
expiresAt: request.expiresAt,
hash,
});
console.log("[Signing]", { console.log("[Signing]", {
url, path: request.path,
issuedAt, validity: request.issuedAt + " .. " + request.expiresAt,
expiresAt, ua: Config.get().security.cdnSignatureIncludeUserAgent ? request.userAgent : "[disabled]",
includeUA: Config.get().security.cdnSignatureIncludeUserAgent, ip: Config.get().security.cdnSignatureIncludeIp ? request.ip : "[disabled]"
ua: req.headers["user-agent"], }, "->", result);
includeIP: Config.get().security.cdnSignatureIncludeIp, return result;
ip: req.ip, }
}, "->", hash);
return hash;
};
export const isExpired = (ex: string, is: string) => { export const isExpired = (data: UrlSignResult | UrlSignatureData) => {
// convert issued at // convert issued at
const issuedAt = parseInt(is, 16); const issuedAt = parseInt(data.issuedAt, 16);
const expiresAt = parseInt(ex, 16); const expiresAt = parseInt(data.expiresAt, 16);
if (Number.isNaN(issuedAt) || Number.isNaN(expiresAt)) { if (Number.isNaN(issuedAt) || Number.isNaN(expiresAt)) {
// console.debug("Invalid timestamps in query"); console.debug("[Signing] Invalid timestamps in query");
return true; return true;
} }
const currentTime = Date.now(); const currentTime = Date.now();
const isExpired = expiresAt < currentTime; const isExpired = expiresAt < currentTime;
if (isExpired) {
console.debug("[Signing] Signature expired");
return true;
}
const isValidIssuedAt = issuedAt < currentTime; const isValidIssuedAt = issuedAt < currentTime;
if (isExpired || !isValidIssuedAt) { if (!isValidIssuedAt) {
// console.debug("Signature expired"); console.debug("[Signing] Signature issued in the future");
return true; return true;
} }
return false; return false;
}; };
export const hasValidSignature = (path: string, query: ParsedQs, req: Request) => { export const hasValidSignature = (req: NewUrlUserSignatureData, sig: UrlSignResult) => {
// get url path
const { ex, is, hm } = query;
// if the required query parameters are not present, return false // if the required query parameters are not present, return false
if (!ex || !is || !hm) { if (!sig.expiresAt || !sig.issuedAt || !sig.hash) {
console.debug("Missing required query parameters for signature validation"); console.warn(
"[Signing] Missing required query parameters for signature validation",
);
return false; return false;
} }
// check if the signature is expired // check if the signature is expired
if (isExpired(ex as string, is as string)) { if (isExpired(sig)) {
console.debug("Signature is expired"); console.warn("[Signing] Signature is expired");
return false; return false;
} }
const calcd = calculateHash(path, is as string, ex as string, req); const calcResult = calculateHash(new UrlSignatureData({
path: sig.path,
issuedAt: sig.issuedAt,
expiresAt: sig.expiresAt,
ip: req.ip,
userAgent: req.userAgent
}));
const calcd = calcResult.hash;
const calculated = Buffer.from(calcd); const calculated = Buffer.from(calcd);
const received = Buffer.from(hm as string); const received = Buffer.from(sig.hash as string);
console.assert(sig.issuedAt == calcResult.issuedAt, "[Signing] Mismatched issuedAt", {
is: sig.issuedAt,
calculated: calcResult.issuedAt,
});
console.assert(sig.expiresAt == calcResult.expiresAt, "[Signing] Mismatched expiresAt", {
ex: sig.expiresAt,
calculated: calcResult.expiresAt,
});
console.assert(calculated.length === received.length, "[Signing] Mismatched hash length", {
calculated: calculated.length,
received: received.length,
});
const isHashValid = const isHashValid =
calculated.length === received.length && calculated.length === received.length &&
timingSafeEqual(calculated, received); timingSafeEqual(calculated, received);
console.debug( if (!isHashValid)
`Signature validation for ${path} - isHashValid: ${isHashValid}, calculated: ${calcd}, received: ${hm}`, console.warn(
); `Signature validation for ${sig.path} (is=${sig.issuedAt}, ex=${sig.expiresAt}) failed: calculated: ${calcd}, received: ${sig.hash}`,
);
return isHashValid; return isHashValid;
}; };
export const resignUrl = (attachmentUrl: string, req: Request) => {
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, req);
url.searchParams.set("ex", expiresAt);
url.searchParams.set("is", issuedAt);
url.searchParams.set("hm", hash);
return url.toString();
};

View File

@ -28,8 +28,11 @@ import { URL } from "url";
import { deleteFile } from "../util/cdn"; import { deleteFile } from "../util/cdn";
import { BaseClass } from "./BaseClass"; import { BaseClass } from "./BaseClass";
import { dbEngine } from "../util/Database"; import { dbEngine } from "../util/Database";
import { Request } from "express"; import {
import { resignUrl } from "../Signing"; getUrlSignature,
NewUrlUserSignatureData,
NewUrlSignatureData,
} from "../Signing";
@Entity({ @Entity({
name: "attachments", name: "attachments",
@ -76,11 +79,15 @@ export class Attachment extends BaseClass {
return deleteFile(new URL(this.url).pathname); return deleteFile(new URL(this.url).pathname);
} }
signUrls(req: Request) { signUrls(data: NewUrlUserSignatureData): Attachment {
return { return {
...this, ...this,
url: resignUrl(this.url, req), url: getUrlSignature(
proxy_url: resignUrl(this.proxy_url, req), new NewUrlSignatureData({ ...data, url: this.url }),
} ).applyToUrl(this.url).toString(),
proxy_url: getUrlSignature(
new NewUrlSignatureData({ ...data, url: this.proxy_url }),
).applyToUrl(this.proxy_url).toString(),
};
} }
} }

View File

@ -40,7 +40,7 @@ import { Webhook } from "./Webhook";
import { Sticker } from "./Sticker"; import { Sticker } from "./Sticker";
import { Attachment } from "./Attachment"; import { Attachment } from "./Attachment";
import { dbEngine } from "../util/Database"; import { dbEngine } from "../util/Database";
import { Request } from "express"; import { NewUrlUserSignatureData } from "../Signing";
export enum MessageType { export enum MessageType {
DEFAULT = 0, DEFAULT = 0,
@ -262,11 +262,11 @@ export class Message extends BaseClass {
}; };
} }
withSignedAttachments(req: Request) { withSignedAttachments(data: NewUrlUserSignatureData) {
return { return {
...this, ...this,
attachments: this.attachments?.map((attachment: Attachment) => attachments: this.attachments?.map((attachment: Attachment) =>
Attachment.prototype.signUrls.call(attachment, req) Attachment.prototype.signUrls.call(attachment, data)
) )
}; };
} }