Initial T06 security template
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.env
|
||||
certs/
|
||||
api/private.pem
|
||||
api/public.pem
|
||||
*.log
|
||||
.DS_Store
|
||||
51
README.md
Normal file
51
README.md
Normal 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
19
api/src/admin-service.js
Normal 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
37
api/src/auth.js
Normal 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
19
api/src/data.js
Normal 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 };
|
||||
26
api/src/middleware/authenticate.js
Normal file
26
api/src/middleware/authenticate.js
Normal 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
18
api/src/routes/auth.js
Normal 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
24
api/src/routes/orders.js
Normal 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
42
api/src/routes/preview.js
Normal 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
11
api/src/routes/profile.js
Normal 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
59
api/src/server.js
Normal 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
23
docker-compose.yml
Normal 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
46
gateway/nginx.conf
Normal 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
19
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
services/orders-api/Dockerfile
Normal file
7
services/orders-api/Dockerfile
Normal 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"]
|
||||
12
services/orders-api/package.json
Normal file
12
services/orders-api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
33
services/orders-api/server.js
Normal file
33
services/orders-api/server.js
Normal 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');
|
||||
});
|
||||
7
services/users-api/Dockerfile
Normal file
7
services/users-api/Dockerfile
Normal 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"]
|
||||
12
services/users-api/package.json
Normal file
12
services/users-api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
57
services/users-api/server.js
Normal file
57
services/users-api/server.js
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user