-
Notifications
You must be signed in to change notification settings - Fork 4
Implement validate-self-folder-access: HTTP 403 + audit on cross-tenant @self.folder writes (#1342) #1431
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development
Are you sure you want to change the base?
Implement validate-self-folder-access: HTTP 403 + audit on cross-tenant @self.folder writes (#1342) #1431
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,6 +32,7 @@ | |
| use OCA\OpenRegister\Db\RegisterMapper; | ||
| use OCA\OpenRegister\Db\SchemaMapper; | ||
| use OCA\OpenRegister\Exception\CustomValidationException; | ||
| use OCA\OpenRegister\Exception\FolderAccessDeniedException; | ||
| use OCA\OpenRegister\Exception\ValidationException; | ||
| use OCA\OpenRegister\Exception\RegisterNotFoundException; | ||
| use OCA\OpenRegister\Exception\SchemaNotFoundException; | ||
|
|
@@ -1752,6 +1753,10 @@ | |
| ], | ||
| statusCode: 422 | ||
| ); | ||
| } catch (FolderAccessDeniedException $exception) { | ||
| // MUST be caught before generic \Exception to avoid being absorbed as a 403 with | ||
| // a non-structured body. See the `self-folder-access-control` capability spec. | ||
| return $this->folderAccessDeniedResponse(exception: $exception); | ||
| } catch (\Exception $exception) { | ||
| // Handle all other exceptions (including RBAC permission errors). | ||
| return new JSONResponse(data: ['error' => $exception->getMessage()], statusCode: 403); | ||
|
|
@@ -1924,6 +1929,9 @@ | |
| data: ['error' => $exception->getMessage(), 'errors' => $exception->getErrors()], | ||
| statusCode: 422 | ||
| ); | ||
| } catch (FolderAccessDeniedException $exception) { | ||
| // MUST be caught before generic \Exception. See `self-folder-access-control` spec. | ||
| return $this->folderAccessDeniedResponse(exception: $exception); | ||
| } catch (\Exception $exception) { | ||
| // Handle all other exceptions (including RBAC permission errors). | ||
| return new JSONResponse(data: ['error' => $exception->getMessage()], statusCode: 403); | ||
|
|
@@ -2097,6 +2105,9 @@ | |
| data: ['error' => $exception->getMessage(), 'errors' => $exception->getErrors()], | ||
| statusCode: 422 | ||
| ); | ||
| } catch (FolderAccessDeniedException $exception) { | ||
| // MUST be caught before generic \Exception. See `self-folder-access-control` spec. | ||
| return $this->folderAccessDeniedResponse(exception: $exception); | ||
| } catch (\Exception $exception) { | ||
| // Handle all other exceptions (including RBAC permission errors). | ||
| $this->logger->error( | ||
|
|
@@ -2320,7 +2331,7 @@ | |
| $deleteHandler = $objectService->getDeleteHandler(); | ||
| $analysis = $deleteHandler->canDelete($objectEntity); | ||
|
|
||
| return new JSONResponse(data: $analysis->toArray(), statusCode: 200); | ||
|
Check failure on line 2334 in lib/Controller/ObjectsController.php
|
||
| } catch (\OCP\AppFramework\Db\DoesNotExistException $exception) { | ||
| return new JSONResponse(data: ['error' => 'Object not found'], statusCode: 404); | ||
| } catch (\Exception $exception) { | ||
|
|
@@ -3476,4 +3487,29 @@ | |
|
|
||
| return $result; | ||
| }//end stripEmptyValues() | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Blocker — 403 body echoes attempted folder ID — folder existence oracle / enumeration vectorThe {"error": "folder_access_denied", "folder": "<id>"}where The problem: the fact that the server returns 403 (rather than 404) already confirms the folder exists in the Nextcloud instance. An attacker can enumerate valid folder node IDs across the instance by probing The response body Strict-mode escalation: uncertain whether the 403 vs 404 distinction is an acceptable design tradeoff (HTTP semantics: 403 = exists but forbidden; 404 = does not exist or forbidden to reveal existence). Since this is a security-sensitive PR and the spec explicitly documents the 403 shape, the lack of a "404 for unknown IDs, 403 only for known-but-inaccessible" decision is uncertain → escalated to blocker. Impact: An authenticated tenant can enumerate all valid folder node IDs on the Nextcloud instance by brute-forcing Suggested fix (options): (a) Return 403 for any numeric folder ID regardless of whether it exists — do not distinguish "not found" from "not readable" in the HTTP response. Return |
||
| /** | ||
| * Build the structured HTTP 403 response for a folder-access denial. | ||
| * | ||
| * Per the `self-folder-access-control` capability spec, every save | ||
| * endpoint that propagates `FolderAccessDeniedException` MUST return | ||
| * status 403 with body `{ "error": "folder_access_denied", "folder": "<requested-id>" }`. | ||
| * | ||
| * Centralised here so the three save endpoints (create / update / postPatch) | ||
| * stay in sync without copy-pasting the response shape. | ||
| * | ||
| * @param FolderAccessDeniedException $exception The denial exception carrying the attempted folder ID. | ||
| * | ||
| * @return JSONResponse HTTP 403 with the structured body. | ||
| */ | ||
| private function folderAccessDeniedResponse(FolderAccessDeniedException $exception): JSONResponse | ||
| { | ||
| return new JSONResponse( | ||
| data: [ | ||
| 'error' => 'folder_access_denied', | ||
| 'folder' => $exception->getAttemptedFolderId(), | ||
| ], | ||
| statusCode: 403 | ||
| ); | ||
| }//end folderAccessDeniedResponse() | ||
| }//end class | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| <?php | ||
|
|
||
| /** | ||
| * Class FolderAccessDeniedException | ||
| * | ||
| * Thrown when a `@self.folder` write attempts to bind an object to a folder | ||
| * that the acting user cannot read. | ||
| * | ||
| * @category Exception | ||
| * @package OCA\OpenRegister\Exception | ||
| * @author Conduction Development Team <dev@conduction.nl> | ||
| * @copyright 2024 Conduction B.V. | ||
| * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 | ||
| * @version GIT: <git-id> | ||
| * @link https://OpenRegister.app | ||
| * | ||
| * @spec openspec/changes/validate-self-folder-access/specs/self-folder-access-control/spec.md | ||
| */ | ||
|
|
||
| namespace OCA\OpenRegister\Exception; | ||
|
|
||
| use Exception; | ||
|
|
||
| /** | ||
| * Exception thrown when a `@self.folder` bind is denied. | ||
| * | ||
| * Raised by `FolderManagementHandler::createObjectFolderById()` when: | ||
| * - the supplied folder ID does not resolve in the acting user's user-folder mount, | ||
| * - the resolved node is not a `Folder` (e.g. a file ID was supplied), | ||
| * - the resolved folder is not readable by the acting user (`Folder::isReadable() === false`). | ||
| * | ||
| * Controllers MUST catch this exception specifically (not generic `\Exception`) | ||
| * and map it to HTTP 403 with a structured body of the form | ||
| * `{"error": "folder_access_denied", "folder": "<requested-id>"}`. | ||
| * | ||
| * The class extends `\Exception` directly — NOT `OCP\Files\NotPermittedException` | ||
| * or any other Nextcloud exception — so generic catch-blocks for those exceptions | ||
| * do not silently absorb a denial. | ||
| * | ||
| * @category Exception | ||
| * @package OCA\OpenRegister\Exception | ||
| * | ||
| * @author Conduction Development Team <dev@conduction.nl> | ||
| * @copyright 2024 Conduction B.V. | ||
| * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 | ||
| * | ||
| * @version GIT: <git-id> | ||
| * | ||
| * @link https://OpenRegister.app | ||
| * | ||
| * @phpstan-consistent-constructor | ||
| */ | ||
| class FolderAccessDeniedException extends Exception | ||
| { | ||
|
|
||
| /** | ||
| * The folder ID the caller attempted to bind to. | ||
| * | ||
| * @var string | ||
| */ | ||
| private string $attemptedFolderId; | ||
|
|
||
| /** | ||
| * FolderAccessDeniedException constructor. | ||
| * | ||
| * @param string $attemptedFolderId The folder ID the caller attempted to bind to. | ||
| * @param int $code HTTP status code (default: 403 Forbidden). | ||
| * @param Exception|null $previous The previous exception that caused this one, if any. | ||
| */ | ||
| public function __construct(string $attemptedFolderId, int $code=403, ?Exception $previous=null) | ||
| { | ||
| $this->attemptedFolderId = $attemptedFolderId; | ||
|
|
||
| $message = "Access to folder '".$attemptedFolderId."' is denied for the acting user."; | ||
| parent::__construct(message: $message, code: $code, previous: $previous); | ||
|
|
||
| }//end __construct() | ||
|
|
||
| /** | ||
| * Get the folder ID the caller attempted to bind to. | ||
| * | ||
| * Used by controller error handlers to populate the `folder` field of | ||
| * the structured 403 response body. | ||
| * | ||
| * @return string | ||
| */ | ||
| public function getAttemptedFolderId(): string | ||
| { | ||
| return $this->attemptedFolderId; | ||
|
|
||
| }//end getAttemptedFolderId() | ||
| }//end class | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (adjusted from line 249 to nearest hunk line 92) 🟢 Minor — FolderAccessDeniedException constructor parameter $code defaults to 403 — but Exception code has no HTTP semantics The constructor signature is Suggested fix: Consider using a named constant like |
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟢 Minor — CHANGELOG has two '### Breaking Changes' sections at the same level — malformed markdown
The diff adds a new
### Breaking Changesentry at line 8, but line 11 already has an existing### Breaking Changesentry. The result is two consecutive level-3### Breaking Changesheadings under## Unreleased. Changelog parsers and release tools that use headings for grouping will emit duplicate sections.Suggested fix: Merge the new
@self.folderentry into the existing### Breaking Changessection rather than creating a duplicate heading.