fix: only navigate to a validated absolute http(s) return URL - #620
fix: only navigate to a validated absolute http(s) return URL#620oc-tmueller wants to merge 1 commit into
Conversation
kw-fscheuer
left a comment
There was a problem hiding this comment.
Reviewed as a security fix for the DOM XSS → account takeover finding tracked internally as OC10-163 (YesWeHack #YWH-PGM8721-594, CVSS:3.1 8.7). Approving — this closes it, and the approach is the right one.
What I checked and liked:
- The source is removed, not filtered. Dropping
getURLParameter('server')in favour of a server-populated hidden input fixes the root cause instead of blocking one payload. Havingpublic()andindex()emit an emptyreturn_to_serveris what actually closes the reported page — the vulnerable route can no longer produce a navigable value at all. - No render site was missed.
TemplateResponse('richdocuments', 'documents', …)occurs at exactly three places (index,public,federated) and all three now set the key, so the template never renders with it undefined. _absoluteHttpUrl()gets the subtle part right. It does not rely onnew URL()throwing — it would not, sincenew URL('javascript:alert(1)')parses successfully — but explicitly checksurl.protocolandurl.host. That is the single easiest thing to get wrong here. Keeping the path component so subdirectory installs work is the right call too.isValidServerUrl()is sound: requiringparse_url()to yield both a scheme and a non-empty host rejectsjavascript:/data:(no host) and scheme-relative//evil.tld(no scheme), and the scheme comparison is case-insensitive.- Both
messagelisteners are guarded, includingeditorInitListener— our original assessment only cited the second one, so thanks for catching that — and narrowing the outboundpostMessagetarget from'*'is a genuine improvement beyond the reported issue. - Failure modes are closed throughout: messages are dropped when the origin cannot be determined, and
onClose()falls through to the document list rather than navigating on a value that does not validate. - Test coverage is solid. Asserting
getDocumentByFederatedTokenisnever()called on the invalid cases proves the request stops at the guard rather than merely erroring later, which is the right thing to pin.
Three non-blocking hardening notes, none of which should hold up the merge:
-
_wopiOrigin()falls back to our own origin whenurlsrcis empty.new URL(undefined, window.location.href)does not throw —undefinedstringifies to"undefined"and resolves as a relative path — so the helper returns the ownCloud origin rather thannull, and the listeners would then accept messages from our own origin. Not exploitable (posting from that origin already implies script execution there, and the editor pages always setrd_urlsrc), but the failure mode is worth closing:_wopiOrigin: function() { if (!documentsMain.urlsrc) { return null; } try { return new URL(documentsMain.urlsrc, window.location.href).origin; } catch (exc) { … } }
-
Worth a comment on the ordering dependency.
isValidServerUrl()accepts any absolute http(s) host, so on its own it would allowserver=http://attacker.tldas a navigation target. What actually prevents that is the federation allowlist downstream —getWopiForToken()→FederationService::isServerAllowed(), which fails closed on an emptyrichdocuments.federation_allowlistand makesfederated()returnresponseError()before rendering. That layering is correct, but implicit; a one-line comment at the new guard noting that host trust comes fromisServerAllowed()and this check is scheme/shape only would stop a future refactor from quietly turning it into an open redirect. -
The
allowInlineScript(true)opt-out remains (index,public,federated). Correctly out of scope for this PR, but it is what made this sink reachable at all, so until it goes the next DOM sink here will also fail open. This PR conveniently demonstrates the pattern for retiring it —#return-to-serverjoining#wopi-url. Worth its own issue.
Noted on testing: the JS side having no harness is a real gap for items like (1), since lint covers src/ only, not js/. Not something to solve in this PR.
The return-to-server value is now supplied by the server side through a hidden input instead of being read from the URL. federated() validates it as an absolute http(s) URL with a non-empty host and rejects anything else the same way the other request guards do; index() and public() emit an empty value, since only a federated share ever returns to a remote server. documents.js re-checks the value before using it as a navigation target and falls back to the document list if it does not parse. A path is accepted, installations can live in a subdirectory. Also narrows the WOPI postMessage target origin from '*' to the Collabora Online origin derived from the discovery urlsrc, and ignores incoming messages that do not come from that origin. That origin has to fail closed, and new URL() throws for neither of the two ways it can be missing: an empty urlsrc - which is what discovery returns when it cannot be read - resolves against the base URL and would make this server its own Collabora Online origin, and a urlsrc that is not http(s) has the opaque origin 'null', which is also what a sandboxed frame reports as its own origin. Both now yield no origin at all, so messages are neither accepted nor sent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Müller <323649642+oc-tmueller@users.noreply.github.com>
702b36d to
3e633bb
Compare
|
Notes 1 and 2 are addressed in the force-pushed Note 1 —
The second row is the worse one: an opaque origin serializes to the string Both now yield no origin at all. The guard reuses _wopiOrigin: function() {
if (!documentsMain.urlsrc) {
return null;
}
var resolved;
try {
resolved = new URL(documentsMain.urlsrc, window.location.href);
} catch (exc) { … return null; }
if (!documentsMain._absoluteHttpUrl(resolved.href)) {
return null;
}
return resolved.origin;
}The callers already handled Note 2 — the ordering dependency. Confirmed and now written down at the guard: it checks the shape only, and host trust comes from Note 3 — On the testing gap. Fair, and it is the reason note 1 could sit there unnoticed. There is no JS harness on these branches, so this change has no CI coverage — I verified it against a local karma/jasmine harness instead, and all six new |
Summary
The value that tells the editor where to navigate when it is closed is now
supplied by the server side and validated, instead of being taken from the
serverURL parameter indocuments.js.DocumentController::federated()validatesserveras an absolute http(s)URL with a non-empty host and returns the usual
responseError()otherwise,alongside the existing request guards. The validated value is handed to the
template as
return_to_server.index()andpublic()emit an emptyreturn_to_server— only a federatedshare ever returns to a remote server. All three methods render the same
template, so all three set the key.
templates/documents.phpemits it as a hidden input, following the existing#wopi-urlidiom.documents.jsreads that hidden input rather than the URL parameter, andre-checks the value with
new URL()before using it as a navigation target,falling back to the document list if it does not parse. A path component is
accepted, so subdirectory installs keep working.
Separately, the WOPI
postMessagetarget origin is narrowed from'*'to theCollabora Online origin derived from the discovery
urlsrc, and bothmessagelisteners now ignore events that do not come from that origin.
Core's shared
getURLParameter()is deliberately untouched — it is a globalalso used by
apps/files.Testing
20 new PHPUnit cases in
DocumentControllerTest:federated()rejects values that are not absolute http(s) URLs, includingscheme-relative ones, and accepts well-formed ones — in particular a
subdirectory install and a non-default port.
public()always emits anempty one.
public()accepts noserverparameter at all.Full unit suite: 78 tests, 199 assertions, green on PHP 8.3.
php-cs-fixer (ownCloud coding standard): clean. phpstan level 5 on
appinfo lib:no errors.
The JS side is verified manually — this repo has no JS test harness
(
package.jsonhaslintandbuildonly, andlintcoverssrc/only, notjs/).🤖 Generated with Claude Code