Improve security
This commit is contained in:
parent
0fde080ab8
commit
853f88c66b
3 changed files with 275 additions and 228 deletions
|
|
@ -6,6 +6,21 @@ window.plausible =
|
|||
(window.plausible.q = window.plausible.q || []).push(arguments);
|
||||
};
|
||||
|
||||
// Escape user-supplied text before interpolating it into HTML, to prevent XSS.
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(
|
||||
/[&<>"']/g,
|
||||
(ch) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[ch],
|
||||
);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
loadQuestions();
|
||||
loadImages();
|
||||
|
|
@ -13,23 +28,31 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||
});
|
||||
|
||||
function setupEventListeners() {
|
||||
document.querySelectorAll('.tab[data-tab]').forEach(tab => {
|
||||
tab.addEventListener('click', (e) => {
|
||||
document.querySelectorAll(".tab[data-tab]").forEach((tab) => {
|
||||
tab.addEventListener("click", (e) => {
|
||||
switchTab(e.target.dataset.tab);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('addQuestionBtn').addEventListener('click', showAddQuestionModal);
|
||||
document
|
||||
.getElementById("addQuestionBtn")
|
||||
.addEventListener("click", showAddQuestionModal);
|
||||
|
||||
document.getElementById('imageUpload').addEventListener('change', uploadImage);
|
||||
document
|
||||
.getElementById("imageUpload")
|
||||
.addEventListener("change", uploadImage);
|
||||
|
||||
document.getElementById('closeModalBtn').addEventListener('click', closeModal);
|
||||
document.getElementById('cancelBtn').addEventListener('click', closeModal);
|
||||
document
|
||||
.getElementById("closeModalBtn")
|
||||
.addEventListener("click", closeModal);
|
||||
document.getElementById("cancelBtn").addEventListener("click", closeModal);
|
||||
|
||||
document.getElementById('questionForm').addEventListener('submit', saveQuestion);
|
||||
document
|
||||
.getElementById("questionForm")
|
||||
.addEventListener("submit", saveQuestion);
|
||||
|
||||
document.getElementById('questionModal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'questionModal') {
|
||||
document.getElementById("questionModal").addEventListener("click", (e) => {
|
||||
if (e.target.id === "questionModal") {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
|
@ -70,39 +93,37 @@ function displayQuestions(questions) {
|
|||
.map(
|
||||
(q) => `
|
||||
<div class="question-item">
|
||||
<h4>ID: ${q.id} - ${q.source}</h4>
|
||||
<p><strong>Kérdés:</strong> ${q.description.substring(
|
||||
0,
|
||||
100
|
||||
)}...</p>
|
||||
<p><strong>Típus:</strong> ${q.type
|
||||
} | <strong>Helyes válasz:</strong> ${["A", "B", "C", "D"][q.correct - 1]
|
||||
}</p>
|
||||
<h4>ID: ${q.id} - ${escapeHtml(q.source)}</h4>
|
||||
<p><strong>Kérdés:</strong> ${escapeHtml(
|
||||
q.description.substring(0, 100),
|
||||
)}...</p>
|
||||
<p><strong>Típus:</strong> ${escapeHtml(q.type)} | <strong>Helyes válasz:</strong> ${
|
||||
["A", "B", "C", "D"][q.correct - 1]
|
||||
}</p>
|
||||
<div class="question-actions">
|
||||
<button data-edit-id="${q.id}">Szerkesztés</button>
|
||||
<button class="danger" data-delete-id="${q.id}">Törlés</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
container.querySelectorAll('[data-edit-id]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
container.querySelectorAll("[data-edit-id]").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
editQuestion(parseInt(e.target.dataset.editId));
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('[data-delete-id]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
container.querySelectorAll("[data-delete-id]").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
deleteQuestion(parseInt(e.target.dataset.deleteId));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showAddQuestionModal() {
|
||||
document.getElementById("modalTitle").textContent =
|
||||
"Új kérdés hozzáadása";
|
||||
document.getElementById("modalTitle").textContent = "Új kérdés hozzáadása";
|
||||
document.getElementById("questionForm").reset();
|
||||
document.getElementById("questionId").value = "";
|
||||
document.getElementById("questionModal").style.display = "block";
|
||||
|
|
@ -126,11 +147,9 @@ async function editQuestion(id) {
|
|||
document.getElementById("questionB").value = question.b;
|
||||
document.getElementById("questionC").value = question.c;
|
||||
document.getElementById("questionD").value = question.d;
|
||||
document.getElementById("questionCorrect").value =
|
||||
question.correct;
|
||||
document.getElementById("questionCorrect").value = question.correct;
|
||||
document.getElementById("questionType").value = question.type;
|
||||
document.getElementById("questionImage").value =
|
||||
question.image || "";
|
||||
document.getElementById("questionImage").value = question.image || "";
|
||||
document.getElementById("questionModal").style.display = "block";
|
||||
}
|
||||
}
|
||||
|
|
@ -166,7 +185,7 @@ async function saveQuestion(event) {
|
|||
method: isEdit ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
|
|
@ -178,7 +197,7 @@ async function saveQuestion(event) {
|
|||
showAlert(
|
||||
"questionsAlert",
|
||||
error.error || "Hiba a mentés során",
|
||||
"danger"
|
||||
"danger",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -190,10 +209,9 @@ async function deleteQuestion(id) {
|
|||
if (!confirm("Biztosan törölni szeretnéd ezt a kérdést?")) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/admin/questions/${id}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
const response = await fetch(`${API_BASE}/api/admin/questions/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (response.ok) {
|
||||
loadQuestions();
|
||||
showAlert("questionsAlert", "Kérdés törölve!", "success");
|
||||
|
|
@ -226,16 +244,20 @@ function displayImages(images) {
|
|||
.map(
|
||||
(image) => `
|
||||
<div class="image-item">
|
||||
<img src="${API_BASE}/api/pics/${image}" alt="${image}">
|
||||
<p>${image}</p>
|
||||
<button class="danger" data-delete-image="${image}">Törlés</button>
|
||||
<img src="${API_BASE}/api/pics/${encodeURIComponent(
|
||||
image,
|
||||
)}" alt="${escapeHtml(image)}">
|
||||
<p>${escapeHtml(image)}</p>
|
||||
<button class="danger" data-delete-image="${escapeHtml(
|
||||
image,
|
||||
)}">Törlés</button>
|
||||
</div>
|
||||
`
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
container.querySelectorAll('[data-delete-image]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
container.querySelectorAll("[data-delete-image]").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
deleteImage(e.target.dataset.deleteImage);
|
||||
});
|
||||
});
|
||||
|
|
@ -265,7 +287,7 @@ async function uploadImage() {
|
|||
showAlert(
|
||||
"imagesAlert",
|
||||
error.error || "Hiba a feltöltés során",
|
||||
"danger"
|
||||
"danger",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -281,7 +303,7 @@ async function deleteImage(filename) {
|
|||
`${API_BASE}/api/admin/images/${encodeURIComponent(filename)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
|
|
@ -308,4 +330,4 @@ function showAlert(elementId, message, type) {
|
|||
setTimeout(() => {
|
||||
alertDiv.style.display = "none";
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,58 @@
|
|||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const helmet = require('helmet');
|
||||
const express = require("express");
|
||||
const cors = require("cors");
|
||||
const multer = require("multer");
|
||||
const path = require("path");
|
||||
const fs = require("fs").promises;
|
||||
const helmet = require("helmet");
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: [
|
||||
"'self'",
|
||||
"https://stats.schmelczer.dev",
|
||||
"'unsafe-inline'",
|
||||
],
|
||||
scriptSrc: [
|
||||
"'self'",
|
||||
"https://stats.schmelczer.dev",
|
||||
"'unsafe-inline'",
|
||||
],
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: [
|
||||
"'self'",
|
||||
"https://stats.schmelczer.dev",
|
||||
"'unsafe-inline'",
|
||||
],
|
||||
scriptSrc: [
|
||||
"'self'",
|
||||
"https://stats.schmelczer.dev",
|
||||
"'unsafe-inline'",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
app.use(cors({
|
||||
origin: process.env.FRONTEND_URL || '*',
|
||||
credentials: true
|
||||
}));
|
||||
}),
|
||||
);
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.FRONTEND_URL || "*",
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
app.use(express.json({ limit: '100mb' }));
|
||||
app.use(express.static('public'));
|
||||
app.use(express.json({ limit: "100mb" }));
|
||||
app.use(express.static("public"));
|
||||
|
||||
// File paths
|
||||
const DATA_PATH = process.env.DATA_PATH || path.join(__dirname, '../frontend/fizika.json');
|
||||
const PICS_PATH = process.env.PICS_PATH || path.join(__dirname, '../frontend/pics');
|
||||
const DATA_PATH =
|
||||
process.env.DATA_PATH || path.join(__dirname, "../frontend/fizika.json");
|
||||
const PICS_PATH =
|
||||
process.env.PICS_PATH || path.join(__dirname, "../frontend/pics");
|
||||
|
||||
// Reject any name that could escape the target directory (path traversal).
|
||||
const isUnsafeFilename = (name) =>
|
||||
typeof name !== "string" ||
|
||||
name.length === 0 ||
|
||||
name.includes("/") ||
|
||||
name.includes("\\") ||
|
||||
name.includes("\0") ||
|
||||
name === "." ||
|
||||
name === ".." ||
|
||||
path.isAbsolute(name);
|
||||
|
||||
// Multer configuration for image uploads
|
||||
const storage = multer.diskStorage({
|
||||
|
|
@ -43,25 +60,31 @@ const storage = multer.diskStorage({
|
|||
cb(null, PICS_PATH);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
cb(null, file.originalname);
|
||||
}
|
||||
// Strip any directory components the client may have sent so an upload
|
||||
// named e.g. "../../server.js" can never escape PICS_PATH.
|
||||
const name = path.basename(file.originalname);
|
||||
if (isUnsafeFilename(name)) {
|
||||
return cb(new Error("Invalid filename"));
|
||||
}
|
||||
cb(null, name);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: { fileSize: 50 * 1024 * 1024 }, // 5MB
|
||||
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
if (file.mimetype.startsWith("image/")) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only images allowed'));
|
||||
cb(new Error("Only images allowed"));
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Utility functions
|
||||
const readData = async () => {
|
||||
const data = await fs.readFile(DATA_PATH, 'utf8');
|
||||
const data = await fs.readFile(DATA_PATH, "utf8");
|
||||
return JSON.parse(data);
|
||||
};
|
||||
|
||||
|
|
@ -69,44 +92,42 @@ const writeData = async (data) => {
|
|||
await fs.writeFile(DATA_PATH, JSON.stringify(data, null, 2));
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Public routes
|
||||
app.get('/api/fizika', async (req, res) => {
|
||||
app.get("/api/fizika", async (req, res) => {
|
||||
try {
|
||||
const data = await readData();
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to read data' });
|
||||
res.status(500).json({ error: "Failed to read data" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/images', async (req, res) => {
|
||||
app.get("/api/images", async (req, res) => {
|
||||
try {
|
||||
const files = await fs.readdir(PICS_PATH);
|
||||
const images = files.filter(f => /\.(jpg|jpeg|png|gif|bmp)$/i.test(f));
|
||||
const images = files.filter((f) => /\.(jpg|jpeg|png|gif|bmp)$/i.test(f));
|
||||
res.json(images);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to read images' });
|
||||
res.status(500).json({ error: "Failed to read images" });
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api/pics', express.static(PICS_PATH));
|
||||
app.use("/api/pics", express.static(PICS_PATH));
|
||||
|
||||
// Admin routes (no auth required)
|
||||
app.get('/api/admin/questions', async (req, res) => {
|
||||
app.get("/api/admin/questions", async (req, res) => {
|
||||
try {
|
||||
const data = await readData();
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to read questions' });
|
||||
res.status(500).json({ error: "Failed to read questions" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/questions', async (req, res) => {
|
||||
app.post("/api/admin/questions", async (req, res) => {
|
||||
try {
|
||||
const data = await readData();
|
||||
const maxId = Math.max(...data.map(q => q.id), 0);
|
||||
const maxId = Math.max(...data.map((q) => q.id), 0);
|
||||
|
||||
const newQuestion = { id: maxId + 1, ...req.body };
|
||||
data.push(newQuestion);
|
||||
|
|
@ -114,17 +135,17 @@ app.post('/api/admin/questions', async (req, res) => {
|
|||
|
||||
res.status(201).json(newQuestion);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to create question' });
|
||||
res.status(500).json({ error: "Failed to create question" });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/admin/questions/:id', async (req, res) => {
|
||||
app.put("/api/admin/questions/:id", async (req, res) => {
|
||||
try {
|
||||
const data = await readData();
|
||||
const index = data.findIndex(q => q.id === parseInt(req.params.id));
|
||||
const index = data.findIndex((q) => q.id === parseInt(req.params.id));
|
||||
|
||||
if (index === -1) {
|
||||
return res.status(404).json({ error: 'Question not found' });
|
||||
return res.status(404).json({ error: "Question not found" });
|
||||
}
|
||||
|
||||
data[index] = { ...data[index], ...req.body };
|
||||
|
|
@ -132,61 +153,70 @@ app.put('/api/admin/questions/:id', async (req, res) => {
|
|||
|
||||
res.json(data[index]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to update question' });
|
||||
res.status(500).json({ error: "Failed to update question" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/admin/questions/:id', async (req, res) => {
|
||||
app.delete("/api/admin/questions/:id", async (req, res) => {
|
||||
try {
|
||||
const data = await readData();
|
||||
const index = data.findIndex(q => q.id === parseInt(req.params.id));
|
||||
const index = data.findIndex((q) => q.id === parseInt(req.params.id));
|
||||
|
||||
if (index === -1) {
|
||||
return res.status(404).json({ error: 'Question not found' });
|
||||
return res.status(404).json({ error: "Question not found" });
|
||||
}
|
||||
|
||||
data.splice(index, 1);
|
||||
await writeData(data);
|
||||
|
||||
res.json({ message: 'Question deleted' });
|
||||
res.json({ message: "Question deleted" });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to delete question' });
|
||||
res.status(500).json({ error: "Failed to delete question" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/images/upload', upload.single('image'), (req, res) => {
|
||||
app.post("/api/admin/images/upload", upload.single("image"), (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No image provided' });
|
||||
return res.status(400).json({ error: "No image provided" });
|
||||
}
|
||||
|
||||
res.json({
|
||||
filename: req.file.filename,
|
||||
path: `/api/pics/${req.file.filename}`
|
||||
path: `/api/pics/${req.file.filename}`,
|
||||
});
|
||||
});
|
||||
|
||||
app.delete('/api/admin/images/:filename', async (req, res) => {
|
||||
app.delete("/api/admin/images/:filename", async (req, res) => {
|
||||
if (isUnsafeFilename(req.params.filename)) {
|
||||
return res.status(400).json({ error: "Invalid filename" });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.unlink(path.join(PICS_PATH, req.params.filename));
|
||||
res.json({ message: 'Image deleted' });
|
||||
res.json({ message: "Image deleted" });
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return res.status(404).json({ error: 'Image not found' });
|
||||
if (error.code === "ENOENT") {
|
||||
return res.status(404).json({ error: "Image not found" });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to delete image' });
|
||||
res.status(500).json({ error: "Failed to delete image" });
|
||||
}
|
||||
});
|
||||
|
||||
// Error handling
|
||||
app.use((error, req, res, next) => {
|
||||
console.error('Error:', error.message);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
console.error("Error:", error.message);
|
||||
res.status(500).json({ error: "Server error" });
|
||||
});
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
res.status(404).json({ error: "Not found" });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Fizika Admin Backend running on port ${PORT}`);
|
||||
});
|
||||
// Only start listening when run directly, so tests can import the app.
|
||||
if (require.main === module) {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Fizika Admin Backend running on port ${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { app, isUnsafeFilename };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue