Bug
[IDOR / Broken Access Control] WebPortal viewimage controller serves any company's documents to any authenticated portal customer in Dolibarr @ d7b1bc7
Summary
The Dolibarr WebPortal module (a customer-facing portal, stable/default module, version='dolibarr') ships a viewimage controller that streams files from the server's documents directory. Access is decided exclusively by dol_check_secure_access_document($modulepart, $file, $entity, $user, …), evaluated against the single internal "technical" user (WEBPORTAL_USER_LOGGED) that the portal runs as. The per-customer ownership filter returned by that function (sqlprotectagainstexternals, which restricts files to fk_soc = $user->socid) is applied only when $user->socid > 0. Because the technical user is an internal account (socid = 0), that branch is dead code, and the controller never consults the actually-logged-in customer ($context->logged_thirdparty).
Net effect: any authenticated portal customer can read any other company's documents (invoices, orders, proposals, contracts, ticket attachments, member files, …) of a previewable type — including PDF, which is the storage format of all those business documents — simply by guessing/iterating object references. This is a cross-tenant confidentiality breach (IDOR). It is post-auth (a low-privilege portal customer account) and works in the documented portal configuration.
Technical Detail
Root cause
htdocs/webportal/controllers/viewimage.controller.class.php, init() (audited @ d7b1bc79):
// htdocs/webportal/controllers/viewimage.controller.class.php:82
global $conf, $hookmanager, $user; // $user == the WebPortal technical user
...
// :116-122
$action = GETPOST('action', 'aZ09');
$original_file = GETPOST('file', 'alphanohtml');
$modulepart = GETPOST('modulepart', 'alpha', 1); // attacker-controlled
$entity = (GETPOSTINT('entity') ? GETPOSTINT('entity') : $conf->entity);
...
// :209-214 -- access decision delegated entirely to dol_check_secure_access_document
} elseif (empty($reshook)) {
$check_access = dol_check_secure_access_document($modulepart, $original_file, $entity, $user, $refname);
$accessallowed = $check_access['accessallowed'];
$sqlprotectagainstexternals = $check_access['sqlprotectagainstexternals'];
$fullpath_original_file = $check_access['original_file'];
}
// :216-238 -- the per-customer ownership filter
if (!empty($hashp)) {
$accessallowed = 1;
$sqlprotectagainstexternals = '';
} else {
// Basic protection (against external users only)
if ($user->socid > 0) { // <-- technical user socid == 0 => NEVER TRUE
if ($sqlprotectagainstexternals) {
$resql = $this->db->query($sqlprotectagainstexternals);
...
if ($user->socid != $obj->fk_soc) { $accessallowed = 0; break; }
}
}
}
// :242
if (!$accessallowed) { accessforbidden(); }
And what dol_check_secure_access_document decides for, e.g., invoices (htdocs/core/lib/files.lib.php:3404):
} elseif (($modulepart == 'facture' || $modulepart == 'invoice') && !empty($conf->invoice->multidir_output[$entity])) {
if ($fuser->hasRight('facture', $lire) || preg_match('/^specimen/i', $original_file)) {
$accessallowed = 1; // depends only on the TECHNICAL user's rights
}
$original_file = $conf->invoice->multidir_output[$entity].'/'.$original_file;
$sqlprotectagainstexternals = "SELECT fk_soc ... FROM ...facture WHERE ref='".$db->escape($refname)."' ...";
} // owner filter returned, but caller never runs it
The function's design splits the decision in two: accessallowed (does this user have the module right?) and sqlprotectagainstexternals (does this external customer own the specific object?). The second half is the only thing that ties a file to a tenant, and the controller gates it behind $user->socid > 0.
In the WebPortal, all customers are served under one internal technical user (WEBPORTAL_USER_LOGGED), set as the global $user in htdocs/public/webportal/webportal.main.inc.php:
// webportal.main.inc.php:281-291
$user_id = getDolGlobalInt('WEBPORTAL_USER_LOGGED');
...
$result = $logged_user->fetch($user_id);
// :367-368
global $user;
$user = $context->logged_user; // internal user, socid = 0
So $user->socid is 0, the ownership branch is unreachable, and accessallowed stays 1 for every file the technical user is allowed to read — regardless of which customer is logged in. The customer's real identity, $context->logged_thirdparty, is never referenced in viewimage.controller.
This is a copy of the main-app htdocs/viewimage.php:290-323, which uses the identical if ($user->socid > 0) guard. The main app is safe because a real external customer authenticates with their own external user (socid = <their company>), so the guard engages. The WebPortal reused the code but changed the trust model (one shared internal user for all customers) without substituting an equivalent per-customer check.
The sibling document controller demonstrates the correct pattern and confirms this is an oversight rather than intended behaviour — it explicitly enforces all three gates (htdocs/webportal/controllers/document.controller.class.php:190-216):
if (getDolGlobalInt('WEBPORTAL_' . $moduleNameUpperEn . '_LIST_ACCESS')
&& in_array($type, array('application/pdf'))
&& ($context->logged_thirdparty && $context->logged_thirdparty->id > 0)
&& $context->logged_thirdparty->id == $socId) { // per-customer ownership
...
$ok = checkUserAccessToObject($tmpuser, array($obj->src_object_type), $obj->src_object_id, '', '', 'fk_soc');
$accessallowed = ($ok ? 1 : 0);
}
viewimage.controller has none of these checks: no WEBPORTAL_*_LIST_ACCESS gate, no logged_thirdparty == soc_id, no checkUserAccessToObject.
Trigger conditions
- Endpoint:
GET /public/webportal/index.php with controller=viewimage.
- The request must carry a valid WebPortal session cookie (any customer account; an unauthenticated request is redirected to the login controller via
webportal.main.inc.php:229-237).
- Parameters (all attacker-controlled, read with
GETPOST):
modulepart — e.g. facture/invoice, commande/order, propal, contract, ticket, member, …
file — relative path of the target document, e.g. FA2401-0099/FA2401-0099.pdf (the on-disk layout is <ref>/<ref>.pdf).
entity — optional, defaults to current entity.
- File extension must be "previewable" (
dolIsAllowedForPreview, functions.lib.php:13826) — the allow-list includes pdf, jpeg/png/gif/tiff/webp/avif/bmp, plain, css, webm, mp4. PDF is included, so all generated business documents are reachable.
- Configuration:
WEBPORTAL_USER_LOGGED has read right on the target module. This is satisfied in any working portal — the portal exposes ticket lists, member cards, invoice/order lists, etc., and its admin help text for this setting reads "This user is used to update cards" (htdocs/langs/en_US/website.lang:222), i.e. it is expected to hold read/write rights on the exposed modules.
Why existing defenses do not apply
GETPOST('file','alphanohtml') + .. stripping (lines 165-167, 248) — these prevent path traversal out of the documents tree. They do nothing about authorization: the attacker is reading a legitimate file inside the documents tree that simply belongs to another tenant.
sqlprotectagainstexternals (the ownership filter) — only executed inside if ($user->socid > 0). The portal's effective $user is internal (socid = 0), so it is never executed. This is the broken control.
dolIsAllowedForPreview — restricts file type, not owner; and PDF (the sensitive format) is allowed.
WEBPORTAL_*_LIST_ACCESS / checkUserAccessToObject — enforced by the document controller but entirely absent from viewimage, so the portal-level access policy is bypassed by choosing controller=viewimage instead of controller=document.
- Login requirement — present, but only establishes that a customer is logged in, not which resources they may read.
Proof of Concept
Setup
# A standard Dolibarr install with:
# - WebPortal module enabled
# - WEBPORTAL_USER_LOGGED = a user with at least 'Read invoices' (facture/lire) right
# - Two customer third-parties: Victim Inc (socid=2) and Attacker Ltd (socid=3),
# each with a website/portal account (SocieteAccount) the customer can log in with
# - At least one validated invoice for Victim Inc, e.g. ref FA2401-0099,
# with its generated PDF at documents/facture/FA2401-0099/FA2401-0099.pdf
Trigger
Authenticate to the portal as Attacker Ltd (its own account), keep the session cookie, then request a Victim Inc invoice:
GET /public/webportal/index.php?controller=viewimage&modulepart=facture&entity=1&file=FA2401-0099%2FFA2401-0099.pdf HTTP/1.1
Host: portal.example.com
Cookie: <WEBPORTAL session cookie of Attacker Ltd>
curl form:
curl -s -b "$ATTACKER_PORTAL_COOKIE" \
'https://portal.example.com/public/webportal/index.php?controller=viewimage&modulepart=facture&entity=1&file=FA2401-0099/FA2401-0099.pdf' \
-o stolen.pdf
file stolen.pdf # => "stolen.pdf: PDF document, ..." (another company's invoice)
Iterate file=FA2401-XXXX/FA2401-XXXX.pdf over the (sequential, guessable) invoice reference space to enumerate every company's invoices. Repeat with modulepart=order|propal|contract|ticket|member to harvest other document classes.
Observable result
The server responds 200 OK, Content-Disposition: inline; filename="FA2401-0099.pdf", and the body is Victim Inc's invoice PDF — even though the authenticated session belongs to Attacker Ltd, who has no relationship to that invoice.
Negative control
The same request through the correctly-guarded document controller is rejected (accessforbidden) because $context->logged_thirdparty->id (3) != soc_id:
GET /public/webportal/index.php?controller=document&modulepart=facture&soc_id=2&file=FA2401-0099/FA2401-0099.pdf
=> 403 / accessforbidden
And the same viewimage request issued without a portal session is redirected to the login controller (proves the bug is post-auth, not pre-auth).
Note: I could not stand up a full Dolibarr instance with a configured WebPortal + database in this read-only audit environment, so the PoC is provided as paste-ready steps. The decision path is fully static-traceable from the cited lines: accessallowed is set to 1 by files.lib.php:3406/3716/... whenever the technical user holds the module right, and the only owner-scoping branch (viewimage.controller:221) is statically unreachable for an internal user (socid == 0).
Impact
- Direct primitive: Cross-tenant arbitrary read of stored documents (previewable types, incl. PDF) within the Dolibarr documents tree, by any authenticated portal customer.
- Chain potential (per
05-chain-analysis.md): IDOR → lateral access to every other customer's invoices, orders, proposals, contracts, ticket attachments and member documents. These commonly contain PII, banking/payment details, signed contracts and internal correspondence. In a multi-company (entity) deployment the attacker can also pivot across entities via the entity parameter (subject to the technical user's per-entity rights), widening the breach across separate business units. No vertical escalation is demonstrated (read-only), so the claim is bounded to confidentiality.
- Realistic exploitation: A customer of the business (or anyone who obtains one portal credential, e.g. via a leaked welcome email) scripts the reference space and downloads the entire customer base's billing/contract documents — a mass data-breach with regulatory (GDPR) consequences, achievable with a single low-privilege account and trivially guessable, sequential document references.
Suggested Mitigation
Make the viewimage controller enforce the WebPortal's own per-customer scoping instead of relying on the (inapplicable) internal-user socid mechanism — mirroring document.controller.
Concretely, in htdocs/webportal/controllers/viewimage.controller.class.php after dol_check_secure_access_document(...):
- // Basic protection (against external users only)
- if ($user->socid > 0) {
- if ($sqlprotectagainstexternals) {
- $resql = $this->db->query($sqlprotectagainstexternals);
- ...
- if ($user->socid != $obj->fk_soc) { $accessallowed = 0; break; }
- }
- }
+ // WebPortal customers are all served under one internal technical user (socid=0),
+ // so the external-user (socid>0) ownership filter never runs. Scope to the
+ // actually-logged-in third party instead.
+ $portalSocId = (!empty($context->logged_thirdparty) ? (int) $context->logged_thirdparty->id : 0);
+ if ($portalSocId <= 0) {
+ $accessallowed = 0;
+ } elseif ($sqlprotectagainstexternals) {
+ $resql = $this->db->query($sqlprotectagainstexternals);
+ if ($resql) {
+ $num = $this->db->num_rows($resql);
+ for ($i = 0; $i < $num; $i++) {
+ $obj = $this->db->fetch_object($resql);
+ if ($portalSocId != $obj->fk_soc) { $accessallowed = 0; break; }
+ }
+ } else {
+ $accessallowed = 0;
+ }
+ } else {
+ // No ownership filter available for this modulepart -> refuse by default,
+ // or restrict modulepart to an explicit allow-list of safe public types
+ // (e.g. 'mycompany', 'medias') needed for theme/logo rendering.
+ $accessallowed = 0;
+ }
Additionally:
- Restrict
modulepart in this controller to an explicit allow-list of the values the portal legitimately needs to render images (e.g. mycompany, medias, and per-object types it scopes), and reject everything else — defense in depth, and it prevents abuse of any module whose dol_check_secure_access_document branch grants access unconditionally.
- Apply the same review to the
document controller's reliance on WEBPORTAL_*_LIST_ACCESS and confirm no other WebPortal controller delegates authorization to the $user->socid > 0 path.
Test coverage gap
There is no test asserting that a logged-in WebPortal customer cannot fetch another third party's document via controller=viewimage. A test that logs in as third party A and requests a file owned by third party B (for each supported modulepart) and expects accessforbidden would have caught this — and would catch regressions in the document controller too.
References
htdocs/webportal/controllers/viewimage.controller.class.php:82,116-122,209-244 (sink)
htdocs/core/lib/files.lib.php:3019,3404-3410,3708-3737 (dol_check_secure_access_document)
htdocs/public/webportal/webportal.main.inc.php:281-291,367-368 (technical user becomes $user)
htdocs/webportal/class/context.class.php:199,264 (controller chosen from request)
htdocs/webportal/controllers/document.controller.class.php:190-216 (correct, scoped sibling)
htdocs/viewimage.php:304-323 (origin of the socid>0 guard, safe in the main app)
htdocs/core/lib/functions.lib.php:13818-13839 (dolIsAllowedForPreview allows PDF)
- Dolibarr
SECURITY.md — IDOR / broken access control are in scope.
Dolibarr Version
No response
Environment PHP
No response
Environment Database
No response
Steps to reproduce the behavior and expected behavior
No response
Attached files
No response
Bug
[IDOR / Broken Access Control] WebPortal
viewimagecontroller serves any company's documents to any authenticated portal customer in Dolibarr @ d7b1bc7Summary
The Dolibarr WebPortal module (a customer-facing portal, stable/default module,
version='dolibarr') ships aviewimagecontroller that streams files from the server's documents directory. Access is decided exclusively bydol_check_secure_access_document($modulepart, $file, $entity, $user, …), evaluated against the single internal "technical" user (WEBPORTAL_USER_LOGGED) that the portal runs as. The per-customer ownership filter returned by that function (sqlprotectagainstexternals, which restricts files tofk_soc = $user->socid) is applied only when$user->socid > 0. Because the technical user is an internal account (socid = 0), that branch is dead code, and the controller never consults the actually-logged-in customer ($context->logged_thirdparty).Net effect: any authenticated portal customer can read any other company's documents (invoices, orders, proposals, contracts, ticket attachments, member files, …) of a previewable type — including PDF, which is the storage format of all those business documents — simply by guessing/iterating object references. This is a cross-tenant confidentiality breach (IDOR). It is post-auth (a low-privilege portal customer account) and works in the documented portal configuration.
Technical Detail
Root cause
htdocs/webportal/controllers/viewimage.controller.class.php,init()(audited @d7b1bc79):And what
dol_check_secure_access_documentdecides for, e.g., invoices (htdocs/core/lib/files.lib.php:3404):The function's design splits the decision in two:
accessallowed(does this user have the module right?) andsqlprotectagainstexternals(does this external customer own the specific object?). The second half is the only thing that ties a file to a tenant, and the controller gates it behind$user->socid > 0.In the WebPortal, all customers are served under one internal technical user (
WEBPORTAL_USER_LOGGED), set as the global$userinhtdocs/public/webportal/webportal.main.inc.php:So
$user->socidis0, the ownership branch is unreachable, andaccessallowedstays1for every file the technical user is allowed to read — regardless of which customer is logged in. The customer's real identity,$context->logged_thirdparty, is never referenced inviewimage.controller.This is a copy of the main-app
htdocs/viewimage.php:290-323, which uses the identicalif ($user->socid > 0)guard. The main app is safe because a real external customer authenticates with their own external user (socid = <their company>), so the guard engages. The WebPortal reused the code but changed the trust model (one shared internal user for all customers) without substituting an equivalent per-customer check.The sibling
documentcontroller demonstrates the correct pattern and confirms this is an oversight rather than intended behaviour — it explicitly enforces all three gates (htdocs/webportal/controllers/document.controller.class.php:190-216):viewimage.controllerhas none of these checks: noWEBPORTAL_*_LIST_ACCESSgate, nologged_thirdparty == soc_id, nocheckUserAccessToObject.Trigger conditions
GET /public/webportal/index.phpwithcontroller=viewimage.webportal.main.inc.php:229-237).GETPOST):modulepart— e.g.facture/invoice,commande/order,propal,contract,ticket,member, …file— relative path of the target document, e.g.FA2401-0099/FA2401-0099.pdf(the on-disk layout is<ref>/<ref>.pdf).entity— optional, defaults to current entity.dolIsAllowedForPreview,functions.lib.php:13826) — the allow-list includespdf,jpeg/png/gif/tiff/webp/avif/bmp,plain,css,webm,mp4. PDF is included, so all generated business documents are reachable.WEBPORTAL_USER_LOGGEDhas read right on the target module. This is satisfied in any working portal — the portal exposes ticket lists, member cards, invoice/order lists, etc., and its admin help text for this setting reads "This user is used to update cards" (htdocs/langs/en_US/website.lang:222), i.e. it is expected to hold read/write rights on the exposed modules.Why existing defenses do not apply
GETPOST('file','alphanohtml')+..stripping (lines 165-167, 248) — these prevent path traversal out of the documents tree. They do nothing about authorization: the attacker is reading a legitimate file inside the documents tree that simply belongs to another tenant.sqlprotectagainstexternals(the ownership filter) — only executed insideif ($user->socid > 0). The portal's effective$useris internal (socid = 0), so it is never executed. This is the broken control.dolIsAllowedForPreview— restricts file type, not owner; and PDF (the sensitive format) is allowed.WEBPORTAL_*_LIST_ACCESS/checkUserAccessToObject— enforced by thedocumentcontroller but entirely absent fromviewimage, so the portal-level access policy is bypassed by choosingcontroller=viewimageinstead ofcontroller=document.Proof of Concept
Setup
Trigger
Authenticate to the portal as Attacker Ltd (its own account), keep the session cookie, then request a Victim Inc invoice:
curlform:Iterate
file=FA2401-XXXX/FA2401-XXXX.pdfover the (sequential, guessable) invoice reference space to enumerate every company's invoices. Repeat withmodulepart=order|propal|contract|ticket|memberto harvest other document classes.Observable result
The server responds
200 OK,Content-Disposition: inline; filename="FA2401-0099.pdf", and the body is Victim Inc's invoice PDF — even though the authenticated session belongs to Attacker Ltd, who has no relationship to that invoice.Negative control
The same request through the correctly-guarded
documentcontroller is rejected (accessforbidden) because$context->logged_thirdparty->id (3) != soc_id:And the same
viewimagerequest issued without a portal session is redirected to the login controller (proves the bug is post-auth, not pre-auth).Impact
05-chain-analysis.md): IDOR → lateral access to every other customer's invoices, orders, proposals, contracts, ticket attachments and member documents. These commonly contain PII, banking/payment details, signed contracts and internal correspondence. In a multi-company (entity) deployment the attacker can also pivot across entities via theentityparameter (subject to the technical user's per-entity rights), widening the breach across separate business units. No vertical escalation is demonstrated (read-only), so the claim is bounded to confidentiality.Suggested Mitigation
Make the
viewimagecontroller enforce the WebPortal's own per-customer scoping instead of relying on the (inapplicable) internal-usersocidmechanism — mirroringdocument.controller.Concretely, in
htdocs/webportal/controllers/viewimage.controller.class.phpafterdol_check_secure_access_document(...):Additionally:
modulepartin this controller to an explicit allow-list of the values the portal legitimately needs to render images (e.g.mycompany,medias, and per-object types it scopes), and reject everything else — defense in depth, and it prevents abuse of any module whosedol_check_secure_access_documentbranch grants access unconditionally.documentcontroller's reliance onWEBPORTAL_*_LIST_ACCESSand confirm no other WebPortal controller delegates authorization to the$user->socid > 0path.Test coverage gap
There is no test asserting that a logged-in WebPortal customer cannot fetch another third party's document via
controller=viewimage. A test that logs in as third party A and requests a file owned by third party B (for each supportedmodulepart) and expectsaccessforbiddenwould have caught this — and would catch regressions in thedocumentcontroller too.References
htdocs/webportal/controllers/viewimage.controller.class.php:82,116-122,209-244(sink)htdocs/core/lib/files.lib.php:3019,3404-3410,3708-3737(dol_check_secure_access_document)htdocs/public/webportal/webportal.main.inc.php:281-291,367-368(technical user becomes$user)htdocs/webportal/class/context.class.php:199,264(controller chosen from request)htdocs/webportal/controllers/document.controller.class.php:190-216(correct, scoped sibling)htdocs/viewimage.php:304-323(origin of thesocid>0guard, safe in the main app)htdocs/core/lib/functions.lib.php:13818-13839(dolIsAllowedForPreviewallows PDF)SECURITY.md— IDOR / broken access control are in scope.Dolibarr Version
No response
Environment PHP
No response
Environment Database
No response
Steps to reproduce the behavior and expected behavior
No response
Attached files
No response