Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.

Commit d9434ab

Browse files
committed
fix(validate-self-folder-access): address PR #1431 third-pass review (1 blocker + 2 concerns)
Closes the three findings from Wilco's 3rd-pass review (commit d0b2839). **🔴 Blocker — class docblock still taught the vulnerable response shape:** `lib/Exception/FolderAccessDeniedException.php:34` documented `{"error": "folder_access_denied", "folder": "<requested-id>"}` as the canonical 403 body, contradicting every other artifact (code, controller, spec, `docs/api/objects.md`) that says the body MUST NOT echo the attempted folder ID. The next endpoint author copying the pattern from the docblock would reintroduce the exact enumeration oracle this PR closes. Updated the class docblock to: - State the correct body shape (`{"error": "folder_access_denied"}` only). - Spell out the enumeration-oracle rationale inline so the rationale travels with the code. - Cross-reference `ObjectsController::folderAccessDeniedResponse()` as the canonical mapping site. - Re-scope `getAttemptedFolderId()`'s docblock to "server-side audit logging only — MUST NOT be in the HTTP response body". **🟡 Concern — `assertObjectFolderAccessible` hardcoded `currentUser: null`:** The proxy method had no `?IUser $currentUser` parameter, so non-HTTP callers (cron, import pipelines, event listeners) fell through to `IUserSession::getUser()` → null → default-deny on every existing- binding save. Contradicted the CHANGELOG's own "explicit IUser $currentUser" contract. Plumbed the parameter through three call sites: - `FileService::assertObjectFolderAccessible(ObjectEntity $object, ?IUser $currentUser=null)` — forwards verbatim to `FolderManagementHandler::assertFolderIsAccessible` which already accepts the same parameter with the same fallback semantics (explicit → session → null = default-deny). - `ObjectService::ensureObjectFolder(?string $uuid, ?IUser $currentUser=null)` — plumbs the user through to the proxy. - `ObjectService::saveObject(..., ?IUser $currentUser=null)` — public surface gains the parameter as the last positional arg (back-compat preserved; existing callers pass nothing and get the same session- resolution behaviour they had before). Non-HTTP callers can now pass an explicit acting user end-to-end. Docblocks at each level document the resolution precedence + call out the non-HTTP-caller requirement. **🟡 Concern — defense-in-depth re-validation was uncached:** `ensureObjectFolder()` runs `getUserFolder() + getById()` on every save for any folder-bound object — real Nextcloud filesystem I/O per call. Bulk imports / cascade saves of the same object pay this cost every iteration despite the (uid, folderId) tuple being a stable access-grant key for the lifetime of a request. Added a per-request memoization map on `FileService` (`folderAccessRevalidationCache`) keyed `"{uid}:{folderId}"`. Only successful re-validations are cached; denials re-throw and re-check on retry (so a freshly-mounted-then-revoked folder still gets a correct verdict on the next call). The cache lives for the lifetime of the FileService instance (one HTTP request), and access changes do not propagate mid-request anyway, so a cache hit returns the same verdict the live check would. **Verification:** - PHPCS clean on all 3 touched files. - PHPStan clean. - `FolderAccessDeniedExceptionTest` (3 tests / 7 assertions) still green — Wilco confirmed in pass 3 that this test locks both the HTTP-agnostic-code invariant and the `HTTP_STATUS === 403` invariant, neither of which this commit touches. - `testPostPatchReturns403WithStructuredBodyOnFolderAccessDenied` is pre-existing failure on the head SHA (not introduced or worsened by this commit — verified by git stash). Refs: #1431, Wilco's third-pass review `4335769385`
1 parent d0b2839 commit d9434ab

3 files changed

Lines changed: 81 additions & 13 deletions

File tree

lib/Exception/FolderAccessDeniedException.php

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,17 @@
3030
* - the resolved folder is not readable by the acting user (`Folder::isReadable() === false`).
3131
*
3232
* Controllers MUST catch this exception specifically (not generic `\Exception`)
33-
* and map it to HTTP 403 with a structured body of the form
34-
* `{"error": "folder_access_denied", "folder": "<requested-id>"}`.
33+
* and map it to HTTP 403 with the structured body `{"error": "folder_access_denied"}`.
34+
*
35+
* **The response body MUST NOT echo the attempted folder ID.** Including it
36+
* would re-create the enumeration oracle the `self-folder-access-control`
37+
* capability spec was written to close: a caller probing `@self.folder` with
38+
* sequential integers could distinguish "folder exists but I can't read it"
39+
* (403 + id) from "folder does not exist" (auto-create / no-op) just by
40+
* inspecting the response shape. The attempted id remains available via
41+
* `getAttemptedFolderId()` for server-side audit logging only — see
42+
* `ObjectsController::folderAccessDeniedResponse()` for the canonical
43+
* controller-side mapping.
3544
*
3645
* The class extends `\Exception` directly — NOT `OCP\Files\NotPermittedException`
3746
* or any other Nextcloud exception — so generic catch-blocks for those exceptions
@@ -96,8 +105,9 @@ public function __construct(string $attemptedFolderId, int $code=0, ?Exception $
96105
/**
97106
* Get the folder ID the caller attempted to bind to.
98107
*
99-
* Used by controller error handlers to populate the `folder` field of
100-
* the structured 403 response body.
108+
* **Server-side use only** — for audit-trail entries and structured
109+
* log lines. MUST NOT be included in the HTTP response body (see the
110+
* class docblock for the enumeration-oracle rationale).
101111
*
102112
* @return string
103113
*/

lib/Service/FileService.php

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,25 @@ class FileService
319319
*/
320320
private FileAuditHandler $fileAuditHandler;
321321

322+
/**
323+
* Per-request memoization for the defense-in-depth folder-access
324+
* re-validation done by `assertObjectFolderAccessible`.
325+
*
326+
* Keyed `"{uid}:{folderId}"` (or `"__no_user__:{folderId}"` when no
327+
* acting user resolves). Only successful re-validations are cached;
328+
* denials re-throw and re-check on retry. The cache lives for the
329+
* lifetime of the FileService instance — i.e. one HTTP request — so
330+
* bulk imports / cascade saves stop re-running
331+
* `getUserFolder() + getById()` filesystem I/O on every iteration.
332+
* PR #1431 concern.
333+
*
334+
* Access changes are not propagated mid-request anyway, so a cache
335+
* hit here returns the same verdict the live check would.
336+
*
337+
* @var array<string, true>
338+
*/
339+
private array $folderAccessRevalidationCache = [];
340+
322341
/**
323342
* Root folder name for all OpenRegister files.
324343
*
@@ -844,24 +863,47 @@ public function getObjectFolder(ObjectEntity|string $objectEntity, int|string|nu
844863
* is non-numeric (legacy path-style folder, handled by the
845864
* auto-create branch in ensureObjectFolder).
846865
*
847-
* @param ObjectEntity $object The existing object whose folder must be re-validated.
866+
* **Acting user resolution.** `$currentUser` is forwarded verbatim
867+
* to `assertFolderIsAccessible` and follows that method's
868+
* documented precedence: when non-null it is used as-is, otherwise
869+
* `IUserSession::getUser()` is consulted, otherwise the bind is
870+
* denied. Non-HTTP callers (cron, import pipelines, event listeners)
871+
* MUST pass an explicit `$currentUser` to avoid the
872+
* session-user-is-null → default-deny path; HTTP callers can omit
873+
* the argument and rely on session resolution.
874+
*
875+
* @param ObjectEntity $object The existing object whose folder must be re-validated.
876+
* @param IUser|null $currentUser Explicit acting user; falls back to session resolution.
848877
*
849878
* @return void
850879
*
851880
* @throws \OCA\OpenRegister\Exception\FolderAccessDeniedException When the acting user cannot access the bound folder.
852881
*/
853-
public function assertObjectFolderAccessible(ObjectEntity $object): void
882+
public function assertObjectFolderAccessible(ObjectEntity $object, ?IUser $currentUser=null): void
854883
{
855884
$folder = $object->getFolder();
856885
if ($folder === null || $folder === '' || is_numeric($folder) === false) {
857886
return;
858887
}
859888

889+
// Per-request memoization. Resolve the acting user the same way
890+
// `FolderManagementHandler::assertFolderIsAccessible` will (explicit
891+
// arg → session → null) so the cache key matches the access-grant
892+
// tuple the inner method actually evaluates.
893+
$actingUser = ($currentUser ?? $this->userSession->getUser());
894+
$cacheKey = (($actingUser?->getUID() ?? '__no_user__').':'.$folder);
895+
if (isset($this->folderAccessRevalidationCache[$cacheKey]) === true) {
896+
return;
897+
}
898+
860899
$this->folderManagementHandler->assertFolderIsAccessible(
861900
folderId: (string) $folder,
862-
currentUser: null,
901+
currentUser: $currentUser,
863902
objectEntity: $object
864903
);
904+
905+
// Only cache successes — failures re-throw and re-check on retry.
906+
$this->folderAccessRevalidationCache[$cacheKey] = true;
865907
}//end assertObjectFolderAccessible()
866908

867909
/**

lib/Service/ObjectService.php

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
use React\Promise\Promise;
8080
use React\Promise\PromiseInterface;
8181
use React\Async;
82+
use OCP\IUser;
8283
use OCP\IUserSession;
8384
use OCP\IGroupManager;
8485
use OCP\IUserManager;
@@ -1071,6 +1072,12 @@ public function getLogs(string $uuid, array $filters=[], bool $_rbac=true, bool
10711072
* @param bool $_multitenancy Whether to apply multitenancy filtering (default: true)
10721073
* @param bool $silent Whether to skip audit trail creation and events (default: false)
10731074
* @param array|null $uploadedFiles Uploaded files from multipart/form-data (optional)
1075+
* @param IUser|null $currentUser Explicit acting user for `@self.folder` access checks
1076+
* (forwarded to `ensureObjectFolder` → `assertObjectFolderAccessible`).
1077+
* Defaults to null → `IUserSession::getUser()` resolution.
1078+
* Non-HTTP callers (cron, import pipelines, event listeners)
1079+
* MUST pass an explicit user to avoid the
1080+
* default-deny fall-through on every folder-bound save.
10741081
*
10751082
* @return ObjectEntity The saved and rendered object
10761083
*
@@ -1089,7 +1096,8 @@ public function saveObject(
10891096
bool $_rbac=true,
10901097
bool $_multitenancy=true,
10911098
bool $silent=false,
1092-
?array $uploadedFiles=null
1099+
?array $uploadedFiles=null,
1100+
?IUser $currentUser=null
10931101
): ObjectEntity {
10941102
// Set register/schema context.
10951103
$this->setContextFromParameters(
@@ -1171,7 +1179,7 @@ public function saveObject(
11711179
$this->validateObjectIfRequired(object: $object);
11721180

11731181
// Ensure folder exists for the object.
1174-
$folderId = $this->ensureObjectFolder(uuid: $uuid);
1182+
$folderId = $this->ensureObjectFolder(uuid: $uuid, currentUser: $currentUser);
11751183

11761184
// Clear request-scoped caches before starting a new top-level save operation.
11771185
// This ensures cascade operations benefit from caching while avoiding stale data.
@@ -1457,11 +1465,16 @@ private function normalizeDateValues(array $object): array
14571465
/**
14581466
* Ensure object folder exists, create if needed.
14591467
*
1460-
* @param string|null $uuid Object UUID
1468+
* @param string|null $uuid Object UUID
1469+
* @param IUser|null $currentUser Explicit acting user forwarded to the
1470+
* defense-in-depth re-validation; falls
1471+
* back to `IUserSession::getUser()`.
1472+
* Non-HTTP callers (cron, import
1473+
* pipelines) MUST pass an explicit user.
14611474
*
14621475
* @return int|null Folder ID if created/exists, null otherwise
14631476
*/
1464-
private function ensureObjectFolder(?string $uuid): ?int
1477+
private function ensureObjectFolder(?string $uuid, ?IUser $currentUser=null): ?int
14651478
{
14661479
// Handle folder creation for existing objects or new objects with UUIDs.
14671480
$folderId = null;
@@ -1504,8 +1517,11 @@ private function ensureObjectFolder(?string $uuid): ?int
15041517
// `FolderAccessDeniedException` → HTTP 403 at the
15051518
// controller layer when the acting user cannot access
15061519
// the bound folder.
1507-
$this->fileService->assertObjectFolderAccessible($existingObject);
1508-
}
1520+
$this->fileService->assertObjectFolderAccessible(
1521+
object: $existingObject,
1522+
currentUser: $currentUser
1523+
);
1524+
}//end if
15091525
} catch (\OCA\OpenRegister\Exception\FolderAccessDeniedException $e) {
15101526
// Propagate folder-access denials up to the controller.
15111527
throw $e;

0 commit comments

Comments
 (0)