mirror of
https://github.com/error311/FileRise.git
synced 2026-05-12 15:00:36 -05:00
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:
@@ -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`
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user