Initial T06 security template

This commit is contained in:
2026-05-12 06:49:39 +00:00
commit af0ff49f87
20 changed files with 529 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
.env
certs/
api/private.pem
api/public.pem
*.log
.DS_Store

51
README.md Normal file
View File

@@ -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.

19
api/src/admin-service.js Normal file
View File

@@ -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;

37
api/src/auth.js Normal file
View File

@@ -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 };

19
api/src/data.js Normal file
View File

@@ -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 };

View File

@@ -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;

18
api/src/routes/auth.js Normal file
View File

@@ -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;

24
api/src/routes/orders.js Normal file
View File

@@ -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;

42
api/src/routes/preview.js Normal file
View File

@@ -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;

11
api/src/routes/profile.js Normal file
View File

@@ -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;

59
api/src/server.js Normal file
View File

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

23
docker-compose.yml Normal file
View File

@@ -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

46
gateway/nginx.conf Normal file
View File

@@ -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;
}
}
}

19
package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,7 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 3002
CMD ["npm", "start"]

View File

@@ -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"
}
}

View File

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

View File

@@ -0,0 +1,7 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 3001
CMD ["npm", "start"]

View File

@@ -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"
}
}

View File

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