Skip to content

Commit f4fcb50

Browse files
committed
added injection in URL and in the write/update mode
1 parent b46a44b commit f4fcb50

10 files changed

Lines changed: 749 additions & 12 deletions

File tree

Resume.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
2+
## What was implemented
3+
4+
This project now demonstrates both read and write NoSQL injection in two modes:
5+
6+
- `vuln` mode: intentionally unsafe behavior for learning.
7+
- `secure` mode: validated and ownership-safe behavior.
8+
9+
Main additions made:
10+
11+
1. Added profile fields to user records (`age`, `phoneNumber`, `bio`, `department`) and updated seeding for all users.
12+
2. Added profile retrieval endpoints in both modes using URL query parameters.
13+
3. Added profile update endpoints in both modes (`POST`/`PUT`) to demonstrate write injection.
14+
4. Updated the website search page with a profile editor (view + update) and raw JSON payload testing.
15+
5. Kept URL-bar testing support for profile query injection.
16+
17+
## How it works
18+
19+
### Read injection
20+
21+
- Vulnerable endpoint takes query params directly and passes them to Mongo filters.
22+
- Example attack style: `email[$ne]=wassim@cns.ensia`.
23+
- Result: attackers can query unintended records.
24+
25+
Secure endpoint behavior:
26+
27+
- Only allows safe filter keys.
28+
- Requires string values for filters.
29+
- Enforces user ownership for non-admin sessions.
30+
31+
### Write injection
32+
33+
- Vulnerable update endpoint builds selector from request body and applies updates with weak checks.
34+
- This enables:
35+
- Operator injection in selectors (`$eq`, `$ne`, etc.).
36+
- Property injection in updates (for example changing `role`).
37+
38+
Secure update endpoint behavior:
39+
40+
- Uses strict allowlist for updatable fields.
41+
- Blocks sensitive fields like `role`, `password`, `passwordHash`, `_id`.
42+
- Validates types/ranges/lengths.
43+
- Always updates only the logged-in user's profile.
44+
45+
## Quick demo payload (vuln mode)
46+
47+
Use raw JSON update payload:
48+
49+
```json
50+
{
51+
"email": { "$eq": "wassim@cns.ensia" },
52+
"role": "admin",
53+
"bio": "Injected write demo"
54+
}
55+
```
56+
57+
Expected in `vuln`: update can escalate privileges.
58+
Expected in `secure`: request is rejected.
59+
60+
## Tips
61+
62+
1. Always run `npm run seed` before demos to reset the lab state.
63+
2. Keep one terminal running `npm run dev` during all tests.
64+
3. Test the same payload in both `vuln` and `secure` modes to clearly show the difference.
65+
4. For screenshots/reporting, capture: login result, payload used, server response, and final profile state.
66+
5. After any write-injection test, reseed to avoid carrying modified roles into the next test.
67+

website/public/css/search.css

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,86 @@ body {
7272
margin-bottom: 0.75rem;
7373
}
7474

75+
.profile-editor {
76+
border: 1px solid #333;
77+
background: #efefef;
78+
padding: 0.75rem;
79+
margin-bottom: 0.75rem;
80+
}
81+
82+
.profile-editor h2 {
83+
margin: 0 0 0.6rem;
84+
font-size: 1.15rem;
85+
}
86+
87+
.profile-form {
88+
display: flex;
89+
flex-direction: column;
90+
gap: 0.6rem;
91+
}
92+
93+
.profile-grid {
94+
display: grid;
95+
grid-template-columns: repeat(2, minmax(0, 1fr));
96+
gap: 0.55rem;
97+
}
98+
99+
.profile-grid label {
100+
display: flex;
101+
flex-direction: column;
102+
gap: 0.2rem;
103+
font-size: 0.9rem;
104+
font-weight: bold;
105+
}
106+
107+
.profile-grid input,
108+
.profile-grid textarea {
109+
padding: 0.4rem;
110+
font-family: "Times New Roman", serif;
111+
font-size: 0.95rem;
112+
border: 1px solid #333;
113+
background: #fff;
114+
}
115+
116+
.profile-bio-row {
117+
grid-column: 1 / -1;
118+
}
119+
120+
.profile-raw {
121+
width: 100%;
122+
box-sizing: border-box;
123+
padding: 0.45rem;
124+
font-family: "Courier New", monospace;
125+
font-size: 0.85rem;
126+
border: 1px solid #333;
127+
background: #fff;
128+
resize: vertical;
129+
}
130+
131+
.profile-actions {
132+
display: flex;
133+
gap: 0.5rem;
134+
flex-wrap: wrap;
135+
}
136+
137+
.profile-actions button {
138+
padding: 0.45rem 0.9rem;
139+
font-family: "Times New Roman", serif;
140+
font-size: 1rem;
141+
background: #2a2a2a;
142+
color: #fff;
143+
border: 1px solid #111;
144+
cursor: pointer;
145+
}
146+
147+
.profile-actions button:hover {
148+
background: #1a1a1a;
149+
}
150+
151+
.hidden {
152+
display: none;
153+
}
154+
75155
.form-group {
76156
display: flex;
77157
gap: 0.5rem;
@@ -316,6 +396,10 @@ body {
316396
grid-template-columns: 1fr;
317397
}
318398

399+
.profile-grid {
400+
grid-template-columns: 1fr;
401+
}
402+
319403
.response-panel {
320404
position: static;
321405
max-height: none;

website/public/js/search.js

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ const logoutBtn = document.getElementById("logout-btn");
88
const badge = document.getElementById("mode-badge");
99
const queryInput = document.getElementById("query");
1010
const sendJsonCheckbox = document.getElementById("send-json");
11+
const profileForm = document.getElementById("profile-form");
12+
const profileRefreshBtn = document.getElementById("profile-refresh-btn");
13+
const profileEmailInput = document.getElementById("profile-email");
14+
const profileAgeInput = document.getElementById("profile-age");
15+
const profilePhoneInput = document.getElementById("profile-phone");
16+
const profileDepartmentInput = document.getElementById("profile-department");
17+
const profileBioInput = document.getElementById("profile-bio");
18+
const profileRawModeCheckbox = document.getElementById("profile-raw-mode");
19+
const profileRawPayloadInput = document.getElementById("profile-raw-payload");
1120
const materialIcons = {
1221
pdf: "/assets/pdf.png",
1322
video: "/assets/video.png",
@@ -21,6 +30,65 @@ function showResponse(payload) {
2130
responseOutput.textContent = JSON.stringify(payload, null, 2);
2231
}
2332

33+
function clearProfileForm() {
34+
profileEmailInput.value = "";
35+
profileAgeInput.value = "";
36+
profilePhoneInput.value = "";
37+
profileDepartmentInput.value = "";
38+
profileBioInput.value = "";
39+
}
40+
41+
function fillProfileForm(profile) {
42+
if (!profile || typeof profile !== "object") {
43+
clearProfileForm();
44+
return;
45+
}
46+
47+
profileEmailInput.value = typeof profile.email === "string" ? profile.email : "";
48+
profileAgeInput.value = Number.isFinite(profile.age) ? String(profile.age) : "";
49+
profilePhoneInput.value = typeof profile.phoneNumber === "string" ? profile.phoneNumber : "";
50+
profileDepartmentInput.value =
51+
typeof profile.department === "string" ? profile.department : "";
52+
profileBioInput.value = typeof profile.bio === "string" ? profile.bio : "";
53+
54+
const exampleEmail = typeof profile.email === "string" ? profile.email : "user@example.com";
55+
profileRawPayloadInput.placeholder = JSON.stringify(
56+
{
57+
email: { $eq: exampleEmail },
58+
role: "admin",
59+
bio: "Injected write demo",
60+
},
61+
null,
62+
2
63+
);
64+
}
65+
66+
function buildProfileFormPayload() {
67+
const payload = {};
68+
69+
if (profileEmailInput.value.trim() !== "") {
70+
payload.email = profileEmailInput.value.trim();
71+
}
72+
73+
if (profileAgeInput.value.trim() !== "") {
74+
payload.age = Number(profileAgeInput.value);
75+
}
76+
77+
if (profilePhoneInput.value.trim() !== "") {
78+
payload.phoneNumber = profilePhoneInput.value.trim();
79+
}
80+
81+
if (profileDepartmentInput.value.trim() !== "") {
82+
payload.department = profileDepartmentInput.value.trim();
83+
}
84+
85+
if (profileBioInput.value.trim() !== "") {
86+
payload.bio = profileBioInput.value.trim();
87+
}
88+
89+
return payload;
90+
}
91+
2492
function escapeHtml(value) {
2593
return String(value)
2694
.replace(/&/g, "&")
@@ -165,6 +233,50 @@ async function fetchJson(url, options = {}) {
165233
return { response, payload };
166234
}
167235

236+
function getProfileQueryStringFromPageUrl() {
237+
const params = new URLSearchParams(window.location.search);
238+
const profileParams = new URLSearchParams();
239+
240+
for (const [key, value] of params.entries()) {
241+
if (key === "mode") {
242+
continue;
243+
}
244+
245+
const isProfileFilterKey =
246+
key === "username" ||
247+
key === "email" ||
248+
key.startsWith("username[") ||
249+
key.startsWith("email[");
250+
251+
if (isProfileFilterKey) {
252+
profileParams.append(key, value);
253+
}
254+
}
255+
256+
return profileParams.toString();
257+
}
258+
259+
async function loadProfilesFromPageUrlQuery() {
260+
const rawQueryString = getProfileQueryStringFromPageUrl();
261+
if (!rawQueryString) {
262+
return;
263+
}
264+
265+
const url = `/api/${mode}/profile?${rawQueryString}`;
266+
const { payload } = await fetchJson(url, { method: "GET" });
267+
showResponse(payload);
268+
}
269+
270+
async function loadCurrentProfile(showPayload = false) {
271+
const { payload } = await fetchJson(`/api/${mode}/profile`, { method: "GET" });
272+
if (showPayload) {
273+
showResponse(payload);
274+
}
275+
276+
const profile = Array.isArray(payload.results) && payload.results.length > 0 ? payload.results[0] : null;
277+
fillProfileForm(profile);
278+
}
279+
168280
async function ensureAuthenticated() {
169281
try {
170282
const { response, payload } = await fetchJson(`/api/${mode}/me`);
@@ -245,9 +357,58 @@ document.getElementById("search-form").addEventListener("submit", async (event)
245357
}
246358
});
247359

360+
profileRawModeCheckbox.addEventListener("change", () => {
361+
profileRawPayloadInput.classList.toggle("hidden", !profileRawModeCheckbox.checked);
362+
});
363+
364+
profileRefreshBtn.addEventListener("click", async () => {
365+
try {
366+
await loadCurrentProfile(true);
367+
} catch (error) {
368+
showResponse({ ok: false, mode, message: error.message });
369+
}
370+
});
371+
372+
profileForm.addEventListener("submit", async (event) => {
373+
event.preventDefault();
374+
375+
let payload;
376+
if (profileRawModeCheckbox.checked) {
377+
try {
378+
payload = JSON.parse(profileRawPayloadInput.value);
379+
} catch (error) {
380+
showResponse({
381+
ok: false,
382+
mode,
383+
message: `Invalid JSON payload: ${error.message}`,
384+
});
385+
return;
386+
}
387+
} else {
388+
payload = buildProfileFormPayload();
389+
}
390+
391+
try {
392+
const { payload: responsePayload } = await fetchJson(`/api/${mode}/profile`, {
393+
method: "PUT",
394+
headers: { "Content-Type": "application/json" },
395+
body: JSON.stringify(payload),
396+
});
397+
398+
showResponse(responsePayload);
399+
if (responsePayload && responsePayload.ok) {
400+
await loadCurrentProfile(false);
401+
}
402+
} catch (error) {
403+
showResponse({ ok: false, mode, message: error.message });
404+
}
405+
});
406+
248407
(async () => {
249408
const authenticated = await ensureAuthenticated();
250409
if (authenticated) {
251410
await loadItems();
411+
await loadCurrentProfile(false);
412+
await loadProfilesFromPageUrlQuery();
252413
}
253414
})();

0 commit comments

Comments
 (0)