Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

### Breaking Changes
- **`@self.folder` writes now require read access to the target folder.** Callers that POST/PUT/PATCH an object with a numeric `@self.folder` value (e.g. `"@self": {"folder": "42"}`) now receive HTTP 403 with body `{"error": "folder_access_denied", "folder": "<id>"}` if the acting user cannot read the referenced Nextcloud folder. Previously the binding was unchecked and silently created a cross-tenant link: an authenticated caller could attach an object — and all of its child file writes — to *any* user's folder by guessing or harvesting node IDs. The new check uses the user's user-folder mount and `Folder::isReadable()`, deliberately avoiding the root-folder fallback (which exists only for anonymous public file reads and is preserved on the general-purpose `getNodeById()` helper). Empty and legacy non-numeric folder values continue through the existing auto-create path unchanged. Every denial writes a forensic audit-trail entry (`action: "folder_access_denied"`) before propagating the exception. **Internal callers that legitimately need to bind to a folder outside the session user's tree must now pass an explicit `IUser $currentUser` to `FolderManagementHandler::createObjectFolderById()`.** No internal caller in the OpenRegister codebase regresses (cron jobs and import paths don't set `@self.folder`); downstream apps that already use accessible folder IDs are unaffected. See `docs/api/objects.md#self-folder-access-control-contract` for the full contract. ([#1342](https://github.com/ConductionNL/openregister/issues/1342))

### Breaking Changes
Copy link
Copy Markdown
Contributor

@WilcoLouwerse WilcoLouwerse May 7, 2026

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 Changes entry at line 8, but line 11 already has an existing ### Breaking Changes entry. The result is two consecutive level-3 ### Breaking Changes headings under ## Unreleased. Changelog parsers and release tools that use headings for grouping will emit duplicate sections.

Suggested fix: Merge the new @self.folder entry into the existing ### Breaking Changes section rather than creating a duplicate heading.

- **`@self.files` on rendered objects is now opt-in for full file metadata.** By default, `@self.files` is a lightweight list of integer file IDs (`[123, 456, 789]`). Consumers that need full file metadata (`id`, `path`, `title`, `accessUrl`, `downloadUrl`, `type`, `extension`, `size`, `hash`, `published`, `modified`, `labels`) MUST add `_extend[]=@self.files` (or the equivalent shorthand `_extend[]=_files`) to their request. The change applies to **every** consumer of OpenRegister's render output, including `show` endpoints in dependent apps (e.g. opencatalogi `/publications/{catalogSlug}/{id}`). Migration is a one-line query parameter addition. The previous behavior — full metadata always served on show, no metadata on list — caused asymmetric responses across endpoints and paid the file-lookup cost on every show response regardless of need. The new contract is symmetric across show and list endpoints (both emit `@self.files` as IDs by default; both accept `_extend[]=@self.files` for full metadata) and is documented under the `files-render-extension` capability. **Note:** Using `_extend[]=@self.files` (or `_files`) on **list** endpoints is heavily discouraged because it triggers per-row file/tag lookups (N+1 queries scaling with page size) and will result in degraded performance. Use it only when full file metadata is genuinely required for every row. **SOLR limitation:** on SOLR/index-backed list endpoints, `_extend[]=@self.files` is not yet supported; the lightweight ID list is always returned and the response carries `@self.extend_unsupported: ["@self.files"]` so consumers can detect the mismatch programmatically. Use the database-backed path when full file metadata is required on lists.

Expand Down
57 changes: 57 additions & 0 deletions docs/api/objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,7 @@ When creating or updating objects, you can explicitly set certain @self metadata
- **`organisation`**: Organization UUID
- **`published`**: Publication timestamp
- **`depublished`**: Depublication timestamp
- **`folder`**: Numeric Nextcloud folder ID to bind the object to (see access-control contract below)

Example:
```json
Expand All @@ -621,6 +622,62 @@ Example:

For detailed information about @self metadata handling, see [Self Metadata Handling](../development/self-metadata-handling.md).

### `@self.folder` access-control contract

The `@self.folder` metadata field binds an object to an existing Nextcloud folder
by node ID. The bind is governed by an access-control check on every save:

- **Empty / absent** — the system creates a new folder under the register's root
folder and stores the new node ID on the object. (Default behaviour, unchanged.)
- **Legacy non-numeric** (path-style strings from older installs) — auto-create
proceeds as before; no access check runs.
- **Non-empty numeric** (the format produced by current `@self.folder` writes) —
the acting user MUST be able to read the folder. The check uses the user's
user-folder mount and `Folder::isReadable()`. If either fails — folder doesn't
exist in the user's mount, the resolved node is a file, the folder is trashed,
or the user has no read permission — the save is rejected.

#### Denial response shape

When `@self.folder` is rejected, the endpoint returns **HTTP 403** with body:

```json
{
"error": "folder_access_denied",
"folder": "99"
}
```

`folder` echoes the attempted node ID. The check applies uniformly across
`POST` (create), `PUT` (update), and `PATCH` (partial update) on object endpoints.

#### Acting user resolution ("self")

The check resolves the acting user in this order:

1. The `IUser` explicitly passed to the underlying service helper (DI / cron path).
2. The session user (`IUserSession::getUser()`).
3. If neither resolves, the bind is **denied** by default — there is no fail-open
path on `@self.folder` writes.

#### Audit trail

Every denial writes a forensic audit-trail entry with `action: "folder_access_denied"`,
the actor (UID or `"system"`), the attempted folder ID, and a reason code. The
entry is written **before** the exception is thrown, so even a caller that
catches the exception has a record. Audit-write failures are logged at warning
level and do **not** swallow the denial — denial is authoritative.

For cleanup of stale `@self.folder` references on existing objects (folders the
owner can no longer access), an `occ openregister:folder-audit` command is
tracked separately as a follow-up.

#### See also

- Capability spec (post-archive): `openspec/specs/self-folder-access-control/spec.md`
- Architectural context: ADR-007 (Security and Auth), ADR-008 (Backend Layering)
- Downstream consumer benefiting from this hardening: DocuDesk's `add-dossier-schema` change.

## Security

- **RBAC**: Respects role-based access control
Expand Down
1 change: 1 addition & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ function (ContainerInterface $container) {
userSession: $container->get('OCP\IUserSession'),
groupManager: $container->get('OCP\IGroupManager'),
logger: $container->get('Psr\Log\LoggerInterface'),
auditTrailMapper: $container->get(\OCA\OpenRegister\Db\AuditTrailMapper::class),
fileService: null
);
}
Expand Down
36 changes: 36 additions & 0 deletions lib/Controller/ObjectsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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

View workflow job for this annotation

GitHub Actions / quality / PHP Quality (psalm)

UndefinedClass

lib/Controller/ObjectsController.php:2334:43: UndefinedClass: Class, interface or enum named OCA\OpenRegister\Dto\DeletionAnalysis does not exist (see https://psalm.dev/019)

Check failure on line 2334 in lib/Controller/ObjectsController.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality (phpstan)

Call to method toArray() on an unknown class OCA\OpenRegister\Dto\DeletionAnalysis.
} catch (\OCP\AppFramework\Db\DoesNotExistException $exception) {
return new JSONResponse(data: ['error' => 'Object not found'], statusCode: 404);
} catch (\Exception $exception) {
Expand Down Expand Up @@ -3476,4 +3487,29 @@

return $result;
}//end stripEmptyValues()

Copy link
Copy Markdown
Contributor

@WilcoLouwerse WilcoLouwerse May 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker — 403 body echoes attempted folder ID — folder existence oracle / enumeration vector

The folderAccessDeniedResponse() helper (line ~3490) returns:

{"error": "folder_access_denied", "folder": "<id>"}

where folder is the caller-supplied numeric node ID echoed back verbatim. This is documented in docs/api/objects.md and the CHANGELOG as the specified response shape.

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 @self.folder with sequential integers: 403 means the folder exists (just not accessible), whereas 404 / auto-create would mean it doesn't. The folder field in the body additionally confirms which ID was checked, providing round-trip confirmation of the node ID (not necessary — the caller already knows the ID they sent).

The response body folder field adds no information for legitimate callers (they know which ID they sent) but is explicitly documented as echoing the attempted ID, cementing the enumeration oracle into the public API contract.

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 @self.folder values. For multi-tenant installs this reveals the existence of other users' folders.

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 {"error": "folder_access_denied"} without the folder field, since the caller knows which ID they tried. (b) Alternatively, document this as an accepted risk in the spec with a justification (e.g., node IDs are not secret, or the attack requires authentication), which would downgrade this to a concern rather than a blocker. If the decision was made and documented, the reviewer needs to see it.

/**
* 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
92 changes: 92 additions & 0 deletions lib/Exception/FolderAccessDeniedException.php
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
Copy link
Copy Markdown
Contributor

@WilcoLouwerse WilcoLouwerse May 7, 2026

Choose a reason for hiding this comment

The 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 __construct(string $attemptedFolderId, int $code=403, ?Exception $previous=null). Using HTTP status codes as exception codes conflates two different things: Exception::getCode() is typically an application error code, not an HTTP status. Controllers using $exception->getCode() anywhere for routing logic would get 403 (expected) but future callers might be confused by the dual-use.

Suggested fix: Consider using a named constant like self::HTTP_STATUS = 403 or simply 0 for the exception code and document that HTTP mapping is the controller's responsibility (which it is — the folderAccessDeniedResponse() method hardcodes statusCode: 403 correctly).

Loading
Loading