stream: encrypt cached stream data & clean up related modules

also limited CORS methods to GET and POST
This commit is contained in:
wukko 2024-03-05 18:14:26 +06:00
parent 8d8b04dd1f
commit e282a9183f
4 changed files with 88 additions and 33 deletions

View file

@ -51,7 +51,11 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
app.set('trust proxy', ['loopback', 'uniquelocal']); 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/json', apiLimiter);
app.use('/api/stream', apiLimiterStream); app.use('/api/stream', apiLimiterStream);
app.use('/api/onDemand', apiLimiter); 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('/') } try { decodeURIComponent(req.path) } catch (e) { return res.redirect('/') }
next(); next();
}); });
app.use('/api/json', express.json({ app.use('/api/json', express.json({
verify: (req, res, buf) => { verify: (req, res, buf) => {
let acceptCon = String(req.header('Accept')) === "application/json"; 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) // handle express.json errors properly (https://github.com/expressjs/express/issues/4065)
app.use('/api/json', (err, req, res, next) => { app.use('/api/json', (err, req, res, next) => {
let errorText = "invalid json body"; let errorText = "invalid json body";
@ -86,6 +92,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
next(); next();
} }
}); });
app.post('/api/json', async (req, res) => { app.post('/api/json', async (req, res) => {
try { try {
let lang = languageCode(req); let lang = languageCode(req);
@ -118,13 +125,17 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
try { try {
switch (req.params.type) { switch (req.params.type) {
case 'stream': case 'stream':
if (req.query.t && req.query.h && req.query.e && req.query.t.toString().length === 21 const q = req.query;
&& req.query.h.toString().length === 64 && req.query.e.toString().length === 13) { const checkQueries = q.t && q.e && q.h && q.s && q.i;
let streamInfo = verifyStream(req.query.t, req.query.h, req.query.e); 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) { if (streamInfo.error) {
return res.status(streamInfo.status).json(apiJSON(0, { t: streamInfo.error }).body); return res.status(streamInfo.status).json(apiJSON(0, { t: streamInfo.error }).body);
} }
if (req.query.p) { if (q.p) {
return res.status(200).json({ return res.status(200).json({
status: "continue" status: "continue"
}); });
@ -132,7 +143,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
return stream(res, streamInfo); return stream(res, streamInfo);
} else { } else {
let j = apiJSON(0, { 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); 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) => { app.get('/api/status', (req, res) => {
res.status(200).end() res.status(200).end()
}); });
app.get('/favicon.ico', (req, res) => { app.get('/favicon.ico', (req, res) => {
res.sendFile(`${__dirname}/src/front/icons/favicon.ico`) res.sendFile(`${__dirname}/src/front/icons/favicon.ico`)
}); });
app.get('/*', (req, res) => { app.get('/*', (req, res) => {
res.redirect('/api/json') res.redirect('/api/json')
}); });

View file

@ -2,7 +2,7 @@ import NodeCache from "node-cache";
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { sha256 } from "../sub/crypto.js"; import { decryptStream, encryptStream, sha256 } from "../sub/crypto.js";
import { streamLifespan } from "../config.js"; import { streamLifespan } from "../config.js";
const streamCache = new NodeCache({ const streamCache = new NodeCache({
@ -15,48 +15,69 @@ streamCache.on("expired", (key) => {
streamCache.del(key); streamCache.del(key);
}) })
const streamSalt = randomBytes(64).toString('hex'); const hmacSalt = randomBytes(64).toString('hex');
export function createStream(obj) { 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, exp = Math.floor(new Date().getTime()) + streamLifespan,
ghmac = sha256(`${streamID},${obj.service},${exp}`, streamSalt); hmac = sha256(`${streamID},${exp},${iv},${secret}`, hmacSalt),
streamData = {
if (!streamCache.has(streamID)) {
streamCache.set(streamID, {
id: streamID,
service: obj.service, service: obj.service,
type: obj.type, type: obj.type,
urls: obj.u, urls: obj.u,
filename: obj.filename, filename: obj.filename,
hmac: ghmac,
exp: exp, exp: exp,
isAudioOnly: !!obj.isAudioOnly,
audioFormat: obj.audioFormat, audioFormat: obj.audioFormat,
time: obj.time ? obj.time : false, isAudioOnly: !!obj.isAudioOnly,
time: obj.time || false,
copy: !!obj.copy, copy: !!obj.copy,
mute: !!obj.mute, mute: !!obj.mute,
metadata: obj.fileMetadata ? obj.fileMetadata : false metadata: obj.fileMetadata || false
}); };
} else {
let streamInfo = streamCache.get(streamID); streamCache.set(
exp = streamInfo.exp; streamID,
ghmac = streamInfo.hmac; encryptStream(streamData, iv, secret)
} )
return `${process.env.apiURL}api/stream?t=${streamID}&e=${exp}&h=${ghmac}`;
let streamLink = new URL('/api/stream', process.env.apiURL);
const params = {
't': streamID,
'e': exp,
'h': hmac,
's': secret,
'i': iv
} }
export function verifyStream(id, hmac, exp) { for (const [key, value] of Object.entries(params)) {
streamLink.searchParams.append(key, value);
}
return streamLink.toString();
}
export function verifyStream(id, hmac, exp, secret, iv) {
try { 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 { if (!streamInfo) return {
error: "this download link has expired or doesn't exist. go back and try again!", error: "this download link has expired or doesn't exist. go back and try again!",
status: 400 status: 400
} }
let ghmac = sha256(`${id},${streamInfo.service},${exp}`, streamSalt); if (String(exp) === String(streamInfo.exp) && Number(exp) > Math.floor(new Date().getTime())) {
if (String(hmac) === ghmac && String(exp) === String(streamInfo.exp) && ghmac === String(streamInfo.hmac)
&& Number(exp) > Math.floor(new Date().getTime())) {
return streamInfo; return streamInfo;
} }
return { return {
@ -64,6 +85,6 @@ export function verifyStream(id, hmac, exp) {
status: 401 status: 401
} }
} catch (e) { } 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!" } };
} }
} }

View file

@ -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) { 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");
} }

View file

@ -11,7 +11,6 @@ const apiVar = {
}, },
booleanOnly: ["isAudioOnly", "isNoTTWatermark", "isTTFullAudio", "isAudioMuted", "dubLang", "vimeoDash", "disableMetadata", "twitterGif"] booleanOnly: ["isAudioOnly", "isNoTTWatermark", "isTTFullAudio", "isAudioMuted", "dubLang", "vimeoDash", "disableMetadata", "twitterGif"]
} }
const forbiddenChars = ['}', '{', '(', ')', '\\', '>', '<', '^', '*', '!', '~', ';', ':', ',', '`', '[', ']', '#', '$', '"', "'", "@", '=='];
const forbiddenCharsString = ['}', '{', '%', '>', '<', '^', ';', '`', '$', '"', "@", '=']; const forbiddenCharsString = ['}', '{', '%', '>', '<', '^', ';', '`', '$', '"', "@", '='];
export function apiJSON(type, obj) { export function apiJSON(type, obj) {