Skip to content

Commit 8c28541

Browse files
authored
release(v3.8.0): share-link admin guards and centralized safe-upload policy
- shares(security): require authenticated admin + CSRF for file share link listing and deletion - uploads(policy): add centralized safe-upload policy with strict default and code-friendly admin override - webdav(policy): enforce the shared write-name policy for WebDAV file and folder creation paths - admin(ui): expose safe upload policy in Admin Panel and persist the normalized config value - admin(fix): guard partial config updates that omit oidc payloads
1 parent 7984aa3 commit 8c28541

14 files changed

Lines changed: 340 additions & 63 deletions

CHANGELOG.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,56 @@
11
# Changelog
22

3+
## Changes 03/12/2026 (v3.8.0)
4+
5+
`release(v3.8.0): share-link admin guards and centralized safe-upload policy`
6+
7+
**Commit message**
8+
9+
```text
10+
release(v3.8.0): share-link admin guards and centralized safe-upload policy
11+
12+
- shares(security): require authenticated admin + CSRF for file share link listing and deletion
13+
- uploads(policy): add centralized safe-upload policy with strict default and code-friendly admin override
14+
- webdav(policy): enforce the shared write-name policy for WebDAV file and folder creation paths
15+
- admin(ui): expose safe upload policy in Admin Panel and persist the normalized config value
16+
- admin(fix): guard partial config updates that omit oidc payloads
17+
```
18+
19+
**Added**
20+
21+
- **Centralized safe-upload policy**
22+
- Added `src/FileRise/Support/UploadNamePolicy.php` to centralize write-path filename policy decisions.
23+
- Added admin-configurable policy modes:
24+
- `strict` (default)
25+
- `code_friendly`
26+
27+
**Changed**
28+
29+
- **File share admin endpoints**
30+
- `getShareLinks.php` now requires an authenticated admin session.
31+
- `deleteShareLink.php` now requires an authenticated admin session and a valid CSRF token.
32+
- Updated the generated OpenAPI spec to reflect the authenticated share-link route behavior.
33+
- **Write-path filename enforcement**
34+
- Normal uploads, file create/save flows, selected folder write paths, and WebDAV now use the shared write-name policy instead of relying only on the generic filename regex.
35+
- Added an Admin Panel control under upload settings so operators can switch between `strict` and `code_friendly` behavior.
36+
37+
**Fixed**
38+
39+
- **Partial admin config saves**
40+
- Fixed admin config updates failing when the submitted payload omits the `oidc` object during narrower settings changes.
41+
- **WebDAV folder-name validation**
42+
- WebDAV folder creation now rejects invalid path-like names such as empty names, `.` / `..`, and names containing path separators.
43+
44+
**Security**
45+
46+
- **Safe-upload defaults**
47+
- New write operations default to `strict` mode.
48+
- `.htaccess`, `.user.ini`, and `web.config` remain blocked in all policy modes.
49+
- **Share-link guard consistency**
50+
- File share-link listing and deletion now use the same authenticated admin expectations as the rest of the admin share management surface.
51+
52+
---
53+
354
## Changes 03/08/2026 (v3.7.0)
455

556
**Demo videos**

openapi.json.dist

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1914,7 +1914,7 @@
19141914
"Shares"
19151915
],
19161916
"summary": "Delete a share link by token",
1917-
"description": "Deletes a share token. NOTE: Current implementation does not require authentication.",
1917+
"description": "Deletes a share token. Requires an authenticated admin session and a valid CSRF token.",
19181918
"operationId": "deleteShareLink",
19191919
"requestBody": {
19201920
"required": true,
@@ -1938,8 +1938,19 @@
19381938
"responses": {
19391939
"200": {
19401940
"description": "Deletion result (success or not found)"
1941+
},
1942+
"401": {
1943+
"description": "Unauthorized"
1944+
},
1945+
"403": {
1946+
"description": "Forbidden"
19411947
}
1942-
}
1948+
},
1949+
"security": [
1950+
{
1951+
"cookieAuth": []
1952+
}
1953+
]
19431954
}
19441955
},
19451956
"/api/file/deleteTrashFiles.php": {
@@ -2386,13 +2397,24 @@
23862397
"Shares"
23872398
],
23882399
"summary": "Get (raw) share links file",
2389-
"description": "Returns the full share links JSON (no auth in current implementation).",
2400+
"description": "Returns the full share links JSON. Requires an authenticated admin session.",
23902401
"operationId": "getShareLinks",
23912402
"responses": {
23922403
"200": {
23932404
"description": "Share links (model-defined JSON)"
2405+
},
2406+
"401": {
2407+
"description": "Unauthorized"
2408+
},
2409+
"403": {
2410+
"description": "Forbidden"
23942411
}
2395-
}
2412+
},
2413+
"security": [
2414+
{
2415+
"cookieAuth": []
2416+
}
2417+
]
23962418
}
23972419
},
23982420
"/api/file/getTrashItems.php": {
@@ -7160,4 +7182,4 @@
71607182
"description": "Uploads"
71617183
}
71627184
]
7163-
}
7185+
}

public/api/file/deleteShareLink.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* @OA\Post(
66
* path="/api/file/deleteShareLink.php",
77
* summary="Delete a share link by token",
8-
* description="Deletes a share token. NOTE: Current implementation does not require authentication.",
8+
* description="Deletes a share token. Requires an authenticated admin session and a valid CSRF token.",
99
* operationId="deleteShareLink",
1010
* tags={"Shares"},
1111
* @OA\RequestBody(
@@ -18,7 +18,9 @@
1818
* )
1919
* )
2020
* ),
21-
* @OA\Response(response=200, description="Deletion result (success or not found)")
21+
* @OA\Response(response=200, description="Deletion result (success or not found)"),
22+
* @OA\Response(response=401, description="Unauthorized"),
23+
* @OA\Response(response=403, description="Forbidden")
2224
* )
2325
*/
2426

public/api/file/getShareLinks.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@
44
* @OA\Get(
55
* path="/api/file/getShareLinks.php",
66
* summary="Get (raw) share links file",
7-
* description="Returns the full share links JSON (no auth in current implementation).",
7+
* description="Returns the full share links JSON. Requires an authenticated admin session.",
88
* operationId="getShareLinks",
99
* tags={"Shares"},
10-
* @OA\Response(response=200, description="Share links (model-defined JSON)")
10+
* @OA\Response(response=200, description="Share links (model-defined JSON)"),
11+
* @OA\Response(response=401, description="Unauthorized"),
12+
* @OA\Response(response=403, description="Forbidden")
1113
* )
1214
*/
1315

1416

1517
require_once __DIR__ . '/../../../config/config.php';
1618

1719
$fileController = new \FileRise\Http\Controllers\FileController();
18-
$fileController->getShareLinks();
20+
$fileController->getShareLinks();

public/js/adminPanel.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5221,6 +5221,7 @@ function captureInitialAdminConfig() {
52215221
authBypass: !!document.getElementById("authBypass")?.checked,
52225222

52235223
enableWebDAV: !!document.getElementById("enableWebDAV")?.checked,
5224+
safeUploadPolicy: (document.getElementById("safeUploadPolicy")?.value || "strict").trim(),
52245225
sharedMaxUploadSize: (document.getElementById("sharedMaxUploadSize")?.value || "").trim(),
52255226
resumableChunkMb: (document.getElementById("resumableChunkMb")?.value || "").trim(),
52265227
resumableTtlHours: (document.getElementById("resumableTtlHours")?.value || "").trim(),
@@ -5286,6 +5287,7 @@ function hasUnsavedChanges() {
52865287
getChk("authBypass") !== o.authBypass ||
52875288

52885289
getChk("enableWebDAV") !== o.enableWebDAV ||
5290+
getVal("safeUploadPolicy") !== (o.safeUploadPolicy || "strict") ||
52895291
getVal("sharedMaxUploadSize") !== o.sharedMaxUploadSize ||
52905292
getVal("resumableChunkMb") !== o.resumableChunkMb ||
52915293
getVal("resumableTtlHours") !== o.resumableTtlHours ||
@@ -7723,6 +7725,22 @@ export function openAdminPanel() {
77237725
${tf("upload_settings", "Upload settings")}
77247726
</div>
77257727
7728+
<div class="form-group" style="margin-top:10px;">
7729+
<label for="safeUploadPolicy">
7730+
${tf("safe_upload_policy_label", "Safe upload policy")}
7731+
</label>
7732+
<select id="safeUploadPolicy" class="form-control">
7733+
<option value="strict">${tf("safe_upload_policy_strict", "Strict (recommended)")}</option>
7734+
<option value="code_friendly">${tf("safe_upload_policy_code_friendly", "Code-friendly")}</option>
7735+
</select>
7736+
<small class="text-muted d-block mt-1">
7737+
${tf(
7738+
"safe_upload_policy_help",
7739+
"Strict blocks executable and script-style filenames on new writes. Code-friendly allows them for editor workflows, but .htaccess, .user.ini, and web.config are always blocked."
7740+
)}
7741+
</small>
7742+
</div>
7743+
77267744
<div class="form-group" style="margin-top:10px;">
77277745
<label for="resumableChunkMb">
77287746
${tf("resumable_chunk_size_label", "Resumable chunk size (MB)")}
@@ -9214,6 +9232,7 @@ ${t("shared_max_upload_size_bytes")}
92149232
}
92159233

92169234
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
9235+
document.getElementById("safeUploadPolicy").value = config.safeUploadPolicy || "strict";
92179236
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
92189237
const uploadCfg = (config.uploads && typeof config.uploads === "object") ? config.uploads : {};
92199238
const chunkEl = document.getElementById("resumableChunkMb");
@@ -9320,6 +9339,7 @@ ${t("shared_max_upload_size_bytes")}
93209339
}
93219340

93229341
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
9342+
document.getElementById("safeUploadPolicy").value = config.safeUploadPolicy || "strict";
93239343
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
93249344
const uploadCfg2 = (config.uploads && typeof config.uploads === "object") ? config.uploads : {};
93259345
const chunkEl2 = document.getElementById("resumableChunkMb");
@@ -9582,6 +9602,7 @@ function handleSave() {
95829602
authHeaderName,
95839603
},
95849604
enableWebDAV: !!document.getElementById("enableWebDAV")?.checked,
9605+
safeUploadPolicy: (document.getElementById("safeUploadPolicy")?.value || "strict").trim(),
95859606
sharedMaxUploadSize: parseInt(
95869607
document.getElementById("sharedMaxUploadSize").value || "0",
95879608
10

src/FileRise/Domain/AdminModel.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace FileRise\Domain;
44

55
use FileRise\Http\Controllers\AdminController;
6+
use FileRise\Support\UploadNamePolicy;
67
use FileRise\Storage\SourcesConfig;
78
use ProAudit;
89

@@ -152,6 +153,7 @@ public static function buildPublicSubset(array $config): array
152153
],
153154
'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '',
154155
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
156+
'safeUploadPolicy' => UploadNamePolicy::normalizeMode($config['safeUploadPolicy'] ?? UploadNamePolicy::MODE_STRICT),
155157
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
156158
'oidc' => [
157159
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
@@ -440,6 +442,9 @@ public static function updateConfig(array $configUpdate): array
440442
$configUpdate['enableWebDAV'] = isset($configUpdate['enableWebDAV'])
441443
? (bool)$configUpdate['enableWebDAV']
442444
: false;
445+
$configUpdate['safeUploadPolicy'] = UploadNamePolicy::normalizeMode(
446+
$configUpdate['safeUploadPolicy'] ?? UploadNamePolicy::MODE_STRICT
447+
);
443448

444449
// Validate sharedMaxUploadSize if provided
445450
if (array_key_exists('sharedMaxUploadSize', $configUpdate)) {
@@ -902,6 +907,9 @@ public static function getConfig(): array
902907
if (!isset($config['enableWebDAV'])) {
903908
$config['enableWebDAV'] = false;
904909
}
910+
$config['safeUploadPolicy'] = UploadNamePolicy::normalizeMode(
911+
$config['safeUploadPolicy'] ?? UploadNamePolicy::MODE_STRICT
912+
);
905913

906914
// sharedMaxUploadSize: default if missing; clamp if present
907915
$maxBytes = self::parseSize(TOTAL_UPLOAD_SIZE);

src/FileRise/Domain/FileModel.php

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use FileRise\Support\ACL;
66
use FileRise\Support\CryptoAtRest;
77
use FileRise\Support\FS;
8+
use FileRise\Support\UploadNamePolicy;
89
use FileRise\Storage\StorageAdapterInterface;
910
use FileRise\Storage\SourceContext;
1011
use FileRise\Storage\StorageRegistry;
@@ -1165,7 +1166,7 @@ public static function renameFile($folder, $oldName, $newName)
11651166
$newName = basename(trim($newName));
11661167

11671168
// Validate file names using REGEX_FILE_NAME.
1168-
if (!preg_match(REGEX_FILE_NAME, $oldName) || !preg_match(REGEX_FILE_NAME, $newName)) {
1169+
if (!preg_match(REGEX_FILE_NAME, $oldName) || !UploadNamePolicy::isAllowedForWrite($newName)) {
11691170
return ["error" => "Invalid file name."];
11701171
}
11711172

@@ -1220,7 +1221,7 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
12201221
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
12211222
return ["error" => "Invalid folder name"];
12221223
}
1223-
if (!preg_match(REGEX_FILE_NAME, $fileName)) {
1224+
if (!UploadNamePolicy::isAllowedForWrite($fileName)) {
12241225
return ["error" => "Invalid file name"];
12251226
}
12261227

@@ -1996,7 +1997,7 @@ public static function extractZipArchive($folder, $files)
19961997
return '';
19971998
};
19981999

1999-
$stampExtractedFiles = function (array $allowedFiles) use ($folderPathReal, $folderNorm, $safeFileNamePattern, $stampMeta, &$extractedFiles): int {
2000+
$stampExtractedFiles = function (array $allowedFiles) use ($folderPathReal, $folderNorm, $stampMeta, &$extractedFiles): int {
20002001
$found = 0;
20012002
foreach ($allowedFiles as $entryName) {
20022003
// Normalize entry path for filesystem checks
@@ -2009,7 +2010,7 @@ public static function extractZipArchive($folder, $files)
20092010
}
20102011

20112012
$basename = basename($entryFsRel);
2012-
if ($basename === '' || !preg_match($safeFileNamePattern, $basename)) {
2013+
if (!UploadNamePolicy::isAllowedForWrite($basename)) {
20132014
continue;
20142015
}
20152016

@@ -3954,7 +3955,7 @@ public static function createFile(string $folder, string $filename, string $uplo
39543955
{
39553956
// 1) basic validation
39563957
$filename = basename(trim($filename));
3957-
if (!preg_match(REGEX_FILE_NAME, $filename)) {
3958+
if (!UploadNamePolicy::isAllowedForWrite($filename)) {
39583959
return ['success' => false, 'error' => 'Invalid filename', 'code' => 400];
39593960
}
39603961

src/FileRise/Domain/FolderModel.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use FileRise\Support\ACL;
66
use FileRise\Support\CryptoAtRest;
77
use FileRise\Support\FS;
8+
use FileRise\Support\UploadNamePolicy;
89
use FileRise\Storage\StorageAdapterInterface;
910
use FileRise\Storage\SourceContext;
1011
use FileRise\Storage\StorageRegistry;
@@ -3041,6 +3042,9 @@ public static function uploadToSharedFolder(string $token, array $fileUpload, st
30413042
// New safe filename
30423043
$safeBase = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName);
30433044
$newFilename = uniqid('', true) . "_" . $safeBase;
3045+
if (!UploadNamePolicy::isAllowedForWrite($newFilename)) {
3046+
return ["error" => "Invalid file name."];
3047+
}
30443048
$targetPath = $targetDir . DIRECTORY_SEPARATOR . $newFilename;
30453049

30463050
if ($storage->isLocal()) {

src/FileRise/Domain/UploadModel.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use FileRise\Support\AuditHook;
77
use FileRise\Support\CryptoAtRest;
88
use FileRise\Support\EventBus;
9+
use FileRise\Support\UploadNamePolicy;
910
use FileRise\Storage\StorageAdapterInterface;
1011
use FileRise\Storage\SourceContext;
1112
use FileRise\Storage\StorageRegistry;
@@ -427,7 +428,7 @@ private static function parseRelativePath(string $raw): array
427428
}
428429

429430
$file = basename($raw);
430-
if ($file === '' || !preg_match(REGEX_FILE_NAME, $file)) {
431+
if (!UploadNamePolicy::isAllowedForWrite($file)) {
431432
return [null, null];
432433
}
433434

@@ -733,7 +734,7 @@ public static function handleUpload(array $post, array $files): array
733734
$relativeSubDir = $subDir;
734735
}
735736

736-
if (!preg_match(REGEX_FILE_NAME, $resumableFilename)) {
737+
if (!UploadNamePolicy::isAllowedForWrite($resumableFilename)) {
737738
return ['error' => "Invalid file name: $resumableFilename"];
738739
}
739740

@@ -938,7 +939,6 @@ public static function handleUpload(array $post, array $files): array
938939
return ['error' => 'Failed to create upload directory'];
939940
}
940941

941-
$safeFileNamePattern = REGEX_FILE_NAME;
942942
$metadataCollection = [];
943943
$metadataChanged = [];
944944

@@ -981,7 +981,7 @@ public static function handleUpload(array $post, array $files): array
981981
. str_replace('/', DIRECTORY_SEPARATOR, $relativeSubDir) . DIRECTORY_SEPARATOR;
982982
}
983983
}
984-
if (!preg_match($safeFileNamePattern, $safeFileName)) {
984+
if (!UploadNamePolicy::isAllowedForWrite($safeFileName)) {
985985
return ['error' => 'Invalid file name: ' . $fileName];
986986
}
987987

0 commit comments

Comments
 (0)