mikupad/server/server.js
Vasilii Ermilov 911db4c6a2
Add whitelist to normalizeStoreName to avoid injection
This PR introduces a whitelist check to mitigate SQL injection risks when handling user input. Previously, user-controlled input was being used directly in dynamic SQL queries, which could allow an attacker to manipulate database queries.
E.g. https://github.com/lmg-anon/mikupad/blob/HEAD/server/server.js#L285
2025-02-27 11:29:47 -05:00

333 lines
10 KiB
JavaScript

const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');
const sqlite3 = require('sqlite3');
const path = require('path');
const minimist = require('minimist');
const axios = require('axios');
const open = require('open');
const app = express();
// Parse command line arguments
const args = minimist(process.argv.slice(2));
// Default fallbacks: command line args -> environment variables -> static defaults
const port = args.port || process.env.MIKUPAD_PORT || 3000;
const host = args.host || process.env.MIKUPAD_HOST || '0.0.0.0';
const noOpen = (args.open !== undefined && !args.open) || process.env.MIKUPAD_NO_OPEN;
const login = args.login || process.env.MIKUPAD_LOGIN || 'anon';
const password = args.password || process.env.MIKUPAD_PASSWORD || undefined;
// Headers that shouldn't be forwarded in the proxy endpoint.
const headersToRemove = [
'content-length',
'cdn-loop',
'cf-connecting-ip',
'cf-ipcountry',
'cf-ray',
'cf-visitor',
'x-forwarded-for',
'x-forwarded-host',
'x-forwarded-proto'
];
app.use(cors(), bodyParser.json({limit: "100mb"}));
// authentication middleware
app.use((req, res, next) => {
if (!password) {
// No password defined, access granted.
return next();
}
const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
const [reqLogin, reqPassword] = Buffer.from(b64auth, 'base64').toString().split(':');
if (reqLogin == login && reqPassword == password) {
// Access granted.
return next();
}
// Access denied.
res.set('WWW-Authenticate', 'Basic realm="401"');
res.status(401).send('Authentication required.');
});
// Open a database connection
const db = new sqlite3.Database('./web-session-storage.db', (err) => {
if (err) {
console.error(err.message);
throw err;
} else {
db.run(`CREATE TABLE IF NOT EXISTS sessions (
key TEXT PRIMARY KEY,
data TEXT
)`);
db.run(`CREATE TABLE IF NOT EXISTS templates (
key TEXT PRIMARY KEY,
data TEXT
)`);
}
});
// GET route to serve Mikupad html
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'mikupad.html'));
});
// GET route to get the server version
app.get('/version', (req, res) => {
res.json({ version: 2 });
});
// Dynamic POST proxy route
app.post('/proxy/*', async (req, res) => {
// Capture the part of the URL after '/proxy'
const path = req.params[0];
// Target server base URL
const targetBaseUrl = req.headers['x-real-url'];
delete req.headers['x-real-url'];
headersToRemove.forEach(header => {
delete req.headers[header.toLowerCase()];
});
try {
const response = await axios({
method: 'post',
url: `${targetBaseUrl}/${path}`,
data: req.body,
headers: {
...req.headers,
'Content-Type': 'application/json',
'Host': new URL(targetBaseUrl).hostname, // Update the Host header for the target server
'Accept-Encoding': 'identity'
},
responseType: 'stream'
});
// Proxy the headers
res.set(response.headers);
// Proxy stream requests
response.data.pipe(res);
// Stop stream requests if the connection is aborted on the other end
res.on('close', () => {
response.data.destroy();
});
} catch (error) {
if (error.response) {
if (error.response.data.pipe !== undefined) {
error.response.data.pipe(res.status(error.response.status));
} else {
res.status(error.response.status).send(error.response.data);
}
} else if (error.request) {
res.status(504).send('No response from target server.');
} else {
res.status(500).send(`Error setting up request to target server: ${error.message}`);
}
}
});
// Dynamic GET proxy route
app.get('/proxy/*', async (req, res) => {
// Capture the part of the URL after '/proxy'
const path = req.params[0];
// Target server base URL
const targetBaseUrl = req.headers['x-real-url'];
delete req.headers['x-real-url'];
headersToRemove.forEach(header => {
delete req.headers[header.toLowerCase()];
});
try {
const response = await axios.get(`${targetBaseUrl}/${path}`, {
params: req.query,
headers: {
...req.headers,
'Content-Type': 'application/json',
'Host': new URL(targetBaseUrl).hostname, // Update the Host header for the target server
'Accept-Encoding': 'identity'
}
});
res.send(response.data);
} catch (error) {
if (error.response) {
res.status(error.response.status).send(error.response.data);
} else if (error.request) {
res.status(504).send('No response from target server.');
} else {
res.status(500).send(`Error setting up request to target server: ${error.message}`);
}
}
});
// Dynamic DELETE proxy route
app.delete('/proxy/*', async (req, res) => {
// Capture the part of the URL after '/proxy'
const path = req.params[0];
// Target server base URL
const targetBaseUrl = req.headers['x-real-url'];
delete req.headers['x-real-url'];
headersToRemove.forEach(header => {
delete req.headers[header.toLowerCase()];
});
try {
const response = await axios.delete(`${targetBaseUrl}/${path}`, {
headers: {
...req.headers,
'Content-Type': 'application/json',
'Host': new URL(targetBaseUrl).hostname, // Update the Host header for the target server
'Accept-Encoding': 'identity'
}
});
res.send(response.data);
} catch (error) {
if (error.response) {
res.status(error.response.status).send(error.response.data);
} else if (error.request) {
res.status(504).send('No response from target server.');
} else {
res.status(500).send(`Error setting up request to target server: ${error.message}`);
}
}
});
const normalizeStoreName = (storeName) => {
if (!storeName)
return "Sessions";
const normalizedStoreName = storeName.split(' ')[0].toLowerCase();
if (["sessions", "templates"].includes(normalizedStoreName)) {
return normalizedStoreName;
}
return "Sessions";
};
// POST route to load data
app.post('/load', (req, res) => {
const { storeName, key } = req.body;
const normStoreName = normalizeStoreName(storeName);
db.get(`SELECT data FROM ${normStoreName} WHERE key = ?`, [key], (err, row) => {
if (err) {
res.status(500).json({ ok: false, message: 'Error querying the database' });
} else if (row) {
res.json({ ok: true, result: JSON.parse(row.data) });
} else {
res.status(404).json({ ok: false, message: 'Key not found' });
}
});
});
// POST route to save data
app.post('/save', (req, res) => {
const { storeName, key, data } = req.body;
const normStoreName = normalizeStoreName(storeName);
db.run(`INSERT OR REPLACE INTO ${normStoreName} (key, data) VALUES (?, ?)`, [key, JSON.stringify(data)], (err) => {
if (err) {
res.status(500).json({ ok: false, message: 'Error writing to the database' });
} else {
res.json({ ok: true, result: 'Data saved successfully' });
}
});
});
// POST route to update session name
app.post('/rename', (req, res) => {
const { storeName, key, newName } = req.body;
const normStoreName = normalizeStoreName(storeName);
db.run(
`
UPDATE ${normStoreName}
SET data = json_set(data, '$.name', ?)
WHERE key = ?
`,
[newName, key],
(err) => {
if (err) {
res.status(500).json({ ok: false, message: 'Error updating the database' });
} else {
res.json({ ok: true, result: 'Session renamed successfully' });
}
}
);
});
// POST route to get all rows from a table
app.post('/all', (req, res) => {
const { storeName } = req.body;
const normStoreName = normalizeStoreName(storeName);
db.all(`SELECT key, data FROM ${normStoreName}`, [], (err, rows) => {
if (err) {
res.status(500).json({ ok: false, message: 'Error querying the database' });
} else {
const all = {};
rows.forEach((row) => {
all[row.key] = JSON.parse(row.data);
});
res.json({ ok: true, result: all });
}
});
});
// POST route to get session info
app.post('/sessions', (req, res) => {
const { storeName } = req.body;
const normStoreName = normalizeStoreName(storeName);
db.all(
`
SELECT key, json_extract(data, '$.name') AS name
FROM ${normStoreName}
WHERE key NOT IN ('selectedSessionId', 'nextSessionId')
`,
[],
(err, rows) => {
if (err) {
res.status(500).json({ ok: false, message: 'Error querying the database' });
} else {
const sessions = {};
rows.forEach((row) => {
sessions[row.key] = row.name;
});
res.json({ ok: true, result: sessions });
}
}
);
});
// POST route to delete a session
app.post('/delete', (req, res) => {
const { storeName, key } = req.body;
const normStoreName = normalizeStoreName(storeName);
db.run(`DELETE FROM ${normStoreName} WHERE key = ?`, [key], (err) => {
if (err) {
res.status(500).json({ ok: false, message: 'Error deleting from the database' });
} else {
res.json({ ok: true, result: 'Session deleted successfully' });
}
});
});
// Start the server
app.listen(port, host, () => {
console.log(`Server listening at http://${host}:${port}`);
if (!noOpen) {
open(`http://127.0.0.1:${port}/`);
}
});
// Close db connection on server close
process.on('SIGINT', () => {
db.close(() => {
process.exit(0);
});
});