Make IP/user-agent validation in CDN work
This commit is contained in:
parent
8bf45dcdc1
commit
2e196573ea
@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
import { route } from "@spacebar/api";
|
||||
import { RefreshUrlsRequestSchema, resignUrl } from "@spacebar/util";
|
||||
import { RefreshUrlsRequestSchema, getUrlSignature, NewUrlSignatureData } from "@spacebar/util";
|
||||
import { Request, Response, Router } from "express";
|
||||
const router = Router();
|
||||
|
||||
@ -37,7 +37,13 @@ router.post(
|
||||
async (req: Request, res: Response) => {
|
||||
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({
|
||||
refreshed_urls,
|
||||
|
||||
@ -30,7 +30,7 @@ import {
|
||||
emitEvent,
|
||||
getPermission,
|
||||
getRights,
|
||||
uploadFile, Config, getUrlSignature,
|
||||
uploadFile
|
||||
} from "@spacebar/util";
|
||||
import { Request, Response, Router } from "express";
|
||||
import { HTTPError } from "lambert-server";
|
||||
|
||||
@ -35,8 +35,8 @@ import {
|
||||
emitEvent,
|
||||
getPermission,
|
||||
isTextChannel,
|
||||
resignUrl,
|
||||
uploadFile,
|
||||
getUrlSignature,
|
||||
uploadFile, NewUrlSignatureData,
|
||||
} from "@spacebar/util";
|
||||
import { Request, Response, Router } from "express";
|
||||
import { HTTPError } from "lambert-server";
|
||||
@ -210,8 +210,17 @@ router.get(
|
||||
|
||||
y.proxy_url = url.toString();
|
||||
|
||||
y.proxy_url = resignUrl(y.proxy_url, req);
|
||||
y.url = resignUrl(y.url, req);
|
||||
y.proxy_url = getUrlSignature(new NewUrlSignatureData({
|
||||
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();
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@ -18,9 +18,8 @@
|
||||
|
||||
import {
|
||||
Config,
|
||||
getUrlSignature,
|
||||
hasValidSignature,
|
||||
Snowflake,
|
||||
hasValidSignature, NewUrlUserSignatureData,
|
||||
Snowflake, UrlSignResult,
|
||||
} from "@spacebar/util";
|
||||
import { Request, Response, Router } from "express";
|
||||
import FileType from "file-type";
|
||||
@ -94,9 +93,16 @@ router.get(
|
||||
|
||||
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 (
|
||||
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.");
|
||||
}
|
||||
|
||||
@ -28,7 +28,6 @@ import { Message } from "./Message";
|
||||
import { Deflate, Inflate } from "fast-zlib";
|
||||
import { URL } from "url";
|
||||
import { Config, ErlpackType } from "@spacebar/util";
|
||||
import { Request } from "express";
|
||||
|
||||
let erlpack: ErlpackType | null = null;
|
||||
try {
|
||||
@ -48,11 +47,28 @@ export async function Connection(
|
||||
) {
|
||||
const forwardedFor = Config.get().security.forwardedFor;
|
||||
const ipAddress = forwardedFor
|
||||
? (request.headers[forwardedFor] as string)
|
||||
? (request.headers[forwardedFor.toLowerCase()] as string)
|
||||
: request.socket.remoteAddress;
|
||||
|
||||
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.
|
||||
const session_id = genSessionId();
|
||||
@ -66,9 +82,9 @@ export async function Connection(
|
||||
|
||||
socket.on("error", (err) => console.error("[Gateway]", err));
|
||||
|
||||
// console.log(
|
||||
// `[Gateway] New connection from ${socket.ipAddress}, total ${this.clients.size}`,
|
||||
// );
|
||||
console.log(
|
||||
`[Gateway] New connection from ${ipAddress}, total ${this.clients.size}`,
|
||||
);
|
||||
|
||||
if (process.env.WS_LOGEVENTS)
|
||||
[
|
||||
|
||||
@ -27,7 +27,7 @@ import {
|
||||
EVENTEnum,
|
||||
Relationship,
|
||||
RelationshipType,
|
||||
Message,
|
||||
Message, NewUrlUserSignatureData,
|
||||
} from "@spacebar/util";
|
||||
import { OPCODES } from "../util/Constants";
|
||||
import { Send } from "../util/Send";
|
||||
@ -290,8 +290,12 @@ async function consume(this: WebSocket, opts: EventOpts) {
|
||||
switch (event) {
|
||||
case "MESSAGE_CREATE":
|
||||
case "MESSAGE_UPDATE":
|
||||
// console.log(this.request)
|
||||
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;
|
||||
default:
|
||||
break;
|
||||
|
||||
@ -29,6 +29,7 @@ export interface WebSocket extends WS {
|
||||
encoding: "etf" | "json";
|
||||
compress?: "zlib-stream";
|
||||
ipAddress?: string;
|
||||
userAgent?: string; // for cdn request signing
|
||||
shard_count?: bigint;
|
||||
shard_id?: bigint;
|
||||
deflate?: Deflate;
|
||||
@ -43,9 +44,4 @@ export interface WebSocket extends WS {
|
||||
listen_options: ListenEventOpts;
|
||||
capabilities?: Capabilities;
|
||||
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
|
||||
}
|
||||
|
||||
@ -19,14 +19,105 @@
|
||||
import { Config } from "@spacebar/util";
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
import ms, { StringValue } from "ms";
|
||||
import { ParsedQs } from "qs";
|
||||
import { Request } from "express";
|
||||
import attachments from "../cdn/routes/attachments";
|
||||
import * as console from "node:console";
|
||||
|
||||
export const getUrlSignature = (
|
||||
path: string,
|
||||
req: Request,
|
||||
) => {
|
||||
export class NewUrlUserSignatureData {
|
||||
ip?: string;
|
||||
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;
|
||||
|
||||
// calculate the expiration time
|
||||
@ -37,135 +128,142 @@ export const getUrlSignature = (
|
||||
);
|
||||
|
||||
// hash the url with the cdnSignatureKey
|
||||
const hash = calculateHash(path, issuedAt, expiresAt, req);
|
||||
|
||||
return {
|
||||
hash,
|
||||
issuedAt,
|
||||
expiresAt,
|
||||
};
|
||||
return calculateHash(
|
||||
new UrlSignatureData({
|
||||
...data,
|
||||
issuedAt,
|
||||
expiresAt,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const calculateHash = (
|
||||
url: string,
|
||||
issuedAt: string,
|
||||
expiresAt: string,
|
||||
req: Request,
|
||||
) => {
|
||||
function calculateHash(request: UrlSignatureData): UrlSignResult {
|
||||
const { cdnSignatureKey } = Config.get().security;
|
||||
const data = createHmac("sha256", cdnSignatureKey as string)
|
||||
.update(url)
|
||||
.update(issuedAt)
|
||||
.update(expiresAt);
|
||||
.update(request.path!)
|
||||
.update(request.issuedAt)
|
||||
.update(request.expiresAt);
|
||||
|
||||
if (Config.get().security.cdnSignatureIncludeIp) {
|
||||
if (!req || !req.ip)
|
||||
if (!request.ip)
|
||||
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);
|
||||
data.update(req.ip!);
|
||||
else {
|
||||
console.log(
|
||||
"[Signing] CDN Signature IP is enabled, adding IP to hash:",
|
||||
request.ip,
|
||||
);
|
||||
data.update(request.ip!);
|
||||
}
|
||||
}
|
||||
|
||||
if (Config.get().security.cdnSignatureIncludeUserAgent) {
|
||||
if (!req || !req.headers || !req.headers["user-agent"])
|
||||
if (!request.userAgent)
|
||||
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 result = new UrlSignResult({
|
||||
path: request.path,
|
||||
issuedAt: request.issuedAt,
|
||||
expiresAt: request.expiresAt,
|
||||
hash,
|
||||
});
|
||||
console.log("[Signing]", {
|
||||
url,
|
||||
issuedAt,
|
||||
expiresAt,
|
||||
includeUA: Config.get().security.cdnSignatureIncludeUserAgent,
|
||||
ua: req.headers["user-agent"],
|
||||
includeIP: Config.get().security.cdnSignatureIncludeIp,
|
||||
ip: req.ip,
|
||||
}, "->", hash);
|
||||
return hash;
|
||||
};
|
||||
path: request.path,
|
||||
validity: request.issuedAt + " .. " + request.expiresAt,
|
||||
ua: Config.get().security.cdnSignatureIncludeUserAgent ? request.userAgent : "[disabled]",
|
||||
ip: Config.get().security.cdnSignatureIncludeIp ? request.ip : "[disabled]"
|
||||
}, "->", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export const isExpired = (ex: string, is: string) => {
|
||||
export const isExpired = (data: UrlSignResult | UrlSignatureData) => {
|
||||
// convert issued at
|
||||
const issuedAt = parseInt(is, 16);
|
||||
const expiresAt = parseInt(ex, 16);
|
||||
const issuedAt = parseInt(data.issuedAt, 16);
|
||||
const expiresAt = parseInt(data.expiresAt, 16);
|
||||
|
||||
if (Number.isNaN(issuedAt) || Number.isNaN(expiresAt)) {
|
||||
// console.debug("Invalid timestamps in query");
|
||||
console.debug("[Signing] Invalid timestamps in query");
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentTime = Date.now();
|
||||
|
||||
const isExpired = expiresAt < currentTime;
|
||||
if (isExpired) {
|
||||
console.debug("[Signing] Signature expired");
|
||||
return true;
|
||||
}
|
||||
|
||||
const isValidIssuedAt = issuedAt < currentTime;
|
||||
if (isExpired || !isValidIssuedAt) {
|
||||
// console.debug("Signature expired");
|
||||
if (!isValidIssuedAt) {
|
||||
console.debug("[Signing] Signature issued in the future");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const hasValidSignature = (path: string, query: ParsedQs, req: Request) => {
|
||||
// get url path
|
||||
const { ex, is, hm } = query;
|
||||
|
||||
export const hasValidSignature = (req: NewUrlUserSignatureData, sig: UrlSignResult) => {
|
||||
// if the required query parameters are not present, return false
|
||||
if (!ex || !is || !hm) {
|
||||
console.debug("Missing required query parameters for signature validation");
|
||||
if (!sig.expiresAt || !sig.issuedAt || !sig.hash) {
|
||||
console.warn(
|
||||
"[Signing] Missing required query parameters for signature validation",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if the signature is expired
|
||||
if (isExpired(ex as string, is as string)) {
|
||||
console.debug("Signature is expired");
|
||||
if (isExpired(sig)) {
|
||||
console.warn("[Signing] Signature is expired");
|
||||
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 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 =
|
||||
calculated.length === received.length &&
|
||||
timingSafeEqual(calculated, received);
|
||||
|
||||
console.debug(
|
||||
`Signature validation for ${path} - isHashValid: ${isHashValid}, calculated: ${calcd}, received: ${hm}`,
|
||||
);
|
||||
if (!isHashValid)
|
||||
console.warn(
|
||||
`Signature validation for ${sig.path} (is=${sig.issuedAt}, ex=${sig.expiresAt}) failed: calculated: ${calcd}, received: ${sig.hash}`,
|
||||
);
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
@ -28,8 +28,11 @@ import { URL } from "url";
|
||||
import { deleteFile } from "../util/cdn";
|
||||
import { BaseClass } from "./BaseClass";
|
||||
import { dbEngine } from "../util/Database";
|
||||
import { Request } from "express";
|
||||
import { resignUrl } from "../Signing";
|
||||
import {
|
||||
getUrlSignature,
|
||||
NewUrlUserSignatureData,
|
||||
NewUrlSignatureData,
|
||||
} from "../Signing";
|
||||
|
||||
@Entity({
|
||||
name: "attachments",
|
||||
@ -76,11 +79,15 @@ export class Attachment extends BaseClass {
|
||||
return deleteFile(new URL(this.url).pathname);
|
||||
}
|
||||
|
||||
signUrls(req: Request) {
|
||||
signUrls(data: NewUrlUserSignatureData): Attachment {
|
||||
return {
|
||||
...this,
|
||||
url: resignUrl(this.url, req),
|
||||
proxy_url: resignUrl(this.proxy_url, req),
|
||||
}
|
||||
url: getUrlSignature(
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,7 +40,7 @@ import { Webhook } from "./Webhook";
|
||||
import { Sticker } from "./Sticker";
|
||||
import { Attachment } from "./Attachment";
|
||||
import { dbEngine } from "../util/Database";
|
||||
import { Request } from "express";
|
||||
import { NewUrlUserSignatureData } from "../Signing";
|
||||
|
||||
export enum MessageType {
|
||||
DEFAULT = 0,
|
||||
@ -262,11 +262,11 @@ export class Message extends BaseClass {
|
||||
};
|
||||
}
|
||||
|
||||
withSignedAttachments(req: Request) {
|
||||
withSignedAttachments(data: NewUrlUserSignatureData) {
|
||||
return {
|
||||
...this,
|
||||
attachments: this.attachments?.map((attachment: Attachment) =>
|
||||
Attachment.prototype.signUrls.call(attachment, req)
|
||||
Attachment.prototype.signUrls.call(attachment, data)
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user