Skip to content

IDOR access on webportal module #38947

Description

@eldy

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

Metadata

Metadata

Labels

BugThis is a bug (something does not work as expected)Priority 1 - SecurityThis is a bug or trouble identified as a security bug

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions