release(v3.10.0): resumable upload hardening and ONLYOFFICE callback authorization tightening

- upload(resumable): stop deriving temporary chunk directories from raw client identifiers and switch to hashed internal temp-folder names
- upload(cleanup): require authenticated upload access for resumable temp-folder removal and keep recursive cleanup bounded to the intended staging root
- upload(compat): preserve normal resumable upload flow while making temp-path resolution consistent across probe, write, and cleanup paths
- onlyoffice(callback): issue save callbacks only for editable sessions, bind callbacks to the authorized actor/file, and stop trusting body-supplied editor identities
- onlyoffice(origin): restrict callback fetch URLs to the configured Document Server origin while keeping callback JWT validation compatible with existing deployments
This commit is contained in:
Ryan
2026-03-16 23:32:33 -04:00
committed by GitHub
parent 0af3819192
commit 3871f9fd16
4 changed files with 349 additions and 74 deletions
+35
View File
@@ -1,5 +1,40 @@
# Changelog
## Changes 03/16/2026 (v3.10.0)
`release(v3.10.0): resumable upload hardening and ONLYOFFICE callback authorization tightening`
**Commit message**
```text
release(v3.10.0): resumable upload hardening and ONLYOFFICE callback authorization tightening
- upload(resumable): stop deriving temporary chunk directories from raw client identifiers and switch to hashed internal temp-folder names
- upload(cleanup): require authenticated upload access for resumable temp-folder removal and keep recursive cleanup bounded to the intended staging root
- upload(compat): preserve normal resumable upload flow while making temp-path resolution consistent across probe, write, and cleanup paths
- onlyoffice(callback): issue save callbacks only for editable sessions, bind callbacks to the authorized actor/file, and stop trusting body-supplied editor identities
- onlyoffice(origin): restrict callback fetch URLs to the configured Document Server origin while keeping callback JWT validation compatible with existing deployments
```
**Changed**
- **Resumable temp-folder naming**
- Resumable upload staging now maps client identifiers to hashed internal temp-folder names instead of using raw identifier values directly in filesystem paths.
- The same temp-folder mapping is now used consistently for chunk probe, chunk staging, and resumable cleanup operations.
**Fixed**
- **Resumable cleanup guardrails**
- Tightened resumable temp-folder cleanup so recursive deletion stays bounded to the expected staging area.
- The resumable cleanup endpoint now requires an authenticated session with upload permission for the target folder before removing chunk temp data.
- **ONLYOFFICE save authorization**
- View-only ONLYOFFICE sessions no longer receive save-capable callback URLs.
- ONLYOFFICE save callbacks are now bound to the authorized actor and file, and no longer trust body-supplied editor identities.
- Save fetches are restricted to the configured ONLYOFFICE Document Server origin before FileRise downloads updated content and writes it back to disk.
---
## Changes 03/15/2026 (v3.9.4)
`release(v3.9.4): preserve legacy compatibility path while keeping post-rotation key-file preference`
+80 -11
View File
@@ -119,6 +119,53 @@ class UploadModel
return $folderSan;
}
private static function normalizeResumableIdentifier($identifier): ?string
{
$identifier = trim((string)$identifier);
if ($identifier === '') {
return null;
}
if (strlen($identifier) > 512) {
return null;
}
if (preg_match('/[\x00-\x1F\x7F]/', $identifier)) {
return null;
}
return $identifier;
}
private static function resumableTempFolderName($identifier): ?string
{
$normalized = self::normalizeResumableIdentifier($identifier);
if ($normalized === null) {
return null;
}
return 'resumable_' . hash('sha256', $normalized);
}
private static function resumableTempDir(string $baseUploadDir, $identifier): ?string
{
$folderName = self::resumableTempFolderName($identifier);
if ($folderName === null) {
return null;
}
return rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR . $folderName . DIRECTORY_SEPARATOR;
}
private static function isPathWithinRoot(string $path, string $root): bool
{
$rootReal = realpath($root);
$pathReal = realpath($path);
if ($rootReal === false || $pathReal === false) {
return false;
}
$rootNorm = rtrim(str_replace('\\', '/', $rootReal), '/') . '/';
$pathNorm = rtrim(str_replace('\\', '/', $pathReal), '/') . '/';
return strpos($pathNorm, $rootNorm) === 0;
}
private static function loadResumableIndex(): array
{
$path = self::resumableIndexPath();
@@ -700,16 +747,23 @@ class UploadModel
&& isset($post['resumableChunkNumber'], $post['resumableIdentifier'])
) {
$chunkNumber = (int)($post['resumableChunkNumber'] ?? 0);
$resumableIdentifier = $post['resumableIdentifier'] ?? '';
$resumableIdentifier = self::normalizeResumableIdentifier($post['resumableIdentifier'] ?? '');
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
if ($chunkNumber < 1 || $resumableIdentifier === null) {
return ['error' => 'Invalid resumable upload request'];
}
$baseUploadDir = self::stagingRoot($isLocal);
if ($folderSan !== '') {
$baseUploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
}
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
$tempDir = self::resumableTempDir($baseUploadDir, $resumableIdentifier);
if ($tempDir === null) {
return ['error' => 'Invalid resumable upload request'];
}
$chunkFile = $tempDir . $chunkNumber;
return ['status' => file_exists($chunkFile) ? 'found' : 'not found'];
@@ -719,10 +773,14 @@ class UploadModel
if (isset($post['resumableChunkNumber'])) {
$chunkNumber = (int)$post['resumableChunkNumber'];
$totalChunks = (int)$post['resumableTotalChunks'];
$resumableIdentifier = $post['resumableIdentifier'] ?? '';
$resumableIdentifier = self::normalizeResumableIdentifier($post['resumableIdentifier'] ?? '');
$resumableFilename = urldecode(basename($post['resumableFilename'] ?? ''));
$relativeSubDir = '';
if ($chunkNumber < 1 || $totalChunks < 1 || $chunkNumber > $totalChunks || $resumableIdentifier === null) {
return ['error' => 'Invalid resumable upload request'];
}
if (!empty($post['resumableRelativePath'])) {
[$subDir, $relFile] = self::parseRelativePath((string)$post['resumableRelativePath']);
if ($subDir === null) {
@@ -757,7 +815,10 @@ class UploadModel
return ['error' => 'Failed to create upload directory'];
}
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
$tempDir = self::resumableTempDir($baseUploadDir, $resumableIdentifier);
if ($tempDir === null) {
return ['error' => 'Invalid resumable upload request'];
}
if (!self::ensureDir($tempDir, $createdDirs)) {
return ['error' => 'Failed to create temporary chunk directory'];
}
@@ -780,8 +841,10 @@ class UploadModel
}
}
$cleanupChunk = function () use ($tempDir, $createdDirs, $stagingRoot, $isLocal): void {
self::rrmdir($tempDir);
$cleanupChunk = function () use ($tempDir, $baseUploadDir, $createdDirs, $stagingRoot, $isLocal): void {
if (self::isPathWithinRoot($tempDir, $baseUploadDir)) {
self::rrmdir($tempDir);
}
if (!$isLocal) {
self::cleanupCreatedDirs($createdDirs, $stagingRoot);
}
@@ -1279,10 +1342,13 @@ class UploadModel
public static function removeChunks(string $folder): array
{
$folder = urldecode($folder);
// The folder name should exactly match the "resumable_" pattern.
$regex = "/^resumable_" . PATTERN_FOLDER_NAME . "$/u";
if (!preg_match($regex, $folder)) {
return ['error' => 'Invalid folder name'];
if (strpos($folder, 'resumable_') === 0) {
$folder = substr($folder, strlen('resumable_'));
}
$tempFolderName = self::resumableTempFolderName($folder);
if ($tempFolderName === null) {
return ['error' => 'Invalid resumable identifier'];
}
$isLocal = self::isLocalSourceType();
@@ -1292,9 +1358,12 @@ class UploadModel
$baseDir = rtrim($baseDir, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $targetFolder) . DIRECTORY_SEPARATOR;
}
$tempDir = $baseDir . $folder;
$tempDir = rtrim($baseDir, '/\\') . DIRECTORY_SEPARATOR . $tempFolderName;
$hadDir = is_dir($tempDir);
if ($hadDir) {
if (!self::isPathWithinRoot($tempDir, $baseDir)) {
return ['error' => 'Invalid temporary folder'];
}
self::rrmdir($tempDir);
}
if (is_dir($tempDir)) {
@@ -8,6 +8,7 @@ use FileRise\Support\ACL;
use FileRise\Storage\SourceContext;
use FileRise\Storage\StorageRegistry;
use FileRise\Domain\AdminModel;
use FileRise\Domain\AuthModel;
use FileRise\Domain\FileModel;
// src/controllers/OnlyOfficeController.php
@@ -162,6 +163,143 @@ class OnlyOfficeController
return rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
}
private function decodeJwtPayload(string $jwt, string $secret): ?array
{
$parts = explode('.', $jwt, 3);
if (count($parts) !== 3) {
return null;
}
[$b64Header, $b64Payload, $b64Sig] = $parts;
$headerJson = $this->b64uDec($b64Header);
$payloadJson = $this->b64uDec($b64Payload);
$sig = $this->b64uDec($b64Sig);
if ($headerJson === false || $payloadJson === false || $sig === false) {
return null;
}
$header = json_decode($headerJson, true);
if (!is_array($header) || strtoupper((string)($header['alg'] ?? '')) !== 'HS256') {
return null;
}
$calc = hash_hmac('sha256', $b64Header . '.' . $b64Payload, $secret, true);
if (!hash_equals($calc, $sig)) {
return null;
}
$payload = json_decode($payloadJson, true);
return is_array($payload) ? $payload : null;
}
private function createSignedPayloadToken(array $payload, string $secret): string
{
$data = json_encode($payload, JSON_UNESCAPED_SLASHES);
if (!is_string($data) || $data === '') {
return '';
}
$sig = hash_hmac('sha256', $data, $secret, true);
return $this->b64uEnc($data) . '.' . $this->b64uEnc($sig);
}
private function decodeSignedPayloadToken(string $token, string $secret): ?array
{
if ($token === '' || strpos($token, '.') === false) {
return null;
}
[$b64Data, $b64Sig] = explode('.', $token, 2);
$data = $this->b64uDec($b64Data);
$sig = $this->b64uDec($b64Sig);
if ($data === false || $sig === false) {
return null;
}
$calc = hash_hmac('sha256', $data, $secret, true);
if (!hash_equals($calc, $sig)) {
return null;
}
$payload = json_decode($data, true);
return is_array($payload) ? $payload : null;
}
private function effectiveAclPermsForUser(string $username): array
{
$perms = loadUserPermissions($username) ?: [];
if (AuthModel::getUserRole($username) === '1') {
$perms['admin'] = true;
}
return is_array($perms) ? $perms : [];
}
private function callbackJwtTokenFromRequest(array $rawBody): string
{
$bodyToken = (string)($rawBody['token'] ?? '');
if ($bodyToken !== '') {
return trim($bodyToken);
}
$headers = array_change_key_case(getallheaders() ?: [], CASE_LOWER);
$auth = trim((string)($headers['authorization'] ?? ''));
if ($auth !== '' && preg_match('/^Bearer\s+(.+)$/i', $auth, $m)) {
return trim((string)$m[1]);
}
return '';
}
private function trustedCallbackBody(array $rawBody, string $secret): ?array
{
$jwt = $this->callbackJwtTokenFromRequest($rawBody);
if ($jwt === '') {
return $rawBody;
}
$payload = $this->decodeJwtPayload($jwt, $secret);
if (!is_array($payload)) {
return null;
}
if (isset($payload['payload']) && is_array($payload['payload'])) {
return $payload['payload'];
}
return $payload;
}
private function normalizeOrigin(string $url): ?array
{
$url = trim($url);
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
return null;
}
$scheme = strtolower((string)(parse_url($url, PHP_URL_SCHEME) ?: ''));
$host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?: ''));
if (($scheme !== 'http' && $scheme !== 'https') || $host === '') {
return null;
}
$port = (int)(parse_url($url, PHP_URL_PORT) ?: ($scheme === 'https' ? 443 : 80));
return [
'scheme' => $scheme,
'host' => $host,
'port' => $port,
];
}
private function isAllowedOnlyOfficeUrl(string $url): bool
{
$target = $this->normalizeOrigin($url);
$docs = $this->normalizeOrigin($this->effectiveDocsOrigin());
if ($target === null || $docs === null) {
return false;
}
return $target['scheme'] === $docs['scheme']
&& $target['host'] === $docs['host']
&& $target['port'] === $docs['port'];
}
private function normalizeSourceId($id): string
{
$id = trim((string)$id);
@@ -316,7 +454,7 @@ class OnlyOfficeController
@session_start();
$user = $_SESSION['username'] ?? 'anonymous';
$perms = [];
$perms = $this->effectiveAclPermsForUser((string)$user);
$isAdmin = \ACL::isAdmin($perms);
$enabled = $this->effectiveEnabled();
@@ -426,21 +564,31 @@ class OnlyOfficeController
$tok = $this->b64uEnc($data) . '.' . $this->b64uEnc($sig);
$fileUrl = $fileOriginForDocs . '/api/onlyoffice/signed-download.php?tok=' . rawurlencode($tok);
$cbExp = time() + 10 * 60;
$cbSigBase = ($sourceId !== '')
? ($folder . '|' . $file . '|' . $sourceId . '|' . $cbExp)
: ($folder . '|' . $file . '|' . $cbExp);
$cbSig = hash_hmac('sha256', $cbSigBase, $secret);
$callbackUrl = $fileOriginForDocs . '/api/onlyoffice/callback.php'
. '?folder=' . rawurlencode($folder)
. '&file=' . rawurlencode($file)
. '&exp=' . $cbExp
. '&sig=' . $cbSig;
if ($sourceId !== '') {
$callbackUrl .= '&sourceId=' . rawurlencode($sourceId);
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: 'docx');
$canSave = $canEdit && !in_array($ext, self::OO_NEVER_EDIT, true);
$callbackUrl = null;
if ($canSave) {
$cbExp = time() + 10 * 60;
$cbPayload = [
'f' => $folder,
'n' => $file,
'u' => $user,
'edit' => true,
'op' => 'onlyoffice_save',
'exp' => $cbExp,
];
if ($sourceId !== '') {
$cbPayload['sid'] = $sourceId;
}
$cbTok = $this->createSignedPayloadToken($cbPayload, $secret);
if ($cbTok === '') {
http_response_code(500);
echo '{"error":"Failed to generate ONLYOFFICE callback token"}';
return;
}
$callbackUrl = $fileOriginForDocs . '/api/onlyoffice/callback.php?tok=' . rawurlencode($cbTok);
}
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: 'docx');
$docType = in_array($ext, ['xls','xlsx','ods','csv'], true) ? 'cell'
: (in_array($ext, ['ppt','pptx','odp'], true) ? 'slide' : 'word');
$filePath = $downloadInfo['filePath'] ?? '';
@@ -467,18 +615,21 @@ class OnlyOfficeController
'permissions' => [
'download' => true,
'print' => true,
'edit' => $canEdit && !in_array($ext, self::OO_NEVER_EDIT, true),
'edit' => $canSave,
],
],
'documentType' => $docType,
'editorConfig' => [
'callbackUrl' => $callbackUrl,
'user' => ['id' => $user, 'name' => $user],
'lang' => 'en',
],
'type' => 'desktop',
];
if ($callbackUrl !== null) {
$cfgOut['editorConfig']['callbackUrl'] = $callbackUrl;
}
// JWT sign cfg
$h = $this->b64uEnc(json_encode(['alg' => 'HS256','typ' => 'JWT']));
$p = $this->b64uEnc(json_encode($cfgOut, JSON_UNESCAPED_SLASHES));
@@ -510,51 +661,33 @@ class OnlyOfficeController
return;
}
$folderRaw = (string)($_GET['folder'] ?? 'root');
$fileRaw = (string)($_GET['file'] ?? '');
$exp = (int)($_GET['exp'] ?? 0);
$sig = (string)($_GET['sig'] ?? '');
$sourceIdRaw = (string)($_GET['sourceId'] ?? ($_GET['sid'] ?? ''));
$sourceId = $this->normalizeSourceId($sourceIdRaw);
if ($sourceIdRaw !== '' && $sourceId === '') {
$this->ooLog('error', "invalid source id in callback");
$callbackToken = trim((string)($_GET['tok'] ?? ''));
$callbackPayload = $this->decodeSignedPayloadToken($callbackToken, $secret);
if (!is_array($callbackPayload)) {
$this->ooLog('error', 'invalid callback token');
echo '{"error":6}';
return;
}
$calcBase = ($sourceId !== '')
? "$folderRaw|$fileRaw|$sourceId|$exp"
: "$folderRaw|$fileRaw|$exp";
$calc = hash_hmac('sha256', $calcBase, $secret);
// Debug-only preflight (no secrets; show short sigs)
if ($this->ooDebug()) {
$this->ooLog('debug', sprintf(
"PRE f='%s' n='%s' sid='%s' exp=%d sig[8]=%s calc[8]=%s",
$folderRaw,
$fileRaw,
$sourceId,
$exp,
substr($sig, 0, 8),
substr($calc, 0, 8)
));
}
$folder = \ACL::normalizeFolder($folderRaw);
$file = basename($fileRaw);
if (!$exp || time() > $exp) {
$this->ooLog('error', "expired exp for $folder/$file");
$folder = \ACL::normalizeFolder((string)($callbackPayload['f'] ?? 'root'));
$file = basename((string)($callbackPayload['n'] ?? ''));
$actor = (string)($callbackPayload['u'] ?? '');
$allowEdit = !empty($callbackPayload['edit']);
$op = (string)($callbackPayload['op'] ?? '');
$exp = (int)($callbackPayload['exp'] ?? 0);
$sourceIdRaw = (string)($callbackPayload['sid'] ?? ($callbackPayload['sourceId'] ?? ''));
$sourceId = '';
if ($file === '' || $actor === '' || !$allowEdit || $op !== 'onlyoffice_save' || !$exp || time() > $exp) {
$this->ooLog('error', "expired or invalid callback token for $folder/$file");
echo '{"error":6}';
return;
}
if (!hash_equals($calc, $sig)) {
$this->ooLog('error', "sig mismatch for $folder/$file");
echo '{"error":6}';
return;
}
if ($sourceId !== '') {
[$sourceId, $sourceErr] = $this->resolveSourceId($sourceId, true);
if ($sourceIdRaw !== '') {
[$sourceId, $sourceErr] = $this->resolveSourceId($sourceIdRaw, true);
if ($sourceErr !== null) {
$this->ooLog('error', "invalid source for callback: $sourceId");
$this->ooLog('error', "invalid source for callback: $sourceIdRaw");
echo '{"error":6}';
return;
}
@@ -565,17 +698,25 @@ class OnlyOfficeController
$this->ooLog('debug', 'BODY len=' . strlen($raw));
}
$body = json_decode($raw, true) ?: [];
$status = (int)($body['status'] ?? 0);
$actor = (string)($body['actions'][0]['userid'] ?? '');
$rawBody = json_decode($raw, true);
$rawBody = is_array($rawBody) ? $rawBody : [];
$jwt = $this->callbackJwtTokenFromRequest($rawBody);
$body = $this->trustedCallbackBody($rawBody, $secret);
if (!is_array($body)) {
$this->ooLog('error', "missing or invalid callback JWT for $folder/$file");
echo '{"error":6}';
return;
}
if ($jwt === '') {
$this->ooLog('warn', "callback JWT missing; falling back to signed callback token only for $folder/$file");
}
$actorIsAdmin = (defined('DEFAULT_ADMIN_USER') && $actor !== '' && strcasecmp($actor, (string)DEFAULT_ADMIN_USER) === 0)
|| (strcasecmp($actor, 'admin') === 0);
$perms = $actorIsAdmin ? ['admin' => true] : [];
$status = (int)($body['status'] ?? 0);
$perms = $this->effectiveAclPermsForUser($actor);
// Save-on statuses: 2/6/7
if (in_array($status, [2,6,7], true)) {
if (!$actor || !\ACL::canEdit($actor, $perms, $folder)) {
if (!\ACL::canEdit($actor, $perms, $folder)) {
$this->ooLog('error', "ACL deny edit: actor='$actor' folder='$folder'");
echo '{"error":6}';
return;
@@ -586,6 +727,11 @@ class OnlyOfficeController
echo '{"error":6}';
return;
}
if (!$this->isAllowedOnlyOfficeUrl($saveUrl)) {
$this->ooLog('error', "disallowed save url for $folder/$file");
echo '{"error":6}';
return;
}
// fetch saved file
$data = null;
@@ -595,7 +741,7 @@ class OnlyOfficeController
$ch = curl_init($saveUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 45,
CURLOPT_HTTPHEADER => ['Accept: */*','User-Agent: FileRise-ONLYOFFICE-Callback'],
@@ -240,6 +240,22 @@ class UploadController
return;
}
if (empty($_SESSION['authenticated'])) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
return;
}
$username = (string)($_SESSION['username'] ?? '');
$userPerms = loadUserPermissions($username) ?: [];
$isAdmin = ACL::isAdmin($userPerms);
if (!$isAdmin && !empty($userPerms['disableUpload'])) {
http_response_code(403);
echo json_encode(['error' => 'Upload disabled for this user.']);
return;
}
$sourceId = trim((string)($_POST['sourceId'] ?? ''));
if ($sourceId !== '' && class_exists('SourceContext') && SourceContext::sourcesEnabled()) {
if (!preg_match('/^[A-Za-z0-9_-]{1,64}$/', $sourceId)) {
@@ -262,14 +278,23 @@ class UploadController
return;
}
$targetFolderRaw = isset($_POST['targetFolder']) ? (string)$_POST['targetFolder'] : 'root';
$targetFolder = ACL::normalizeFolder(rawurldecode($targetFolderRaw));
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
http_response_code(403);
echo json_encode([
'error' => 'Forbidden: no write access to folder "' . $targetFolder . '".',
]);
return;
}
if (!isset($_POST['folder'])) {
http_response_code(400);
echo json_encode(['error' => 'No folder specified']);
return;
}
$folderRaw = (string)$_POST['folder'];
$folder = ACL::normalizeFolder(rawurldecode($folderRaw));
$folder = rawurldecode((string)$_POST['folder']);
echo json_encode(UploadModel::removeChunks($folder));
}