CamberStack escaped quotes when its digest email field was a string. The same field also accepted a nested query parameter, and its key was rendered as raw input attributes—enough for reflected XSS on a public page.
Testing an input for cross-site scripting usually starts with its value. If the value lands inside value="...", I try a quote, close the attribute and add an event handler. Encoding the quote finishes that specific payload, but it does not show that every accepted parameter shape reaches the same renderer.
This lab was inspired from an issue I found in a pentest. Framework-style parameter parsers can turn bracketed names into arrays or objects. A server expecting digest[email] as a string may also accept digest[email][key]. This is parameter type confusion: validation expects a string, while the renderer also accepts an object-shaped version of the same field. If validation and rendering do not enforce the same type, attacker-controlled data can reach the page through an object key instead of the value the developer escaped.
Note
What is reflected XSS? Cross-site scripting occurs when attacker-controlled input is included in a page and the browser executes it as script. In a reflected XSS, the input arrives in the request and is returned in the immediate response rather than stored for later. The output context matters: text inside an HTML attribute needs different handling from text in an element body, URL or JavaScript string.
CamberStack demonstrates this failure in a weekly forum-digest form. The page is public and the form uses GET, so a failed submission leaves the complete state in a shareable URL. When the email address is invalid, CamberStack returns the digest page with the submitted value preserved in the email field.
Testing quote escaping in the email attribute
I started with the direct attribute-breakout payload:
not-an-email" autofocus onfocus=alert(1)
An equivalent encoded request is:
GET /digest?context=weekly-digest&digest%5Bemail%5D=not-an-email%22%20autofocus%20onfocus%3Dalert%281%29&digest%5Bconsent%5D=yes HTTP/1.1
Host: target
CamberStack rejects the address and preserves the whole string, but the response source contains an encoded quote:
<input
name="digest[email]"
type="text"
value="not-an-email" autofocus onfocus=alert(1)"
aria-invalid="true"
/>
The browser decodes " as part of the input’s value. It does not treat the quote as markup, so neither autofocus nor onfocus becomes a separate attribute. This is the correct result for a scalar string in a quoted HTML attribute.
The request still gave me a useful lead. The field is named digest[email], which suggests that the server understands bracket notation. I changed the parameter’s shape rather than adding more characters to its value:
digest[email][disabled]=y
The returned email field was disabled. That behavior cannot come from text inside value; the nested key had become an HTML attribute.
How a nested parameter becomes HTML attributes
The final payload puts three attributes in the nested key:
digest[email][autofocus onfocus=alert(1); data-x]=x
data-x has no security significance. It gives the renderer somewhere harmless to attach the trailing ="" that it adds to every key. Without it, the generated handler ends in alert(1);="", which is invalid JavaScript.
The complete URL-encoded request is:
GET /digest?context=weekly-digest&digest%5Bemail%5D%5Bautofocus%20onfocus%3Dalert%281%29%3B%20data-x%5D=x&digest%5Bconsent%5D=yes HTTP/1.1
Host: target
The relevant response fragment is:
<input
name="digest[email]"
type="text"
value="x"
autofocus
onfocus=alert(1);
data-x=""
aria-invalid="true"
/>
When this page opens as a normal top-level document, autofocus focuses the email input and the browser runs the injected onfocus handler. The lab application is displayed inside a cross-origin frame, where Chrome does not grant autofocus, so the learner clicks the field to trigger the same handler.
The attack is reflected XSS because the script comes from the query string and executes in CamberStack’s origin in the returned page. The attacker does not need a CamberStack account, but a victim still has to open the crafted URL. If that victim is signed in, the script can read data and send same-origin requests within the permissions of the victim’s session. The alert proves script execution; it does not by itself prove account takeover or server-side code execution.
The vulnerable renderer trusts an object key
In lib/digest.ts, CamberStack’s parser accepts either a scalar email or nested email entries. This branch extracts the text between the final brackets and keeps it as a key:
for (const [key, value] of Object.entries(params)) {
const match = /^digest\[email\]\[([\s\S]+)\]$/.exec(key);
if (match) {
entries.push([match[1] ?? "", asString(value)]);
}
}
The scalar path is escaped correctly. The object path turns each nested key into an attribute name:
function preservedEmailAttributes(email: EmailShape): HtmlAttribute[] {
if (email.kind === "scalar") {
return [{ name: "value", value: email.value }];
}
if (email.kind === "object") {
const primaryValue = email.entries[0]?.[1] ?? "";
const restoredAttributes = email.entries.map(([name]) => ({ name, value: "" }));
return [{ name: "value", value: primaryValue }, ...restoredAttributes];
}
return [{ name: "value", value: "" }];
}
The serializer encodes attribute values but interpolates attribute names directly:
function serializeAttributes(attributes: HtmlAttribute[]) {
return attributes
.map((attribute) => `${attribute.name}="${escapeAttribute(attribute.value)}"`)
.join(" ");
}
This explains both observations. A quote in the scalar email becomes ". A nested key becomes the attribute.name expression, where spaces and onfocus=... are copied into the markup without validation.
The validation error does not protect the page. CamberStack correctly decides that the object is not a valid email, then hands the rejected object to the vulnerable renderer so the form can preserve it. Validation and output encoding are solving different problems, and the render path still has to be safe for invalid input.
The digest page then inserts the completed string as HTML:
<div
dangerouslySetInnerHTML={{ __html: renderDigestEmailInputHtml(state) }}
/>
Fix the type before rendering
The email field has one legitimate shape: a string. The request boundary should reject nested variants before the value reaches validation or redisplay:
const nestedEmailKeys = Object.keys(params).some((key) =>
key.startsWith("digest[email][")
);
const rawEmail = params["digest[email]"];
if (nestedEmailKeys || typeof rawEmail !== "string") {
return { email: "", error: "Enter a valid email address." };
}
The page should also stop building an input as an HTML string. Once email is guaranteed to be a string, React can render the fixed attribute names and escape the value for the HTML attribute context:
<input
id="digest-email"
name="digest[email]"
type="text"
autoComplete="email"
defaultValue={email}
/>
OWASP’s XSS prevention guidance treats framework output encoding and hardcoded, innocuous attribute names as safe sinks. The safe boundary in this form is a hardcoded attribute name plus a framework-encoded string value. Encoding an attribute value cannot make an attacker-controlled attribute name safe.
When I see bracket notation in a form, I test the field as more than one type. I send the expected scalar first, then an array or nested object, and compare the validation, persistence and redisplay paths. In this case, changing the parameter shape reached a renderer that ordinary quote-based XSS testing never exercised.
