From 5b25fb5b80a2bb124180bfbabe2ed83a17ded74a Mon Sep 17 00:00:00 2001 From: Rasmus Date: Wed, 27 May 2026 10:24:59 +0200 Subject: [PATCH 01/14] =?UTF-8?q?Din=20commit-text=20h=C3=A4r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- planeringsfasen.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/planeringsfasen.md b/planeringsfasen.md index 661cae0e..4f66eae7 100644 --- a/planeringsfasen.md +++ b/planeringsfasen.md @@ -1 +1,24 @@ -# Inlämning 1 - Planeringsfasen \ No newline at end of file +# Inlämning 1 - Planeringsfasen +Vi har gjort en säkerhetsgranskning av YH Message App med hjälp av hotmodellering. Det betyder att vi försöker hitta risker i systemet innan någon angripare gör det — ungefär som att tänka som en tjuv för att kunna bygga bättre lås. + +Först gick vi igenom vad hotmodellering är: + +förstå systemet, tänka igenom vad som kan gå fel, bestämma hur vi skyddar oss och senare kontrollera att lösningarna fungerar + +Vi beskrev sedan systemarkitekturen: webbläsare → frontend → server → databas. +Varje del är en möjlig angreppspunkt. + +Med STRIDE‑modellen såg vi vilka typer av hot som finns, som spoofing, datamanipulation, dataläckor och överbelastning. +Med ESTRID‑modellen såg vi var hoten är störst — i övergångarna mellan zonerna, där servern och databasen är mest skyddsvärda. + +Utifrån detta tog vi fram fyra enkla säkerhetskrav: + +1. Automatisk utloggning + +2. Starka och säkert sparade lösenord + +3. Rate limiting för att stoppa brute force + +4. Endast inloggade användare får göra något + +Tillsammans ger detta en tydlig säkerhetsgrund inför nästa steg: kodgranskning och säkerhetstestning. \ No newline at end of file From f77da8271bc6b991d9099e8fe9163c81d58bdcfb Mon Sep 17 00:00:00 2001 From: Rasmus Date: Wed, 27 May 2026 10:50:09 +0200 Subject: [PATCH 02/14] Planering --- planeringsfasen.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/planeringsfasen.md b/planeringsfasen.md index 4f66eae7..561ba53f 100644 --- a/planeringsfasen.md +++ b/planeringsfasen.md @@ -21,4 +21,4 @@ Utifrån detta tog vi fram fyra enkla säkerhetskrav: 4. Endast inloggade användare får göra något -Tillsammans ger detta en tydlig säkerhetsgrund inför nästa steg: kodgranskning och säkerhetstestning. \ No newline at end of file +Tillsammans ger detta en tydlig säkerhetsgrund inför nästa steg: kodgranskning och säkerhetstestning. \ No newline at end of file From 4dd593326c60265d63d6d72f26f7ab77d655e9ea Mon Sep 17 00:00:00 2001 From: Rasmus Date: Fri, 5 Jun 2026 09:39:58 +0200 Subject: [PATCH 03/14] Co-authored-by: mario900606-code Co-authored-by: salzha-sec --- backend/server.js | 24 ++++++++++++++++++++++-- package-lock.json | 6 ++++++ 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 package-lock.json diff --git a/backend/server.js b/backend/server.js index c8d0c218..e980042f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -45,14 +45,24 @@ app.post("/register", async (req, res) => { }) } + // 🔐 SÄKERHETSKRAV 2: + // Här saknas lösenordsvalidering (styrka). + // bcrypt skyddar lagringen, men svaga lösenord måste stoppas innan hashning. + // Exempel på fix (läggs till i fas 3): + // const pwRegex = /^(?=.*[0-9])(?=.*[!@#$%])[A-Za-z0-9!@#$%]{8,}$/; + // if (!pwRegex.test(password)) return res.status(400).json({ error: "Weak password" }); + const hashedPassword = await bcrypt.hash(password, 10) const user = new User({ username: username.trim(), email, password: hashedPassword }) await user.save() + // 🔐 SÄKERHETSKRAV 1: + // Token lever 2h → ska ändras till 30m enligt kravspecifikation. + // Notering: JWT är inte inaktivitetsbaserad, bara fast livslängd. const accessToken = jwt.sign( { userId: user._id, username: user.username }, process.env.JWT_SECRET, - { expiresIn: "2h" } + { expiresIn: "30m" } // ÄNDRAS TILL "30m" ) res.status(201).json({ @@ -97,10 +107,12 @@ app.post("/login", async (req, res) => { }) } + // 🔐 SÄKERHETSKRAV 1: + // Samma ändring som i /register — ändra 2h → 30m. const accessToken = jwt.sign( { userId: user._id, username: user.username }, process.env.JWT_SECRET, - { expiresIn: "2h" } + { expiresIn: "30m" } // ÄNDRAS TILL "30m" ) res.json({ @@ -125,6 +137,10 @@ const isValidId = (id) => mongoose.Types.ObjectId.isValid(id) app.get("/messages", async (req, res) => { try { + // 🔐 SÄKERHETSKRAV 4: + // Denna route saknar authenticateUser → alla kan läsa alla meddelanden. + // Kravet säger att användaren måste vara inloggad för att göra något. + // I fas 3 bör authenticateUser läggas här. const messages = await Message.find() .sort({ createdAt: "desc" }) .limit(20) @@ -166,6 +182,10 @@ app.patch("/messages/:id", authenticateUser, async (req, res) => { }) app.delete("/messages/:id", async (req, res) => { + // 🔐 SÄKERHETSKRAV 4 — KRITISKT SÄKERHETSHÅL: + // authenticateUser saknas → vem som helst kan radera vilket meddelande som helst. + // Detta är Broken Access Control (OWASP #1). + // I fas 3 ska authenticateUser läggas här. if (!isValidId(req.params.id)) return res.status(400).json({ error: "Invalid message ID" }) try { const message = await Message.findById(req.params.id) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..2c710ff5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "yh-message-app-fullstack", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} From 5f64dbbb27f91cdee1fc47eb59b734d3acdd1a45 Mon Sep 17 00:00:00 2001 From: salzha-sec Date: Fri, 5 Jun 2026 09:45:30 +0200 Subject: [PATCH 04/14] =?UTF-8?q?Fas=202:=20s=C3=A4kerhetskommentarer=20oc?= =?UTF-8?q?h=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Krav 1: JWT expiresIn ändrad från 2h till 30m - Krav 2: Lösenordsvalidering (regex) tillagd i /register innan bcrypt - Krav 3: express-rate-limit tillagd på /login (max 10 försök/15 min) - Krav 4: authenticateUser + ägarskontroll tillagda på DELETE /messages/:id - Säkerhetskommentarer tillagda i server.js, auth.js och User.js Co-Authored-By: Claude Sonnet 4.6 --- 1-jun-practice-apis/index.html | 14 ++++++ 1-jun-practice-apis/script.js | 80 ++++++++++++++++++++++++++++++++++ backend/middleware/auth.js | 15 +++++++ backend/models/User.js | 3 ++ backend/server.js | 80 +++++++++++++++++++++++++--------- 5 files changed, 172 insertions(+), 20 deletions(-) create mode 100644 1-jun-practice-apis/index.html create mode 100644 1-jun-practice-apis/script.js diff --git a/1-jun-practice-apis/index.html b/1-jun-practice-apis/index.html new file mode 100644 index 00000000..770bd734 --- /dev/null +++ b/1-jun-practice-apis/index.html @@ -0,0 +1,14 @@ + + + + + + API practice + + +

API practice

+
+
+
+ + \ No newline at end of file diff --git a/1-jun-practice-apis/script.js b/1-jun-practice-apis/script.js new file mode 100644 index 00000000..3ef2b0af --- /dev/null +++ b/1-jun-practice-apis/script.js @@ -0,0 +1,80 @@ +console.log("JS file is connected to HTML!") + +const starwarsCard = document.getElementById("starwars") +const rickandmortyCard = document.getElementById("rickandmorty") +const pokemonCard = document.getElementById("pokemon") + +// Star Wars (codealong) +const getStarWars = async () => { + try { + const response = await fetch("https://swapi.dev/api/people/1/") + // const response = await fetch("https://swapi.info/api/people/1") + + if (!response.ok) { + throw new Error(`Något gick fel: ${response.status}`) + } + + const data = await response.json() + + starwarsCard.innerHTML = ` +

Character name: PLACEHOLDER

+ ` + } catch (error) { + starwarsCard.innerHTML = ` +

Något gick fel, försök igen senare

+ ` + console.error(error) + } +} + +// Practice: Använd getStarWars som mall för att hämta data från Rick and Morty API eller Pokémon API. Visa karaktärens namn och bild i respektive "card". + +// Rick and Morty +const getRickAndMorty = async () => { + try { + const response = await fetch("https://rickandmortyapi.com/api/character/1") + + if (!response.ok) { + throw new Error(`Något gick fel: ${response.status}`) + } + + const data = await response.json() + + rickandmortyCard.innerHTML = ` +

Character name: PLACEHOLDER

+ + ` + } catch (error) { + rickandmortyCard.innerHTML = ` +

Något gick fel, försök igen senare

+ ` + console.error(error) + } +} + +// Pokémon +const getPokemon = async () => { + try { + const response = await fetch("https://pokeapi.co/api/v2/pokemon/1") + + if (!response.ok) { + throw new Error(`Något gick fel: ${response.status}`) + } + + const data = await response.json() + + pokemonCard.innerHTML = ` +

Character name: PLACEHOLDER

+ + ` + } catch (error) { + pokemonCard.innerHTML = ` +

Något gick fel, försök igen senare

+ ` + console.error(error) + } +} + +getStarWars() +getRickAndMorty() +getPokemon() diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index e3bdf47c..c3b16c71 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,20 +1,35 @@ import jwt from "jsonwebtoken" import { User } from "../models/User.js" +// SÄKERHET [Krav 4]: Middleware som skyddar alla privata routes +// Verifierar att en giltig JWT-token skickats med i Authorization-headern +// Används som grindvakt vid övergången Zon 1 → Zon 2 (frontend → server) export const authenticateUser = async (req, res, next) => { const token = req.headers.authorization?.replace("Bearer ", "") + + // SÄKERHET: Om ingen token finns nekas åtkomst direkt med 401 if (!token) { return res.status(401).json({ success: false, message: "No token provided" }) } + try { + // SÄKERHET: jwt.verify kontrollerar både signatur och utgångstid (expiresIn) + // Om token manipulerats eller gått ut kastas ett fel och användaren nekas åtkomst const decoded = jwt.verify(token, process.env.JWT_SECRET) + const user = await User.findById(decoded.userId) + + // SÄKERHET: Kontrollerar att användaren fortfarande finns i databasen + // Skyddar mot fall där ett konto raderats men en gammal token fortfarande används if (!user) { return res.status(401).json({ success: false, message: "User not found" }) } + + // Lägger användaren på req.user så att routes kan kontrollera ägarskap req.user = user next() } catch (err) { + // SÄKERHET: Alla JWT-fel (utgången token, ogiltig signatur) returnerar 401 res.status(401).json({ success: false, message: "Invalid token" }) } } diff --git a/backend/models/User.js b/backend/models/User.js index 266a60b4..9f177453 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -14,6 +14,9 @@ const userSchema = new mongoose.Schema({ unique: true, lowercase: true, }, + // SÄKERHET [Krav 2]: Lösenordet lagras alltid som bcrypt-hash, aldrig i klartext + // Styrkevalidering sker i server.js innan hashning – schemat lagrar bara resultatet + // Skyddar mot Information Disclosure om databasen skulle läcka (STRIDE: Information Disclosure) password: { type: String, required: true, diff --git a/backend/server.js b/backend/server.js index e980042f..e9eebef5 100644 --- a/backend/server.js +++ b/backend/server.js @@ -5,22 +5,40 @@ import express from "express" import mongoose from "mongoose" import bcrypt from "bcrypt" import jwt from "jsonwebtoken" +import rateLimit from "express-rate-limit" // SÄKERHET [Krav 3]: Importerar rate limiting-paketet import { Message } from "./models/Message.js" import { User } from "./models/User.js" import { authenticateUser } from "./middleware/auth.js" import "./config/db.js" import listEndpoints from "express-list-endpoints" +// SÄKERHET: Servern startar inte utan JWT_SECRET – förhindrar att tokensignering sker med undefined-nyckel if (!process.env.JWT_SECRET) throw new Error("JWT_SECRET is not set in .env") const PORT = process.env.PORT || "3000" const app = express() + +// SÄKERHET: Helmet sätter säkra HTTP-headers (t.ex. X-Frame-Options, Content-Security-Policy) +// Skyddar mot vanliga webbattacker som clickjacking och XSS via headers app.use(helmet()) + +// SÄKERHET [Krav 4 – risk]: CORS tillåter alla origins med "*" +// I produktion bör detta begränsas till bara frontenddomänen, t.ex. origin: "https://din-app.se" app.use(cors({ origin: "*", })) app.use(express.json()) +// SÄKERHET [Krav 3]: Rate limiting på inloggning – max 10 försök per 15 minuter per IP +// Skyddar mot brute force-attacker (STRIDE: Spoofing, DoS) vid övergången Zon 0 → Zon 1 +const loginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minuter + max: 10, + message: { success: false, message: "Too many login attempts, please try again later" }, + standardHeaders: true, + legacyHeaders: false, +}) + app.get("/", (req, res) => { res.send(listEndpoints(app)) }) @@ -33,6 +51,17 @@ app.post("/register", async (req, res) => { return res.status(400).json({ success: false, message: "Username must be at least 2 characters" }) } + // SÄKERHET [Krav 2]: Lösenordsvalidering – kontrollerar styrka innan hashning + // Krav: minst 8 tecken, minst en siffra, minst ett specialtecken + // Skyddar mot svaga lösenord som är enkla att gissa (STRIDE: Spoofing) + const passwordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*])[A-Za-z0-9!@#$%^&*]{8,}$/ + if (!password || !passwordRegex.test(password)) { + return res.status(400).json({ + success: false, + message: "Password must be at least 8 characters and contain at least one number and one special character (!@#$%^&*)", + }) + } + const existingUser = await User.findOne({ $or: [{ email: email.toLowerCase() }, { username: username.trim() }] }) @@ -45,24 +74,18 @@ app.post("/register", async (req, res) => { }) } - // 🔐 SÄKERHETSKRAV 2: - // Här saknas lösenordsvalidering (styrka). - // bcrypt skyddar lagringen, men svaga lösenord måste stoppas innan hashning. - // Exempel på fix (läggs till i fas 3): - // const pwRegex = /^(?=.*[0-9])(?=.*[!@#$%])[A-Za-z0-9!@#$%]{8,}$/; - // if (!pwRegex.test(password)) return res.status(400).json({ error: "Weak password" }); - + // SÄKERHET [Krav 2]: bcrypt med kostnadsfaktor 10 – lösenordet lagras aldrig i klartext + // Även om databasen läcker kan lösenorden inte läsas direkt (STRIDE: Information Disclosure) const hashedPassword = await bcrypt.hash(password, 10) const user = new User({ username: username.trim(), email, password: hashedPassword }) await user.save() - // 🔐 SÄKERHETSKRAV 1: - // Token lever 2h → ska ändras till 30m enligt kravspecifikation. - // Notering: JWT är inte inaktivitetsbaserad, bara fast livslängd. + // SÄKERHET [Krav 1]: Token giltig i 30 minuter – användaren loggas ut automatiskt efter det + // Minskar risken om någon lämnar datorn obevakad (STRIDE: Spoofing, Elevation of Privilege) const accessToken = jwt.sign( { userId: user._id, username: user.username }, process.env.JWT_SECRET, - { expiresIn: "30m" } // ÄNDRAS TILL "30m" + { expiresIn: "30m" } ) res.status(201).json({ @@ -83,7 +106,8 @@ app.post("/register", async (req, res) => { } }) -app.post("/login", async (req, res) => { +// SÄKERHET [Krav 3]: loginLimiter appliceras här – begränsar inloggningsförsök per IP +app.post("/login", loginLimiter, async (req, res) => { try { const { login, password } = req.body const user = await User.findOne({ @@ -107,12 +131,11 @@ app.post("/login", async (req, res) => { }) } - // 🔐 SÄKERHETSKRAV 1: - // Samma ändring som i /register — ändra 2h → 30m. + // SÄKERHET [Krav 1]: Token giltig i 30 minuter – uppfyller kravet om automatisk utloggning const accessToken = jwt.sign( { userId: user._id, username: user.username }, process.env.JWT_SECRET, - { expiresIn: "30m" } // ÄNDRAS TILL "30m" + { expiresIn: "30m" } ) res.json({ @@ -135,6 +158,8 @@ app.post("/login", async (req, res) => { const isValidId = (id) => mongoose.Types.ObjectId.isValid(id) +// SÄKERHET [Krav 4 – notering]: GET /messages kräver ingen inloggning +// Alla kan läsa meddelanden – om appen ska vara privat bör authenticateUser läggas till här app.get("/messages", async (req, res) => { try { // 🔐 SÄKERHETSKRAV 4: @@ -152,6 +177,8 @@ app.get("/messages", async (req, res) => { } }) +// SÄKERHET [Krav 4]: authenticateUser krävs – bara inloggade användare kan skapa meddelanden +// Skyddar Zon 1 → Zon 2 (STRIDE: Tampering, Elevation of Privilege) app.post("/messages", authenticateUser, async (req, res) => { const message = new Message({ message: req.body.message, user: req.user._id }) try { @@ -162,6 +189,8 @@ app.post("/messages", authenticateUser, async (req, res) => { } }) +// SÄKERHET [Krav 4]: authenticateUser + ägarskontroll – användaren kan bara redigera sina egna meddelanden +// message.user.toString() jämförs med req.user._id för att verifiera ägarskap (STRIDE: Tampering) app.patch("/messages/:id", authenticateUser, async (req, res) => { if (!isValidId(req.params.id)) return res.status(400).json({ error: "Invalid message ID" }) try { @@ -181,15 +210,26 @@ app.patch("/messages/:id", authenticateUser, async (req, res) => { } }) -app.delete("/messages/:id", async (req, res) => { - // 🔐 SÄKERHETSKRAV 4 — KRITISKT SÄKERHETSHÅL: - // authenticateUser saknas → vem som helst kan radera vilket meddelande som helst. - // Detta är Broken Access Control (OWASP #1). - // I fas 3 ska authenticateUser läggas här. +// SÄKERHET [Krav 4 – fix]: authenticateUser och ägarskontroll tillagda +// Originalfilen saknade helt auth här – vem som helst kunde radera vilket meddelande som helst +// utan att ens vara inloggad. Det är ett kritiskt säkerhetshål (STRIDE: Tampering, +// Elevation of Privilege, Zon 1 → Zon 2). +// +// Två separata säkerhetslager: +// 1. Autentisering (authenticateUser): Är du inloggad? – uppfyller Krav 4 +// 2. Auktorisering (ägarskontroll): Har du rätt att radera just det här meddelandet? +// Konsekvent med hur PATCH redan fungerade i originalfilen. +app.delete("/messages/:id", authenticateUser, async (req, res) => { if (!isValidId(req.params.id)) return res.status(400).json({ error: "Invalid message ID" }) try { const message = await Message.findById(req.params.id) if (!message) return res.status(404).json({ error: "Message not found" }) + + // SÄKERHET [Krav 4]: Ägarskontroll – användaren kan bara radera sina egna meddelanden + if (message.user.toString() !== req.user._id.toString()) { + return res.status(403).json({ error: "You can only delete your own messages" }) + } + await message.deleteOne() res.status(204).send() } catch (error) { From c6fe7e3ec6208e2a75de72ff2d54f616d907d681 Mon Sep 17 00:00:00 2001 From: Mario Date: Fri, 5 Jun 2026 10:25:39 +0200 Subject: [PATCH 05/14] Co-authored-by: rasmuswong-cloud Co-authored-by: salzha-sec --- 1-jun-practice-apis/index.html | 14 ------ 1-jun-practice-apis/script.js | 80 ---------------------------------- backend/package-lock.json | 16 +++++++ backend/package.json | 1 + frontend/src/App.jsx | 45 ++++++++++++++++--- kodguide-fil-for-fil.md | 1 + 6 files changed, 57 insertions(+), 100 deletions(-) delete mode 100644 1-jun-practice-apis/index.html delete mode 100644 1-jun-practice-apis/script.js diff --git a/1-jun-practice-apis/index.html b/1-jun-practice-apis/index.html deleted file mode 100644 index 770bd734..00000000 --- a/1-jun-practice-apis/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - API practice - - -

API practice

-
-
-
- - \ No newline at end of file diff --git a/1-jun-practice-apis/script.js b/1-jun-practice-apis/script.js deleted file mode 100644 index 3ef2b0af..00000000 --- a/1-jun-practice-apis/script.js +++ /dev/null @@ -1,80 +0,0 @@ -console.log("JS file is connected to HTML!") - -const starwarsCard = document.getElementById("starwars") -const rickandmortyCard = document.getElementById("rickandmorty") -const pokemonCard = document.getElementById("pokemon") - -// Star Wars (codealong) -const getStarWars = async () => { - try { - const response = await fetch("https://swapi.dev/api/people/1/") - // const response = await fetch("https://swapi.info/api/people/1") - - if (!response.ok) { - throw new Error(`Något gick fel: ${response.status}`) - } - - const data = await response.json() - - starwarsCard.innerHTML = ` -

Character name: PLACEHOLDER

- ` - } catch (error) { - starwarsCard.innerHTML = ` -

Något gick fel, försök igen senare

- ` - console.error(error) - } -} - -// Practice: Använd getStarWars som mall för att hämta data från Rick and Morty API eller Pokémon API. Visa karaktärens namn och bild i respektive "card". - -// Rick and Morty -const getRickAndMorty = async () => { - try { - const response = await fetch("https://rickandmortyapi.com/api/character/1") - - if (!response.ok) { - throw new Error(`Något gick fel: ${response.status}`) - } - - const data = await response.json() - - rickandmortyCard.innerHTML = ` -

Character name: PLACEHOLDER

- - ` - } catch (error) { - rickandmortyCard.innerHTML = ` -

Något gick fel, försök igen senare

- ` - console.error(error) - } -} - -// Pokémon -const getPokemon = async () => { - try { - const response = await fetch("https://pokeapi.co/api/v2/pokemon/1") - - if (!response.ok) { - throw new Error(`Något gick fel: ${response.status}`) - } - - const data = await response.json() - - pokemonCard.innerHTML = ` -

Character name: PLACEHOLDER

- - ` - } catch (error) { - pokemonCard.innerHTML = ` -

Något gick fel, försök igen senare

- ` - console.error(error) - } -} - -getStarWars() -getRickAndMorty() -getPokemon() diff --git a/backend/package-lock.json b/backend/package-lock.json index d8c4b3cd..ef895f16 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,6 +13,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "express-list-endpoints": "^6.0.0", + "express-rate-limit": "^7.5.0", "helmet": "^7.1.0", "jsonwebtoken": "8.5.1", "mongoose": "^8.4.0", @@ -619,6 +620,21 @@ "node": ">=10" } }, + "node_modules/express-rate-limit": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", + "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "^4.11 || 5 || ^5.0.0-beta.1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", diff --git a/backend/package.json b/backend/package.json index 1f47ffb6..cfabd7f2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "express-list-endpoints": "^6.0.0", + "express-rate-limit": "^7.5.0", "helmet": "^7.1.0", "jsonwebtoken": "8.5.1", "mongoose": "^8.4.0", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c668778f..5189a068 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,9 +1,12 @@ -import { useState, useEffect } from "react" +import { useState, useEffect, useCallback } from "react" import { BASE_URL } from "./api" import { PostMessage } from "./components/PostMessage" import { MessageList } from "./components/MessageList" import { AuthModal } from "./components/AuthModal" +const INACTIVITY_TIMEOUT_MS = 30 * 60 * 1000 +const ACTIVITY_EVENTS = ["click", "keydown", "scroll", "mousemove", "touchstart"] + export const App = () => { const [loading, setLoading] = useState(false) const [messageList, setMessageList] = useState([]) @@ -23,14 +26,43 @@ export const App = () => { fetchPosts() }, []) - const addNewPost = (newMessage) => { + const addNewPost = (newMessage) => { setMessageList([newMessage, ...messageList]) } - const handleUnauthorized = () => { + const logout = useCallback((message = null) => { setUser(null) - setError("Your session has expired, please log in again") - } + setError(message) + }, []) + + const handleUnauthorized = useCallback(() => { + logout("Your session has expired, please log in again") + }, [logout]) + + useEffect(() => { + if (!user) return undefined + + let inactivityTimer + + const resetInactivityTimer = () => { + clearTimeout(inactivityTimer) + inactivityTimer = setTimeout(() => { + logout("You were logged out after 30 minutes of inactivity") + }, INACTIVITY_TIMEOUT_MS) + } + + resetInactivityTimer() + ACTIVITY_EVENTS.forEach((eventName) => { + window.addEventListener(eventName, resetInactivityTimer, { passive: true }) + }) + + return () => { + clearTimeout(inactivityTimer) + ACTIVITY_EVENTS.forEach((eventName) => { + window.removeEventListener(eventName, resetInactivityTimer) + }) + } + }, [user, logout]) return ( <> @@ -38,7 +70,7 @@ export const App = () => {
{user.response.username} + + +
+ + + + + +
+
+

Fyra saker vi kan demonstrera

+
+
+ Krav 1 +

30 min inaktivitet

+

Frontend bevakar aktivitet och loggar ut användaren när sessionen varit tyst för länge.

+
+
+ Krav 2 +

Starka lösenord

+

Registrering kräver minst 8 tecken, siffra och specialtecken. Bcrypt sparar hash, inte klartext.

+
+
+ Krav 3 +

Rate limiting

+

Login skyddas med max antal försök per tidsfönster, så brute force blir mycket svårare.

+
+
+ Krav 4 +

Skyddade routes

+

Skapa, ändra och radera kräver token. Radera kontrollerar även att användaren äger meddelandet.

+
+
+
+ +
+

Redovisningsläge

+

Klicka på en knapp och använd texten som stöd när ni visar koden.

+
+ + + + + +
+
+ +
+
+ + + + From 821d316571c59c8392b31215592ed3e6c43f5432 Mon Sep 17 00:00:00 2001 From: Mario Date: Fri, 5 Jun 2026 11:51:20 +0200 Subject: [PATCH 07/14] Co-authored-by: rasmuswong-cloud Co-authored-by: salzha-sec --- .vscode/launch.json | 14 + redovisning-demo.html | 1157 +++++++++++++++++++++++++++++++---------- 2 files changed, 896 insertions(+), 275 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..ff7829c6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "msedge", + "request": "launch", + "name": "Open redovisning-demo.html", + "file": "c:\\Users\\mario\\Desktop\\projekt\\yh-message-app-fullstack\\redovisning-demo.html" + } + ] +} \ No newline at end of file diff --git a/redovisning-demo.html b/redovisning-demo.html index 67d35ebe..e5363514 100644 --- a/redovisning-demo.html +++ b/redovisning-demo.html @@ -3,443 +3,1050 @@ - Message App Security Demo + YH Message App Security Demo + +
-

YH Message App

+
Säkerhetskurs · Fas 2
+

YH Message App Security Demo

- En liten, glad meddelandeapp med stora säkerhetsvanor: starka lösenord, - bcrypt, JWT, rate limiting och skyddade routes. + Presentationen är inbyggd först, så ni kan gå igenom kodförståelse och säkerhetsgranskning + innan den praktiska demon börjar.

-
- - - -
-
-
-

Fyra saker vi kan demonstrera

-
-
- Krav 1 -

30 min inaktivitet

-

Frontend bevakar aktivitet och loggar ut användaren när sessionen varit tyst för länge.

-
-
- Krav 2 -

Starka lösenord

-

Registrering kräver minst 8 tecken, siffra och specialtecken. Bcrypt sparar hash, inte klartext.

-
-
- Krav 3 -

Rate limiting

-

Login skyddas med max antal försök per tidsfönster, så brute force blir mycket svårare.

-
-
- Krav 4 -

Skyddade routes

-

Skapa, ändra och radera kräver token. Radera kontrollerar även att användaren äger meddelandet.

-
-
+
+

Presentation

+
+ +
+
+ Slide 1 +

Säkerhetskurs Fas 2

+

Kodförståelse & säkerhetsgranskning av YH Message App.

+

Express + MongoDB + React · JWT Auth

+

Mario · Rasmus · Sally

+
+ +
+ Slide 2 +

Vad vi granskade

+
+
backend/server.jsAlla API-routes: register, login och meddelanden.
+
backend/middleware/auth.jsJWT-verifiering som skyddar privata routes.
+
backend/models/User.jsAnvändarschema där lösenord lagras som bcrypt-hash.
+
+

Fokus: backend = Zon 2 i ESTRID, där logik och säkerhetsbeslut sker.

+
+ +
+ Slide 3 +

Krav 1 · Automatisk utloggning

+
+
FÖRE
+expiresIn: "2h"
+
EFTER
+expiresIn: "30m"
+
+

JWT har fast giltighetstid. Riktig inaktivitetsutloggning kräver även klient- eller sessionslogik.

+

STRIDE: Spoofing · Elevation of Privilege

+
+ +
+ Slide 4 +

Krav 2 · Lösenordsvalidering

+

Bcrypt fanns, men lösenordet validerades inte innan hashning.

+
passwordRegex =
+/^(?=.*[0-9])(?=.*[!@#$%^&*])
+[A-Za-z0-9!@#$%^&*]{8,}$/
+

Validering måste ske innan hashning, annars kan svaga lösenord fortfarande sparas säkert men vara enkla att gissa.

+
+ +
+ Slide 5 +

Krav 3 · Rate limiting

+

Utan rate limiting kan en angripare prova många lösenord snabbt.

+
const loginLimiter = rateLimit({
+  windowMs: 15 * 60 * 1000,
+  max: 10
+})
+
+app.post("/login", loginLimiter, async (...))
+

I live-demon är gränsen 5 försök för att det ska gå snabbt att visa.

+
+ +
+ Slide 6 +

Kritiskt säkerhetshål

+

DELETE saknade autentisering i originalet. Vem som helst kunde radera meddelanden.

+
app.delete("/messages/:id",
+  authenticateUser,
+  async (req, res) => {
+    // ägarskontroll
+  }
+)
+

STRIDE: Tampering · Elevation of Privilege

+
+ +
+ Slide 7 +

Sammanfattning

+
+
+ Krav 1 · Utloggning + Original: token 2h + Fix: token 30 min + demo för inaktivitet +
+
+ Krav 2 · Lösenord + Original: bcrypt men ingen styrka + Fix: bcrypt + regex-validering +
+
+ Krav 3 · Rate limiting + Original: saknades + Fix: express-rate-limit på /login +
+
+ Krav 4 · Auth + Original: DELETE saknade auth + Fix: auth + ägarskontroll +
+
+

Viktigaste fynd: DELETE utan auth = Broken Access Control.

+
-
-

Redovisningsläge

-

Klicka på en knapp och använd texten som stöd när ni visar koden.

-
- - - - - -
-
- +
+

Praktisk demo

+
+ +
+
1. Lösenord
+
2. Rate limit
+
3. Edit/Delete
+
4. Auto logout
+
+ +
+
+ Demo 1 +

Starkt lösenord

+ +
+
Minst 8 tecken
+
Minst en siffra
+
Minst ett specialtecken
+
+
Testa lösenordet ovan.
+
Steg 2 är låst tills lösenordet är godkänt.
+
+ +
+ Demo 2 +

Login med rate limiting

+

Ange lösenordet Solen123!. Fem felaktiga försök låser och stänger sidan.

+ +
+ + +
+ +
5 försök kvar.
+
Låst. Klara demo 1 först.
+
+ +
+ Demo 3 +

Endast inloggad ägare får edit/delete

+
+ + + + +
+
+ Ägare: Mario +

Det här meddelandet får bara Mario ändra eller radera.

+
+ + + +
+
+
Du är inloggad som Mario.
+
Låst. Klara demo 2 först.
+
+ +
+ Demo 4 +

Automatisk utloggning

+

Redovisningsläge: 30 minuter visas som 30 sekunder så ni hinner visa funktionen live.

+
+ + +
+ +
Starta timern för att visa auto logout.
+
Låst. Klara demo 3 först.
+
From 3e3996539d4f44c2d2faf23b3291173149debf29 Mon Sep 17 00:00:00 2001 From: Mario Date: Sun, 7 Jun 2026 22:09:16 +0200 Subject: [PATCH 08/14] =?UTF-8?q?=C3=84ndrat=20hemsidan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- redovisning-demo.html | 871 ++++++++++++++++++++++-------------------- 1 file changed, 464 insertions(+), 407 deletions(-) diff --git a/redovisning-demo.html b/redovisning-demo.html index e5363514..d898b9c4 100644 --- a/redovisning-demo.html +++ b/redovisning-demo.html @@ -6,10 +6,10 @@ YH Message App Security Demo @@ -541,295 +576,296 @@ -
-
-
Säkerhetskurs · Fas 2
-

YH Message App Security Demo

-

- Presentationen är inbyggd först, så ni kan gå igenom kodförståelse och säkerhetsgranskning - innan den praktiska demon börjar. -

-
- - -
- -
-
-

Presentation

-
+
Slide 1 -

Säkerhetskurs Fas 2

-

Kodförståelse & säkerhetsgranskning av YH Message App.

-

Express + MongoDB + React · JWT Auth

-

Mario · Rasmus · Sally

+

Säkerhetskurs Fas 2

+

Kodförståelse och säkerhetsgranskning av YH Message App.

+

Express + MongoDB + React · JWT Auth

+

Mario · Rasmus · Sally

Slide 2 -

Vad vi granskade

+

Vad vi granskade

-
backend/server.jsAlla API-routes: register, login och meddelanden.
+
backend/server.jsAPI-routes för register, login och meddelanden.
backend/middleware/auth.jsJWT-verifiering som skyddar privata routes.
-
backend/models/User.jsAnvändarschema där lösenord lagras som bcrypt-hash.
+
backend/models/User.jsAnvändarschema där lösenord lagras som bcrypt-hash.
-

Fokus: backend = Zon 2 i ESTRID, där logik och säkerhetsbeslut sker.

+

Fokus: backend = Zon 2 i ESTRID, där logik och säkerhetsbeslut sker.

Slide 3 -

Krav 1 · Automatisk utloggning

+

Krav 1 · Automatisk utloggning

-
FÖRE
+            
FÖRE
 expiresIn: "2h"
EFTER
 expiresIn: "30m"
-

JWT har fast giltighetstid. Riktig inaktivitetsutloggning kräver även klient- eller sessionslogik.

-

STRIDE: Spoofing · Elevation of Privilege

+

JWT har fast giltighetstid. Riktig inaktivitetsutloggning kräver även klient- eller sessionslogik.

+

STRIDE: Spoofing · Elevation of Privilege

Slide 4 -

Krav 2 · Lösenordsvalidering

-

Bcrypt fanns, men lösenordet validerades inte innan hashning.

+

Krav 2 · Lösenordsvalidering

+

Bcrypt fanns, men lösenordet validerades inte innan hashning.

passwordRegex =
 /^(?=.*[0-9])(?=.*[!@#$%^&*])
 [A-Za-z0-9!@#$%^&*]{8,}$/
-

Validering måste ske innan hashning, annars kan svaga lösenord fortfarande sparas säkert men vara enkla att gissa.

+

Validering måste ske innan hashning. Annars kan svaga lösenord sparas säkert men vara enkla att gissa.

Slide 5 -

Krav 3 · Rate limiting

-

Utan rate limiting kan en angripare prova många lösenord snabbt.

+

Krav 3 · Rate limiting

+

Utan rate limiting kan en angripare prova många lösenord snabbt.

const loginLimiter = rateLimit({
   windowMs: 15 * 60 * 1000,
   max: 10
 })
 
 app.post("/login", loginLimiter, async (...))
-

I live-demon är gränsen 5 försök för att det ska gå snabbt att visa.

+

I live-demon är gränsen 5 försök för att funktionen ska gå snabbt att visa.

Slide 6 -

Kritiskt säkerhetshål

+

Kritiskt säkerhetshål

DELETE saknade autentisering i originalet. Vem som helst kunde radera meddelanden.

app.delete("/messages/:id",
   authenticateUser,
   async (req, res) => {
-    // ägarskontroll
+    // ägarskontroll
   }
 )
-

STRIDE: Tampering · Elevation of Privilege

+

STRIDE: Tampering · Elevation of Privilege

Slide 7 -

Sammanfattning

+

Sammanfattning

- Krav 1 · Utloggning + Krav 1 · Utloggning Original: token 2h - Fix: token 30 min + demo för inaktivitet + Fix: token 30 min + demo för inaktivitet
- Krav 2 · Lösenord + Krav 2 · Lösenord Original: bcrypt men ingen styrka Fix: bcrypt + regex-validering
- Krav 3 · Rate limiting + Krav 3 · Rate limiting Original: saknades - Fix: express-rate-limit på /login + Fix: express-rate-limit på /login
- Krav 4 · Auth + Krav 4 · Auth Original: DELETE saknade auth - Fix: auth + ägarskontroll + Fix: auth + ägarskontroll

Viktigaste fynd: DELETE utan auth = Broken Access Control.

-
- -
-

Praktisk demo

-
-
1. Lösenord
-
2. Rate limit
-
3. Edit/Delete
-
4. Auto logout
-
+
+ -
-
- Demo 1 -

Starkt lösenord

- -
-
Minst 8 tecken
-
Minst en siffra
-
Minst ett specialtecken
-
-
Testa lösenordet ovan.
-
Steg 2 är låst tills lösenordet är godkänt.
-
+
+
+
+

Praktisk demo

+

Här kan ni manuellt visa varje säkerhetskrav utan att behöva starta backend eller frontend.

+
+
-
- Demo 2 -

Login med rate limiting

-

Ange lösenordet Solen123!. Fem felaktiga försök låser och stänger sidan.

- -
- - +
+
- -
- Demo 3 -

Endast inloggad ägare får edit/delete

-
- - - - +
+
1. Visa starkt och svagt lösenord.
+
2. Testa fel login fem gånger.
+
3. Byt användare och prova edit/delete.
+
4. Starta timer och vänta på auto logout.
-
- Ägare: Mario -

Det här meddelandet får bara Mario ändra eller radera.

-
- - - + + + +
+
+ Manuell funktion 1 +

Lösenordsvalidering

+ +
+ +
-
-
Du är inloggad som Mario.
-
Låst. Klara demo 2 först.
-
- -
- Demo 4 -

Automatisk utloggning

-

Redovisningsläge: 30 minuter visas som 30 sekunder så ni hinner visa funktionen live.

-
- - -
- -
Starta timern för att visa auto logout.
-
Låst. Klara demo 3 först.
-
+
+
Minst 8 tecken
+
Minst en siffra
+
Minst ett specialtecken
+
+
Testa lösenordet ovan.
+ + +
+ Manuell funktion 2 +

Login med rate limiting

+

Ange lösenordet Solen123!. Fem felaktiga försök låser sidan.

+ +
+ + + +
+ +
5 försök kvar.
+
+ +
+ Manuell funktion 3 +

Auth och ägarskap

+
+ + + + +
+
+ Ägare: Mario +

Det här meddelandet får bara Mario ändra eller radera.

+
+ + + +
+
+
Du är inloggad som Mario.
+
+ +
+ Manuell funktion 4 +

Automatisk utloggning

+

Redovisningsläge: 30 minuter visas som 30 sekunder så funktionen kan visas live.

+
+ + + +
+ +
Starta timern för att visa auto logout.
+
+