Repository navigation
Conversation
…tries Assisted-by: omp:claude-opus-5-5 Signed-off-by: meatybot <avatarcc@tuta.io>
Findings
|
There was a problem hiding this comment.
Actionable comments posted: 1

Review info
-
Run configuration-
- Source mode: Auto
- Repository: Unavailable
- Branch: Unavailable
- File: No configuration file from this repository applied
- Commit: Unavailable
- Layers: Organization UI
- Review profile: CHILL
- Plan: Advanced
- Run ID:
448a5e3c-a413-457c-80ee-b3d15f92f774
Files ignored due to path filters: 1-
js/app_api-reference.js.mapis excluded by!**/*.map
Files selected for processing: 24-
CHANGELOG.mdappinfo/info.xmlappinfo/routes.phpjs/app_api-reference.jsjs/app_api-reference.js.licensejs/app_api-reference.js.map.licenselib/AppInfo/Application.phplib/Controller/OCSUiController.phplib/Db/UI/ReferenceProvider.phplib/Db/UI/ReferenceProviderMapper.phplib/Listener/RenderReferenceListener.phplib/Migration/Version036000Date20261024120000.phplib/ResponseDefinitions.phplib/Service/ExAppService.phplib/Service/UI/ReferenceProviderService.phpopenapi-full.jsonopenapi.jsonsrc/reference.jssrc/referenceLoader.jssrc/referenceLoader.spec.jstests/php/Controller/OCSUiControllerTest.phptests/php/Service/ExAppServiceGetAppInfoTest.phptests/php/Service/ReferenceProviderServiceTest.phpwebpack.js
Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.
-
Assisted-by: omp:claude-opus-5-5 Signed-off-by: meatybot <avatarcc@tuta.io>
|
I pushed a second commit with the changes from a review pass, kept separate so it is easy to see:
Open question: providers are still registered in |
There was a problem hiding this comment.
Actionable comments posted: 1

Review info
-
Run configuration-
- Source mode: Auto
- Repository: Unavailable
- Branch: Unavailable
- File: No configuration file from this repository applied
- Commit: Unavailable
- Layers: Organization UI
- Review profile: CHILL
- Plan: Advanced
- Run ID:
4f2f8da1-d6bf-4dc2-8882-fd06dd2e1e58
Files selected for processing: 7-
lib/Controller/OCSUiController.phplib/Listener/RenderReferenceListener.phplib/Service/UI/ReferenceProviderService.phpopenapi-full.jsonopenapi.jsontests/php/Service/ReferenceProviderResolveTest.phptests/php/Service/ReferenceProviderServiceTest.php
Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.
-
| $body = self::patternBody($pattern); | ||
| if ($body === null || preg_match(self::HOST_ANCHOR, $body) !== 1) { | ||
| throw new \InvalidArgumentException(sprintf( | ||
| 'Match pattern must start with ^https:// and a literal host with escaped dots, like #^https://example\.com/#: %s', $pattern | ||
| )); | ||
| } |
There was a problem hiding this comment.
Fix the match-pattern host check. A top-level | alternative bypasses it.
validateMatchPattern() only tests that the pattern body starts with ^https://<literal host>. It does not check the rest of the body. A pattern such as #^https://example\.com/|.*# passes HOST_ANCHOR, because the prefix ^https://example\.com/ matches. Its second top-level alternative, .*, matches every link.
matchReference() runs the pattern as a whole. A malicious or careless ExApp can therefore register a pattern that matches every link a user pastes or renders. The server then sends those links and the user ID to the ExApp. The class comment and the OpenAPI text state that this is exactly what the validation prevents.
A trailing empty alternative, such as #^https://example\.com/|#, has the same effect.
Reject any | that is outside a group and outside a character class.
🔒 Proposed fix
-
--- "a/lib/Service/UI/ReferenceProviderService.php" +++ "b/lib/Service/UI/ReferenceProviderService.php" @@ -230,13 +230,49 @@ if (@preg_match($pattern, '') === false) { throw new \InvalidArgumentException(sprintf('Invalid match pattern: %s', $pattern)); } $body = self::patternBody($pattern); - if ($body === null || preg_match(self::HOST_ANCHOR, $body) !== 1) { + if ($body === null || preg_match(self::HOST_ANCHOR, $body) !== 1 || self::hasTopLevelAlternation($body)) { throw new \InvalidArgumentException(sprintf( - 'Match pattern must start with ^https:// and a literal host with escaped dots, like #^https://example\.com/#: %s', $pattern + 'Match pattern must start with ^https:// and a literal host with escaped dots, without a top-level "|", like #^https://example\.com/#: %s', $pattern )); } } + + /** + * True if the PCRE body has a `|` outside any group and outside a character class. + * Such a branch would not be bound by the leading `^https://host` anchor. + */ + private static function hasTopLevelAlternation(string $body): bool { + $depth = 0; + $inClass = false; + for ($i = 0, $n = strlen($body); $i < $n; $i++) { + $c = $body[$i]; + if ($c === '\\') { + $i++; + continue; + } + if ($inClass) { + $inClass = $c !== ']'; + continue; + } + if ($c === '[') { + $inClass = true; + if (($body[$i + 1] ?? '') === '^') { + $i++; + } + if (($body[$i + 1] ?? '') === ']') { + $i++; + } + } elseif ($c === '(') { + $depth++; + } elseif ($c === ')') { + $depth--; + } elseif ($c === '|' && $depth === 0) { + return true; + } + } + return false; + } /** * The part of a PCRE between its delimiters, or null if it has none.
Add rejection cases to unanchoredPatterns() in tests/php/Service/ReferenceProviderServiceTest.php:
'top-level alternative' => ['#^https://example\.com/|.*#'],
'empty alternative' => ['#^https://example\.com/|#'],The existing accepted pattern (jpg|png) has its | inside a group, so it stays valid.
Implements #1099: an ExApp can register reference providers, so it can add an entry to the Smart Picker, ship its own picker UI, and turn its links into rich previews in Text, Talk, Deck and the other apps that render references.
API
POST,GETandDELETEon/ocs/v2.php/apps/app_api/api/v1/ui/reference-provider, next to the top menu endpoints:providerId[a-zA-Z0-9_.-]{1,64}. The picker script registers its custom element under the same IDtitle,iconUrl,ordericonUrlis a path inside the ExApp or anhttp(s)URLsearchProvidersIdsmatchPatternsscriptPathAn ExApp can only register, read or delete providers under its own app ID. A provider ID owned by another ExApp is refused with 400.
How it works
Each provider of an enabled ExApp becomes an anonymous
ADiscoverableReferenceProvider(alsoISearchableReferenceProvider), registered inApplication::register()under a synthetic class name. This is the patternTaskProcessingService::registerExAppTaskProcessingProviders()used, so the number of providers is not capped. There is no reference provider event, unlikeGetTaskProcessingProvidersEvent, so it has to be done inregister(). The rows are read from the distributed cache.matchReference()checks the stored patterns in PHP, so only a matching link costs a request:POST /reference/resolveon the ExApp with{provider_id, reference}. The answer (title,description,image_url,url,rich_object_type,rich_object) becomes theReference. The cache key is per user, because the ExApp answers per user.RenderReferenceEventlistener addsapp_api-reference.js, which adds each ExApp's picker script and publishes the base URL the browser must use:/exapps/<appid>with HaRP, the PHP proxy otherwise. This answers open question 1 of the issue. The script registers its element withregisterCustomPickerElement(), as PHP apps do.Security
scriptPathand a relativeiconUrlmust stay inside the ExApp: only[A-Za-z0-9._~/-], no.,..or empty segments. That refuses schemes,%-encoding,\,?and#. Without this,../other_app/x.jswould make every page that shows the Smart Picker run another ExApp's script.app1andapp10).Tests
ReferenceProviderServiceTestcovers listing only enabled ExApps, ownership, validation, pattern matching, the cache prefix, and path escapes forscriptPathandiconUrl.OCSUiControllerTestcovers the endpoint round trip and errors. Full suite: 385 tests pass.referenceLoader.spec.jscovers ordering, de-duplication, and the scripts it must not load.openapi*.jsonis regenerated, andjs/app_api-reference.jsis built.Manually, on Nextcloud master with Text, HaRP and the PHP proxy: an ExApp (meatybot/caption_obvious) registered a provider on enable. The entry showed in the Smart Picker, the picker loaded its own Vue UI through
/exapps/, inserted a link, and the link rendered as a preview. Disabling and removing the ExApp removed the provider. A second ExApp registered its own provider next to it: both pickers loaded on the same page, each link went to its own provider, and the second ExApp could not take over, read or delete the first one's provider.Notes
36.0.0-dev.1for the migration (Version036000Date20261024120000, tableex_ui_reference_providers).nc_py_apisupport (nc.ui.reference_provider, sync and async) is written but not proposed yet. I would rather settle the API here first. Until then, ExApps can call the OCS endpoint directly, as caption_obvious does./exapps/) are not part of this PR. The verb question has since been handled in fix(harp): send the route verb to HaRP #1138 and fix(proxy): match the route verb like HaRP #1144.