From af0ff49f87fd4f76a5d409e1a4b58111fb2857e0 Mon Sep 17 00:00:00 2001 From: Daniel Vasic Date: Tue, 12 May 2026 06:49:39 +0000 Subject: [PATCH] Initial T06 security template --- .gitignore | 7 ++++ README.md | 51 ++++++++++++++++++++++++++ api/src/admin-service.js | 19 ++++++++++ api/src/auth.js | 37 +++++++++++++++++++ api/src/data.js | 19 ++++++++++ api/src/middleware/authenticate.js | 26 +++++++++++++ api/src/routes/auth.js | 18 +++++++++ api/src/routes/orders.js | 24 ++++++++++++ api/src/routes/preview.js | 42 +++++++++++++++++++++ api/src/routes/profile.js | 11 ++++++ api/src/server.js | 59 ++++++++++++++++++++++++++++++ docker-compose.yml | 23 ++++++++++++ gateway/nginx.conf | 46 +++++++++++++++++++++++ package.json | 19 ++++++++++ services/orders-api/Dockerfile | 7 ++++ services/orders-api/package.json | 12 ++++++ services/orders-api/server.js | 33 +++++++++++++++++ services/users-api/Dockerfile | 7 ++++ services/users-api/package.json | 12 ++++++ services/users-api/server.js | 57 +++++++++++++++++++++++++++++ 20 files changed, 529 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 api/src/admin-service.js create mode 100644 api/src/auth.js create mode 100644 api/src/data.js create mode 100644 api/src/middleware/authenticate.js create mode 100644 api/src/routes/auth.js create mode 100644 api/src/routes/orders.js create mode 100644 api/src/routes/preview.js create mode 100644 api/src/routes/profile.js create mode 100644 api/src/server.js create mode 100644 docker-compose.yml create mode 100644 gateway/nginx.conf create mode 100644 package.json create mode 100644 services/orders-api/Dockerfile create mode 100644 services/orders-api/package.json create mode 100644 services/orders-api/server.js create mode 100644 services/users-api/Dockerfile create mode 100644 services/users-api/package.json create mode 100644 services/users-api/server.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb40f2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.env +certs/ +api/private.pem +api/public.pem +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..a7914de --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# T06 — Sigurnost mikro-servisnih platformi + +Ovo je početni repozitorij za laboratorijsku vježbu T06. + +## Pokretanje + +```bash +npm install +npm start +``` + +API se pokreće na: + +```text +http://localhost:3000 +``` + +Za HTTPS način rada: + +```bash +HTTPS=true npm start +``` + +API se tada pokreće na: + +```text +https://localhost:3443 +``` + +## Grane + +Svaki zadatak ima svoju granu: + +```bash +git checkout zadatak1 +git checkout zadatak2 +git checkout zadatak3 +git checkout zadatak4 +``` + +## Korisnici za testiranje + +```text +student / fpmoz2024 +student1 / pass1 +student2 / pass2 +``` + +## Napomena + +Repozitorij je namjerno pripremljen s TODO komentarima i nekim ranjivim dijelovima koda jer studenti u vježbi trebaju demonstrirati i popraviti sigurnosne probleme. diff --git a/api/src/admin-service.js b/api/src/admin-service.js new file mode 100644 index 0000000..910cf47 --- /dev/null +++ b/api/src/admin-service.js @@ -0,0 +1,19 @@ +const express = require('express'); + +function startAdminService() { + const app = express(); + + app.get('/admin', (req, res) => { + res.json({ + service: 'internal-admin-service', + warning: 'Ovaj servis ne bi smio biti dostupan kroz javni API.', + secret: 'internal-admin-token-demo' + }); + }); + + app.listen(3001, () => { + console.log('Interni admin servis na http://localhost:3001'); + }); +} + +module.exports = startAdminService; diff --git a/api/src/auth.js b/api/src/auth.js new file mode 100644 index 0000000..579aca7 --- /dev/null +++ b/api/src/auth.js @@ -0,0 +1,37 @@ +const fs = require('fs'); +const path = require('path'); +const jwt = require('jsonwebtoken'); + +const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-ne-koristiti-u-produkciji'; + +function privateKeyPath() { + return path.join(__dirname, '..', 'private.pem'); +} + +function publicKeyPath() { + return path.join(__dirname, '..', 'public.pem'); +} + +function signToken(user) { + const payload = { + sub: user.id, + username: user.username, + role: user.role + }; + + // TODO Z1.4: + // Zamijeni simetrično HS256 potpisivanje asimetričnim RS256 potpisivanjem. + // 1. Generiraj api/private.pem i api/public.pem. + // 2. Umjesto JWT_SECRET koristi fs.readFileSync(privateKeyPath()). + // 3. Promijeni algorithm iz HS256 u RS256. + return jwt.sign(payload, JWT_SECRET, { + algorithm: 'HS256', + expiresIn: '15m' + }); +} + +function getPublicKeyPath() { + return publicKeyPath(); +} + +module.exports = { signToken, getPublicKeyPath, privateKeyPath, publicKeyPath }; diff --git a/api/src/data.js b/api/src/data.js new file mode 100644 index 0000000..6d239f7 --- /dev/null +++ b/api/src/data.js @@ -0,0 +1,19 @@ +const users = [ + { id: 'u0', username: 'student', password: 'fpmoz2024', name: 'Demo Student', role: 'student' }, + { id: 'u1', username: 'student1', password: 'pass1', name: 'Student Jedan', role: 'student' }, + { id: 'u2', username: 'student2', password: 'pass2', name: 'Student Dva', role: 'student' } +]; + +const profiles = { + u0: { id: 'u0', name: 'Demo Student', email: 'student@fpmoz.sum.ba', role: 'student' }, + u1: { id: 'u1', name: 'Student Jedan', email: 'student1@fpmoz.sum.ba', role: 'student' }, + u2: { id: 'u2', name: 'Student Dva', email: 'student2@fpmoz.sum.ba', role: 'student' } +}; + +const orders = [ + { id: 1, userId: 'u1', item: 'Knjiga: Mikroservisi', amount: 25 }, + { id: 2, userId: 'u2', item: 'Knjiga: API Security', amount: 30 }, + { id: 3, userId: 'u1', item: 'Tečaj: Docker osnove', amount: 15 } +]; + +module.exports = { users, profiles, orders }; diff --git a/api/src/middleware/authenticate.js b/api/src/middleware/authenticate.js new file mode 100644 index 0000000..621ad57 --- /dev/null +++ b/api/src/middleware/authenticate.js @@ -0,0 +1,26 @@ +const fs = require('fs'); +const jwt = require('jsonwebtoken'); +const { getPublicKeyPath } = require('../auth'); + +const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-ne-koristiti-u-produkciji'; + +function authenticate(req, res, next) { + const authHeader = req.headers.authorization || ''; + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null; + + if (!token) { + return res.status(401).json({ error: 'Missing bearer token' }); + } + + try { + // TODO Z1.4: + // Nakon prelaska na RS256 ovdje umjesto JWT_SECRET koristi javni ključ: + // jwt.verify(token, fs.readFileSync(getPublicKeyPath())) + req.user = jwt.verify(token, JWT_SECRET); + return next(); + } catch (error) { + return res.status(401).json({ error: 'Invalid token' }); + } +} + +module.exports = authenticate; diff --git a/api/src/routes/auth.js b/api/src/routes/auth.js new file mode 100644 index 0000000..fd2ceca --- /dev/null +++ b/api/src/routes/auth.js @@ -0,0 +1,18 @@ +const express = require('express'); +const { users } = require('../data'); +const { signToken } = require('../auth'); + +const router = express.Router(); + +router.post('/login', (req, res) => { + const { username, password } = req.body; + const user = users.find((candidate) => candidate.username === username && candidate.password === password); + + if (!user) { + return res.status(401).json({ error: 'Invalid username or password' }); + } + + return res.json({ token: signToken(user) }); +}); + +module.exports = router; diff --git a/api/src/routes/orders.js b/api/src/routes/orders.js new file mode 100644 index 0000000..21c2ab9 --- /dev/null +++ b/api/src/routes/orders.js @@ -0,0 +1,24 @@ +const express = require('express'); +const authenticate = require('../middleware/authenticate'); +const { orders } = require('../data'); + +const router = express.Router(); + +router.get('/orders/:id', authenticate, (req, res) => { + const order = orders.find((candidate) => candidate.id === Number(req.params.id)); + + if (!order) { + return res.status(404).json({ error: 'Order not found' }); + } + + // TODO Z4.2: + // Ova provjera je namjerno zakomentirana kako bi se demonstrirala BOLA ranjivost. + // Odkomentiraj blok, restartaj server i ponovno testiraj pristup tuđoj narudžbi. + // if (order.userId !== req.user.sub) { + // return res.status(403).json({ error: 'Forbidden' }); + // } + + return res.json(order); +}); + +module.exports = router; diff --git a/api/src/routes/preview.js b/api/src/routes/preview.js new file mode 100644 index 0000000..8008df3 --- /dev/null +++ b/api/src/routes/preview.js @@ -0,0 +1,42 @@ +const express = require('express'); +const axios = require('axios'); + +const router = express.Router(); + +router.get('/fetch-preview', async (req, res) => { + const { url } = req.query; + + if (!url) { + return res.status(400).json({ error: 'Missing url query parameter' }); + } + + try { + // TODO Z4.4: + // Ova aplikacija je namjerno ranjiva na SSRF jer prihvaća bilo koji URL. + // Odkomentiraj whitelist provjeru i dopusti samo poznate javne domene. + // const ALLOWED_DOMAINS = ['example.com', 'httpbin.org', 'jsonplaceholder.typicode.com']; + // const parsed = new URL(url); + // if (!ALLOWED_DOMAINS.includes(parsed.hostname)) { + // return res.status(403).json({ + // error: 'Domain not allowed', + // allowed: ALLOWED_DOMAINS + // }); + // } + + const response = await axios.get(url, { + timeout: 3000, + maxRedirects: 2, + validateStatus: () => true + }); + + if (typeof response.data === 'object') { + return res.status(response.status).json(response.data); + } + + return res.status(response.status).type('text/plain').send(String(response.data).slice(0, 2000)); + } catch (error) { + return res.status(502).json({ error: 'Fetch failed', details: error.message }); + } +}); + +module.exports = router; diff --git a/api/src/routes/profile.js b/api/src/routes/profile.js new file mode 100644 index 0000000..49af714 --- /dev/null +++ b/api/src/routes/profile.js @@ -0,0 +1,11 @@ +const express = require('express'); +const authenticate = require('../middleware/authenticate'); +const { profiles } = require('../data'); + +const router = express.Router(); + +router.get('/profile', authenticate, (req, res) => { + return res.json(profiles[req.user.sub] || { id: req.user.sub, message: 'Profil nije pronađen' }); +}); + +module.exports = router; diff --git a/api/src/server.js b/api/src/server.js new file mode 100644 index 0000000..03db663 --- /dev/null +++ b/api/src/server.js @@ -0,0 +1,59 @@ +require('dotenv').config(); + +const fs = require('fs'); +const http = require('http'); +const https = require('https'); +const path = require('path'); +const express = require('express'); +const authRoutes = require('./routes/auth'); +const profileRoutes = require('./routes/profile'); +const ordersRoutes = require('./routes/orders'); +const previewRoutes = require('./routes/preview'); +const startAdminService = require('./admin-service'); + +const app = express(); +app.use(express.json()); + +app.get('/api/public', (req, res) => { + res.json({ message: 'Javni endpoint radi.' }); +}); + +app.get('/api/internal', (req, res) => { + const cert = req.socket.getPeerCertificate(); + + if (!req.client.authorized) { + return res.status(401).json({ error: 'Client certificate required' }); + } + + return res.json({ message: 'mTLS pristup odobren.', client: cert.subject }); +}); + +app.use('/auth', authRoutes); +app.use('/api', profileRoutes); +app.use('/api', ordersRoutes); +app.use('/api', previewRoutes); + +const useHttps = String(process.env.HTTPS || '').toLowerCase() === 'true'; + +if (useHttps) { + const certsDir = path.join(process.cwd(), 'certs'); + const options = { + key: fs.readFileSync(path.join(certsDir, 'server.key')), + cert: fs.readFileSync(path.join(certsDir, 'server.crt')), + ca: fs.readFileSync(path.join(certsDir, 'ca.crt')), + requestCert: true, + rejectUnauthorized: false + }; + + https.createServer(options, app).listen(3443, () => { + console.log('API na https://localhost:3443'); + }); +} else { + http.createServer(app).listen(3000, () => { + console.log('API na http://localhost:3000'); + }); +} + +if (process.env.START_ADMIN_SERVICE !== 'false') { + startAdminService(); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..45ab601 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + gateway: + image: nginx:1.27-alpine + container_name: t06-gateway + ports: + - "80:80" + volumes: + - ./gateway/nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - users-api + - orders-api + + users-api: + build: ./services/users-api + container_name: t06-users-api + environment: + JWT_SECRET: dev-secret-ne-koristiti-u-produkciji + + orders-api: + build: ./services/orders-api + container_name: t06-orders-api + environment: + JWT_SECRET: dev-secret-ne-koristiti-u-produkciji diff --git a/gateway/nginx.conf b/gateway/nginx.conf new file mode 100644 index 0000000..cb7629a --- /dev/null +++ b/gateway/nginx.conf @@ -0,0 +1,46 @@ +events {} + +http { + # TODO Z3.3: + # Dodaj rate limiting zonu u ovaj http blok: + # limit_req_zone $binary_remote_addr zone=api:10m rate=5r/s; + + upstream users_api { + server users-api:3001; + } + + upstream orders_api { + server orders-api:3002; + } + + server { + listen 80; + + location = /auth/login { + proxy_pass http://users_api/auth/login; + proxy_set_header Host $host; + } + + location /api/users { + # TODO Z3.3: + # Dodaj u ovaj location: + # limit_req zone=api burst=10 nodelay; + # limit_req_status 429; + + proxy_pass http://users_api/api/users; + proxy_set_header Host $host; + proxy_set_header Authorization $http_authorization; + } + + location /api/orders { + # TODO Z3.3: + # Dodaj u ovaj location: + # limit_req zone=api burst=10 nodelay; + # limit_req_status 429; + + proxy_pass http://orders_api/api/orders; + proxy_set_header Host $host; + proxy_set_header Authorization $http_authorization; + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..326fa4b --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "t06-sigurnost", + "version": "1.0.0", + "description": "Laboratorijska vježba T06 — sigurnost mikro-servisnih platformi", + "main": "api/src/server.js", + "scripts": { + "start": "node api/src/server.js", + "dev": "nodemon api/src/server.js" + }, + "dependencies": { + "axios": "^1.7.9", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "jsonwebtoken": "^9.0.2" + }, + "devDependencies": { + "nodemon": "^3.1.9" + } +} diff --git a/services/orders-api/Dockerfile b/services/orders-api/Dockerfile new file mode 100644 index 0000000..35ff44a --- /dev/null +++ b/services/orders-api/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install --omit=dev +COPY . . +EXPOSE 3002 +CMD ["npm", "start"] diff --git a/services/orders-api/package.json b/services/orders-api/package.json new file mode 100644 index 0000000..da21348 --- /dev/null +++ b/services/orders-api/package.json @@ -0,0 +1,12 @@ +{ + "name": "orders-api", + "version": "1.0.0", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.21.2", + "jsonwebtoken": "^9.0.2" + } +} diff --git a/services/orders-api/server.js b/services/orders-api/server.js new file mode 100644 index 0000000..e83a547 --- /dev/null +++ b/services/orders-api/server.js @@ -0,0 +1,33 @@ +const express = require('express'); +const jwt = require('jsonwebtoken'); + +const app = express(); +const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-ne-koristiti-u-produkciji'; + +function authenticate(req, res, next) { + const authHeader = req.headers.authorization || ''; + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null; + + if (!token) { + return res.status(401).json({ error: 'Missing bearer token' }); + } + + try { + req.user = jwt.verify(token, JWT_SECRET); + return next(); + } catch (error) { + return res.status(401).json({ error: 'Invalid token' }); + } +} + +app.get('/api/orders', authenticate, (req, res) => { + res.setHeader('X-Served-By', 'orders-api'); + return res.json([ + { id: 1, item: 'Knjiga: Mikroservisi', amount: 25 }, + { id: 2, item: 'Knjiga: API Security', amount: 30 } + ]); +}); + +app.listen(3002, () => { + console.log('orders-api na portu 3002'); +}); diff --git a/services/users-api/Dockerfile b/services/users-api/Dockerfile new file mode 100644 index 0000000..c38b9ab --- /dev/null +++ b/services/users-api/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install --omit=dev +COPY . . +EXPOSE 3001 +CMD ["npm", "start"] diff --git a/services/users-api/package.json b/services/users-api/package.json new file mode 100644 index 0000000..f6405ca --- /dev/null +++ b/services/users-api/package.json @@ -0,0 +1,12 @@ +{ + "name": "users-api", + "version": "1.0.0", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.21.2", + "jsonwebtoken": "^9.0.2" + } +} diff --git a/services/users-api/server.js b/services/users-api/server.js new file mode 100644 index 0000000..31b6ff6 --- /dev/null +++ b/services/users-api/server.js @@ -0,0 +1,57 @@ +const express = require('express'); +const jwt = require('jsonwebtoken'); + +const app = express(); +app.use(express.json()); + +const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-ne-koristiti-u-produkciji'; + +const users = [ + { id: 'u0', username: 'student', password: 'fpmoz2024', name: 'Demo Student', role: 'student' } +]; + +function authenticate(req, res, next) { + const authHeader = req.headers.authorization || ''; + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null; + + if (!token) { + return res.status(401).json({ error: 'Missing bearer token' }); + } + + try { + req.user = jwt.verify(token, JWT_SECRET); + return next(); + } catch (error) { + return res.status(401).json({ error: 'Invalid token' }); + } +} + +app.post('/auth/login', (req, res) => { + const { username, password } = req.body; + const user = users.find((candidate) => candidate.username === username && candidate.password === password); + + if (!user) { + return res.status(401).json({ error: 'Invalid username or password' }); + } + + const token = jwt.sign( + { sub: user.id, username: user.username, role: user.role }, + JWT_SECRET, + { algorithm: 'HS256', expiresIn: '15m' } + ); + + return res.json({ token }); +}); + +app.get('/api/users', authenticate, (req, res) => { + res.setHeader('X-Served-By', 'users-api'); + return res.json([ + { id: 'u0', username: 'student', name: 'Demo Student' }, + { id: 'u1', username: 'student1', name: 'Student Jedan' }, + { id: 'u2', username: 'student2', name: 'Student Dva' } + ]); +}); + +app.listen(3001, () => { + console.log('users-api na portu 3001'); +});