From e282a9183fcecfcf2a969cc9fe47927b3ccc7a32 Mon Sep 17 00:00:00 2001 From: wukko Date: Tue, 5 Mar 2024 18:14:26 +0600 Subject: [PATCH] stream: encrypt cached stream data & clean up related modules also limited CORS methods to GET and POST --- src/core/api.js | 26 ++++++++++---- src/modules/stream/manage.js | 69 +++++++++++++++++++++++------------- src/modules/sub/crypto.js | 25 +++++++++++-- src/modules/sub/utils.js | 1 - 4 files changed, 88 insertions(+), 33 deletions(-) diff --git a/src/core/api.js b/src/core/api.js index 5f910315..8b3dec0e 100644 --- a/src/core/api.js +++ b/src/core/api.js @@ -51,7 +51,11 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) { app.set('trust proxy', ['loopback', 'uniquelocal']); - app.use('/api/:type', cors(corsConfig)); + app.use('/api/:type', cors({ + methods: ['GET', 'POST'], + ...corsConfig + })); + app.use('/api/json', apiLimiter); app.use('/api/stream', apiLimiterStream); app.use('/api/onDemand', apiLimiter); @@ -60,6 +64,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) { try { decodeURIComponent(req.path) } catch (e) { return res.redirect('/') } next(); }); + app.use('/api/json', express.json({ verify: (req, res, buf) => { let acceptCon = String(req.header('Accept')) === "application/json"; @@ -71,6 +76,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) { } } })); + // handle express.json errors properly (https://github.com/expressjs/express/issues/4065) app.use('/api/json', (err, req, res, next) => { let errorText = "invalid json body"; @@ -86,6 +92,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) { next(); } }); + app.post('/api/json', async (req, res) => { try { let lang = languageCode(req); @@ -118,13 +125,17 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) { try { switch (req.params.type) { case 'stream': - if (req.query.t && req.query.h && req.query.e && req.query.t.toString().length === 21 - && req.query.h.toString().length === 64 && req.query.e.toString().length === 13) { - let streamInfo = verifyStream(req.query.t, req.query.h, req.query.e); + const q = req.query; + const checkQueries = q.t && q.e && q.h && q.s && q.i; + const checkBaseLength = q.t.length === 21 && q.e.length === 13; + const checkSafeLength = q.h.length === 44 && q.s.length === 344 && q.i.length === 24; + + if (checkQueries && checkBaseLength && checkSafeLength) { + let streamInfo = verifyStream(q.t, q.h, q.e, q.s, q.i); if (streamInfo.error) { return res.status(streamInfo.status).json(apiJSON(0, { t: streamInfo.error }).body); } - if (req.query.p) { + if (q.p) { return res.status(200).json({ status: "continue" }); @@ -132,7 +143,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) { return stream(res, streamInfo); } else { let j = apiJSON(0, { - t: "stream token, hmac, or expiry timestamp is missing" + t: "bad request. stream link may be incomplete or corrupted." }) return res.status(j.status).json(j.body); } @@ -159,12 +170,15 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) { }); } }); + app.get('/api/status', (req, res) => { res.status(200).end() }); + app.get('/favicon.ico', (req, res) => { res.sendFile(`${__dirname}/src/front/icons/favicon.ico`) }); + app.get('/*', (req, res) => { res.redirect('/api/json') }); diff --git a/src/modules/stream/manage.js b/src/modules/stream/manage.js index 459e5a6b..b034268f 100644 --- a/src/modules/stream/manage.js +++ b/src/modules/stream/manage.js @@ -2,7 +2,7 @@ import NodeCache from "node-cache"; import { randomBytes } from "crypto"; import { nanoid } from 'nanoid'; -import { sha256 } from "../sub/crypto.js"; +import { decryptStream, encryptStream, sha256 } from "../sub/crypto.js"; import { streamLifespan } from "../config.js"; const streamCache = new NodeCache({ @@ -15,48 +15,69 @@ streamCache.on("expired", (key) => { streamCache.del(key); }) -const streamSalt = randomBytes(64).toString('hex'); +const hmacSalt = randomBytes(64).toString('hex'); export function createStream(obj) { - let streamID = nanoid(), + const streamID = nanoid(), + iv = randomBytes(16).toString('base64'), + secret = randomBytes(256).toString('base64'), exp = Math.floor(new Date().getTime()) + streamLifespan, - ghmac = sha256(`${streamID},${obj.service},${exp}`, streamSalt); - - if (!streamCache.has(streamID)) { - streamCache.set(streamID, { - id: streamID, + hmac = sha256(`${streamID},${exp},${iv},${secret}`, hmacSalt), + streamData = { service: obj.service, type: obj.type, urls: obj.u, filename: obj.filename, - hmac: ghmac, exp: exp, - isAudioOnly: !!obj.isAudioOnly, audioFormat: obj.audioFormat, - time: obj.time ? obj.time : false, + isAudioOnly: !!obj.isAudioOnly, + time: obj.time || false, copy: !!obj.copy, mute: !!obj.mute, - metadata: obj.fileMetadata ? obj.fileMetadata : false - }); - } else { - let streamInfo = streamCache.get(streamID); - exp = streamInfo.exp; - ghmac = streamInfo.hmac; + metadata: obj.fileMetadata || false + }; + + streamCache.set( + streamID, + encryptStream(streamData, iv, secret) + ) + + let streamLink = new URL('/api/stream', process.env.apiURL); + + const params = { + 't': streamID, + 'e': exp, + 'h': hmac, + 's': secret, + 'i': iv } - return `${process.env.apiURL}api/stream?t=${streamID}&e=${exp}&h=${ghmac}`; + + for (const [key, value] of Object.entries(params)) { + streamLink.searchParams.append(key, value); + } + + return streamLink.toString(); } -export function verifyStream(id, hmac, exp) { +export function verifyStream(id, hmac, exp, secret, iv) { try { - let streamInfo = streamCache.get(id.toString()); + const ghmac = sha256(`${id},${exp},${iv},${secret}`, hmacSalt); + + if (ghmac !== String(hmac)) { + return { + error: "i couldn't verify if you have access to this stream. go back and try again!", + status: 401 + } + } + + const streamInfo = JSON.parse(decryptStream(streamCache.get(id.toString()), iv, secret)); + if (!streamInfo) return { error: "this download link has expired or doesn't exist. go back and try again!", status: 400 } - let ghmac = sha256(`${id},${streamInfo.service},${exp}`, streamSalt); - if (String(hmac) === ghmac && String(exp) === String(streamInfo.exp) && ghmac === String(streamInfo.hmac) - && Number(exp) > Math.floor(new Date().getTime())) { + if (String(exp) === String(streamInfo.exp) && Number(exp) > Math.floor(new Date().getTime())) { return streamInfo; } return { @@ -64,6 +85,6 @@ export function verifyStream(id, hmac, exp) { status: 401 } } catch (e) { - return { status: 500, body: { status: "error", text: "Internal Server Error" } }; + return { status: 500, body: { status: "error", text: "couldn't verify this stream. request a new one!" } }; } } diff --git a/src/modules/sub/crypto.js b/src/modules/sub/crypto.js index e8bf2f94..70484f4f 100644 --- a/src/modules/sub/crypto.js +++ b/src/modules/sub/crypto.js @@ -1,5 +1,26 @@ -import { createHmac } from "crypto"; +import { createHmac, createCipheriv, createDecipheriv, scryptSync } from "crypto"; + +const algorithm = "aes256" +const keyLength = 32; export function sha256(str, salt) { - return createHmac("sha256", salt).update(str).digest("hex"); + return createHmac("sha256", salt).update(str).digest("base64"); +} + +export function encryptStream(str, iv, secret) { + const buff = Buffer.from(JSON.stringify(str), "utf-8"); + + const key = scryptSync(Buffer.from(secret, "base64"), "salt", keyLength); + const cipher = createCipheriv(algorithm, key, Buffer.from(iv, "base64")); + + return Buffer.from(cipher.update(buff, "utf8", "binary") + cipher.final("binary"), "binary"); +} + +export function decryptStream(buf, iv, secret) { + const buff = Buffer.from(buf, "binary"); + + const key = scryptSync(Buffer.from(secret, "base64"), "salt", keyLength); + const decipher = createDecipheriv(algorithm, key, Buffer.from(iv, "base64")); + + return decipher.update(buff, "binary", "utf8") + decipher.final("utf8"); } diff --git a/src/modules/sub/utils.js b/src/modules/sub/utils.js index 8f8678d3..bb21bbb4 100644 --- a/src/modules/sub/utils.js +++ b/src/modules/sub/utils.js @@ -11,7 +11,6 @@ const apiVar = { }, booleanOnly: ["isAudioOnly", "isNoTTWatermark", "isTTFullAudio", "isAudioMuted", "dubLang", "vimeoDash", "disableMetadata", "twitterGif"] } -const forbiddenChars = ['}', '{', '(', ')', '\\', '>', '<', '^', '*', '!', '~', ';', ':', ',', '`', '[', ']', '#', '$', '"', "'", "@", '==']; const forbiddenCharsString = ['}', '{', '%', '>', '<', '^', ';', '`', '$', '"', "@", '=']; export function apiJSON(type, obj) {