fix(security): guard eml-preview with an explicit file-access check - #409
Merged
Merged
Conversation
`EmlPreviewController::preview` renders the ORIGINAL, un-redacted EML for a
caller-supplied `fileId` and performed no ownership or permission check of
its own. A live two-user probe shows cross-user access is denied today — but
the denial comes entirely from Nextcloud's session-scoped id resolution, not
from anything DocuDesk does:
preview($fileId)
-> EmlPreviewService::renderOriginalPreview($fileId)
-> OpenRegister FileService::getFileById($fileId)
-> IRootFolder::getById($fileId) // ROOT scope, not the user folder
-> checkOwnership() == $node->isReadable(), which its own docblock
says deliberately does NOT compare owner to session user
`IRootFolder::getById()` is `Root::getByIdInPath($id, '')`: with an empty path
the mount cache is queried across ALL users and the hits are then intersected
with the current session's mounts. That intersection is the only thing between
a caller and another user's message, and it disappears the moment the same code
runs without a session user — a background job, an `occ` command, any
system-context call.
Add `verifyFileAccess()`, the guard `AnonymizationController` already applies to
the sibling `extract` / `anonymize` endpoints under the same `api/anonymization/`
prefix: resolve through the caller's own user folder, 404 on a miss so callers
cannot probe for existence, 401 with no session user.
Strict subset: shares are mounted inside the user folder, so a shared .eml still
renders (verified live — granting a share flipped the probe from denied to
resolved, removing it flipped it back). Nothing that works today stops working.
Tests: 5 new cases. Negative control run — with the guard call removed, 3 of the
5 fail (401->422, 404->422 x2); with it in place all 5 pass.
Refs #408
rubenvdlinde
requested review from
WilcoLouwerse,
bbrands02 and
rjzondervan
as code owners
August 9, 2026 13:50
Contributor
Quality Report — ConductionNL/docudesk @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| test | ✅ | ||||
| test-unit | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 114/114 | |||
| npm | ✅ | ✅ 646/646 | |||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-09 14:05 UTC
Download the full PDF report from the workflow artifacts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #408.
What this changes
EmlPreviewController::previewrenders the original, un-redacted EML for a caller-suppliedfileIdand had no ownership or permission check of its own. This addsverifyFileAccess()— the same guardAnonymizationControlleralready applies to the siblingextract/anonymizeendpoints under the sameapi/anonymization/URL prefix — plus 5 unit tests.Why, given cross-user access is already denied
Two agents disagreed today over whether this was an exploitable IDOR. A live two-user probe settled it: unguarded, but not currently exploitable. The denial is real, but it belongs to Nextcloud, not to DocuDesk.
IRootFolder::getById()isFolder::getById()→Root::getByIdInPath($id, ''). With an empty path the$userderived from the path isnull, so the mount cache is queried across all users, and the hits are then intersected with the current session's mounts. That intersection is the only thing between a caller and another user's message — and it disappears the moment the same code runs without a session user: a background job, anocccommand, any system-context call.Live probe (shared dev instance, NC 34.0.0, two fresh non-admin users, Basic auth, no shared cookie jar,
OCS-APIRequest: true)Current user is not logged in... requires the OpenRegister anonymise-EML API.... file 21992 is not a readable file node.... file 99999999 is not a readable file node.... requires the OpenRegister anonymise-EML API.... file 21992 is not a readable file node.renderOriginalPreview()resolves the file before it checks for the OpenRegister API, so the two error strings distinguish the two steps exactly. Rows 5 and 6 are the differential control: same user, same id, same request — grant a share and resolution succeeds, remove it and it fails again. The gate is precisely mount/share visibility, not authorisation.Strict subset
getUserFolder($uid)->getById()still resolves shared files (shares are mounted inside the user folder), so the shared-file case above stays a grant. The guard denies exactly what is already denied, only explicitly and early, and keeps denying if resolution ever leaves a session. 404 rather than 403 so callers cannot probe for existence.Verification
phpunit tests/unit/Controller/EmlPreviewControllerTest.php→ OK (5 tests, 13 assertions)verifyFileAccess()call removed and everything else identical → 3 of 5 fail (401→422, 404→422 ×2). The tests can fail.EmlPreviewControllerTest + EmlPreviewServiceTest + AnonymizationControllerTest→ OK (32 tests, 78 assertions)phpcs --standard=phpcs.xml lib/Controller/EmlPreviewController.php→ exit 0 (2 pre-existing@specwarnings, present before this change)phpstan analyse lib/Controller/EmlPreviewController.php→ [OK] No errorsOut of scope, flagged in #408
anonymizeEmlStructureddoes not exist in OpenRegister — not in the deployed 0.2.17-unstable.25, not onorigin/development, not on anyorigin/*branch (verified with a positive control:getFileByIdis found by the same query). Every call to this endpoint therefore 422s at themethod_exists()check, andsrc/services/fileViewerService.js:82points the file viewer at it. The feature is dead end-to-end; that is a separate fix.