Skip to content

Commit

Permalink
Issue 75: Add proofing to WOPI routes.
Browse files Browse the repository at this point in the history
  • Loading branch information
donquixote committed Dec 19, 2024
1 parent dcd2667 commit d3465fa
Show file tree
Hide file tree
Showing 6 changed files with 582 additions and 3 deletions.
6 changes: 3 additions & 3 deletions collabora_online.routing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ collabora-online.wopi.info:
action: 'info'
methods: [ GET ]
requirements:
_permission: 'access content'
_collabora_online_wopi_access: 'TRUE'
_format: 'collabora_online_wopi'
options:
parameters:
Expand All @@ -63,7 +63,7 @@ collabora-online.wopi.contents:
action: 'content'
methods: [ GET ]
requirements:
_permission: 'access content'
_collabora_online_wopi_access: 'TRUE'
_format: 'collabora_online_wopi'
options:
parameters:
Expand All @@ -79,7 +79,7 @@ collabora-online.wopi.save:
action: 'save'
methods: [ POST ]
requirements:
_permission: 'access content'
_collabora_online_wopi_access: 'TRUE'
_format: 'collabora_online_wopi'
options:
parameters:
Expand Down
6 changes: 6 additions & 0 deletions collabora_online.services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@ services:
Drupal\collabora_online\MediaHelperInterface:
class: Drupal\collabora_online\MediaHelper
Drupal\collabora_online\EventSubscriber\ExceptionWopiSubscriber: { }
Drupal\collabora_online\Access\WopiProofAccessCheck:
tags:
- { name: access_check, applies_to: _collabora_online_wopi_access }
Drupal\collabora_online\Access\WopiTimeoutAccessCheck:
tags:
- { name: access_check, applies_to: _collabora_online_wopi_access }
228 changes: 228 additions & 0 deletions src/Access/WopiProofAccessCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
<?php

/*
* Copyright the Collabora Online contributors.
*
* SPDX-License-Identifier: MPL-2.0
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/

declare(strict_types=1);

namespace Drupal\collabora_online\Access;

use Drupal\collabora_online\Cool\CollaboraDiscoveryInterface;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Utility\Error;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Crypt\RSA;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;

/**
* Access checker to verify the WOPI token.
*
* This does not check the X-WOPI-Timestamp expiration.
*
* This is inspired by the wopi-lib package, see
* https://github.com/Champs-Libres/wopi-lib/blob/master/src/Service/ProofValidator.php.
*/
class WopiProofAccessCheck implements AccessInterface {

public function __construct(
protected readonly CollaboraDiscoveryInterface $discovery,
#[Autowire(service: 'logger.channel.collabora_online')]
protected readonly LoggerInterface $logger,
protected readonly ConfigFactoryInterface $configFactory,
) {}

/**
* Checks if the request has a WOPI proof.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function access(Request $request): AccessResultInterface {
// Each incoming request will have a different proof and timestamp, so there
// is no point in caching.
return $this->doCheckAccess($request)
->setCacheMaxAge(0);
}

/**
* Checks if the request has a WOPI proof.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
*
* @return \Drupal\Core\Access\AccessResult
* The access result.
*/
protected function doCheckAccess(Request $request): AccessResult {
$cool_settings = $this->configFactory->get('collabora_online.settings')->get('cool');
if (!($cool_settings['wopi_proof'] ?? TRUE)) {
return AccessResult::allowed();
}
$keys = $this->getKeys();
if (!isset($keys['current'])) {
return AccessResult::forbidden('Missing or incomplete WOPI proof keys.');
}
$signatures = $this->getSignatures($request);
if (!isset($signatures['current'])) {
return AccessResult::forbidden('Missing or incomplete WOPI proof headers.');
}
$subject = $this->getSubject($request);

// Try different key and signature combinations.
foreach ($keys as $key_name => $key) {
foreach ($signatures as $signature_name => $signature) {
if ($key_name === 'old' && $signature_name === 'old') {
// Don't verify an old signature with an old key.
continue;
}
$success = $key->verify($subject, $signature);
if ($success) {
return AccessResult::allowed();
}
}
}
return AccessResult::forbidden('WOPI proof mismatch.');
}

/**
* Gets the message to be signed.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
*
* @return string
* The message to be signed.
*/
protected function getSubject(Request $request): string {
// This class is not responsible for checking the expiration, but it still
// needs the WOPI timestamp to build the message for the signature.
$timestamp_ticks = $request->headers->get('X-WOPI-Timestamp');
$token = $request->query->get('access_token', '');
$url = $request->getUri();
return sprintf(
'%s%s%s%s%s%s',
pack('N', strlen($token)),
$token,
pack('N', strlen($url)),
strtoupper($url),
pack('N', 8),
pack('J', $timestamp_ticks)
);
}

/**
* Gets RSA public keys from the discovery.xml.
*
* The discovery.xml has a current and an old key.
* This is to support situations when the key has been recently changed, but
* the incoming request was signed with the older key.
*
* @return array<'current'|'old', \phpseclib3\Crypt\RSA\PublicKey>
* Current and old public key, or just the current if they are the same, or
* empty array if none found.
*/
protected function getKeys(): array {
// Get current and old key.
// Remove empty values.
// If both are the same, keep only the current one.
$public_keys = array_unique(array_filter([
'current' => $this->discovery->getProofKey(),
'old' => $this->discovery->getProofKeyOld(),
]));
$key_objects = [];
foreach ($public_keys as $key_name => $key_str) {
$key_obj = $this->prepareKey($key_str, $key_name);
if ($key_obj === NULL) {
continue;
}
$key_objects[$key_name] = $key_obj;
}
return $key_objects;
}

/**
* Gets an RSA key object based on a string value.
*
* @param string $key_str
* Key string value from discovery.xml.
* @param 'current'|'old' $key_name
* Key name, only used for logging.
*
* @return \phpseclib3\Crypt\RSA\PublicKey|null
* An RSA public key object, or NULL on failure.
*/
protected function prepareKey(string $key_str, string $key_name): ?RSA\PublicKey {
try {
$key_object = PublicKeyLoader::loadPublicKey($key_str);
} catch (\Throwable $e) {
$log_message = "Problem with the @name key from discovery.yml:<br>\n"
. Error::DEFAULT_ERROR_MESSAGE;
$log_args = [
...Error::decodeException($e),
'@name' => $key_name,
];
$this->logger->error($log_message, $log_args);
return NULL;
}
if (!$key_object instanceof RSA\PublicKey) {
$log_message = "Problem with the @name key from discovery.yml:<br>\n"
. "Expected RSA public key, found @type.";
$log_args = [
'@name' => $key_name,
'@type' => get_debug_type($key_object),
];
$this->logger->error($log_message, $log_args);
return NULL;
}
return $key_object
->withHash('sha256')
->withPadding(RSA::SIGNATURE_RELAXED_PKCS1);
}

/**
* Gets the current and old signature from the request.
*
* The request will have a current and an old signature.
* This is to support situations when the key has been recently changed, but
* the cached discovery.xml still has the old key.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* Incoming request that may have a signature to be verified.
*
* @return array{current?: string, old?: string}
* Current and old signature from the request, decoded and ready for use.
* If they are the same, only one of them is returned.
* If no signatures are found, an empty array is returned.
*/
protected function getSignatures(Request $request): array {
// Get the current and old proof header.
// Remove empty values.
// If both are the same, keep only the current one.
$proof_headers = array_unique(array_filter([
'current' => $request->headers->get('X-WOPI-Proof'),
'old' => $request->headers->get('X-WOPI-ProofOld'),
]));
$decoded_proof_headers = array_map(
fn (string $header_value) => base64_decode($header_value, TRUE),
$proof_headers,
);
// Remove false values where decoding failed.
return array_filter($decoded_proof_headers);
}

}
88 changes: 88 additions & 0 deletions src/Access/WopiTimeoutAccessCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

/*
* Copyright the Collabora Online contributors.
*
* SPDX-License-Identifier: MPL-2.0
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/

declare(strict_types=1);

namespace Drupal\collabora_online\Access;

use Drupal\collabora_online\Util\DotNetTime;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Symfony\Component\HttpFoundation\Request;

/**
* Access checker to deny expired requests based on X-WOPI-Timestamp.
*
* Note that the X-WOPI-Timestamp is in DotNet ticks.
*/
class WopiTimeoutAccessCheck implements AccessInterface {

public function __construct(
protected readonly TimeInterface $time,
protected readonly ConfigFactoryInterface $configFactory,
// The recommended TTL is 20 minutes.
protected readonly int $ttlSeconds = 20 * 60,
) {}

/**
* Checks if the X-WOPI-Timestamp is expired.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function access(Request $request): AccessResultInterface {
// Each incoming request will have a different timestamp, so there is no
// point in caching.
return $this->doCheckAccess($request)
->setCacheMaxAge(0);
}

/**
* Checks if the X-WOPI-Timestamp is expired, without cache metadata.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
*
* @return \Drupal\Core\Access\AccessResult
* The access result.
*/
protected function doCheckAccess(Request $request): AccessResult {
$cool_settings = $this->configFactory->get('collabora_online.settings')->get('cool');
if (!($cool_settings['wopi_proof'] ?? TRUE)) {
return AccessResult::allowed();
}
$wopi_ticks_str = $request->headers->get('X-WOPI-Timestamp', '');
// Unfortunately, is_numeric() confuses the IDE's static analysis, so use
// regular expression instead.
if (!preg_match('#^[1-9]\d+$#', $wopi_ticks_str)) {
return AccessResult::forbidden('The X-WOPI-Timestamp header is missing, empty or invalid.');
}
$wopi_timestamp = DotNetTime::ticksToTimestamp((float) $wopi_ticks_str);
$now_timestamp = $this->time->getRequestTime();
$wopi_age_seconds = $now_timestamp - $wopi_timestamp;
if ($wopi_age_seconds > $this->ttlSeconds) {
return AccessResult::forbidden(sprintf(
'The X-WOPI-Timestamp header is %s seconds old, which is more than the %s seconds TTL.',
$wopi_age_seconds,
$this->ttlSeconds,
));
}
return AccessResult::allowed();
}

}
Loading

0 comments on commit d3465fa

Please sign in to comment.