Skip to content

feat(auth): limit scoped app passwords to the routes they open - #65340

Merged
pringelmann merged 9 commits into
masterfrom
feat/noid/app-password-route-gate
Oct 29, 2026
Merged

pringelmann merged 9 commits into
masterfrom
feat/noid/app-password-route-gate

Conversation

@pringelmann

@pringelmann pringelmann commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Second step for limited app passwords: a token with scopes can only call the index.php and OCS routes its scopes open. Everything else gets a 403. Tokens without scopes aren't affected. DAV is in #65552.

How it works

  • New middleware after the security middleware. Tokens without scopes skip it
  • Controllers declare the scope they need with #[TokenScope] on a method or the class (unstable API in NCU\, so apps can use it). A method's attribute replaces the class one. No attribute means refused, so new endpoints start closed
  • Apps not shipped with Nextcloud can only require their own <appid>: scopes
  • Files routes need files:files:read / files:files:write, shares, sharee search and direct links files_sharing:shares:write. Calendar and contacts OCS routes in dav need the matching dav: scope
  • Only deleting and rotating your own app password is open to every scoped token
  • Admin-only routes and pages are refused before the controller runs

Known limit: an admin's calendar or contacts token can import into other users' calendars and address books via the user parameter, and a files token can transfer ownership of files to another account.

Tests

Unit tests for the middleware and its place in the stack. They pin the scopes of the share, direct link, preview and download endpoints, and check that templates, direct editing, conversion and page methods stay unannotated. Integration tests cover allowed and refused calls with a scoped token.

Next step

DAV enforcement, then the access picker in Settings.

Checklist

AI (if applicable)

  • The content of this PR was partly or fully generated using AI (tests, reviewed)

@pringelmann
pringelmann changed the base branch from master to feat/noid/app-password-scope-storage October 9, 2026 11:15
@pringelmann
pringelmann added this pull request to stack #65341 October 9, 2026 11:15
@pringelmann pringelmann added this to the Nextcloud 36 milestone Oct 9, 2026
@pringelmann pringelmann self-assigned this Oct 9, 2026
$dispatcher->registerMiddleware($c->get(PublicShareMiddleware::class));
$dispatcher->registerMiddleware($c->get(AdditionalScriptsMiddleware::class));
// After AdditionalScriptsMiddleware, so pages are refused before their listeners run
$dispatcher->registerMiddleware($c->get(TokenScopeMiddleware::class));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Own middleware instead of one more check in SecurityMiddleware, which is hand-built and already does a lot. Also registered last on purpose, the test in DIContainerTest checks the order.

* Page renders run every app's listeners and initial state, so none reach a token with scopes
*/
#[\Override]
public function afterController(Controller $controller, string $methodName, Response $response): Response {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We only know it's a page once the controller returns a TemplateResponse, so it has to check after controller runs. Page GETs don't change data, so letting the controller run is fine though. What we block is the render, which pulls in every app's listeners and initial state.

@pringelmann
pringelmann marked this pull request as ready for review October 9, 2026 11:25
@pringelmann
pringelmann requested a review from a team as a code owner October 9, 2026 11:25
@pringelmann
pringelmann requested review from Altahrim, icewind1991, leftybournes and provokateurin and removed request for a team October 9, 2026 11:25
@pringelmann
pringelmann force-pushed the feat/noid/app-password-route-gate branch from d4d67af to a2b3655 Compare October 9, 2026 11:25
@pringelmann pringelmann changed the title feat(auth): add app password scope storage feat(auth): limit scoped app passwords to the routes they open Oct 9, 2026
@pringelmann pringelmann added the 3. to review Waiting for reviews label Oct 9, 2026
@pringelmann
pringelmann force-pushed the feat/noid/app-password-route-gate branch from 0823522 to cf1ad78 Compare October 16, 2026 07:38
@pringelmann pringelmann added the 3. to review Waiting for reviews label Oct 16, 2026
@pringelmann
pringelmann marked this pull request as ready for review October 16, 2026 08:28
@pringelmann
pringelmann force-pushed the feat/noid/app-password-route-gate branch 2 times, most recently from c1b26e4 to bdac8ee Compare October 16, 2026 11:11
@pringelmann

Copy link
Copy Markdown
Contributor Author

@artonge I think adding multiple scopes later will be quite easy.

For example we can do something like:

  • Any of several scopes -> Simply repeat the attribute. Existing single attributes still work
  • All of several scopes -> Widen the param to string|array, which is backwards compatible too.

Either way I've moved the attribute to NCU so it can stay flexible until 37 cc @CarlSchwan

Base automatically changed from feat/noid/app-password-scope-storage to master October 22, 2026 07:52
@pringelmann
pringelmann force-pushed the feat/noid/app-password-route-gate branch 2 times, most recently from fab26ff to f7e03f6 Compare October 22, 2026 08:28
@pringelmann
pringelmann requested a review from artonge October 22, 2026 13:02
* @psalm-import-type Files_SharingRemoteShare from ResponseDefinitions
* @api
*/
#[TokenScope(scope: TokenScopes::FILES_WRITE)]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

shouldnt this be allowing to write shares?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Accepting mounts the share into your own files, so I decided to go with files write. The sharing write scope is for creating shares only, and a client without it should still be able to accept incoming shares. Happy to change it if you disagree?

Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
…opeRequired

Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
…ble API

Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
@pringelmann
pringelmann force-pushed the feat/noid/app-password-route-gate branch from 65fc6f8 to d7c0abc Compare October 26, 2026 09:55
@pringelmann
pringelmann merged commit caa1855 into master Oct 29, 2026
221 of 226 checks passed
@pringelmann
pringelmann deleted the feat/noid/app-password-route-gate branch October 29, 2026 15:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scoped app passwords: route gate

5 participants