Integrate Dokuwiki ACL for file endpoint

This commit is contained in:
2026-01-09 11:13:12 +01:00
parent 23a50ce4f6
commit c11d9bdb8c
14 changed files with 91 additions and 138 deletions

View File

@@ -562,10 +562,37 @@ class Output
*/
protected function itemWebUrl($item, $cachbuster = false)
{
if (str_ends_with($this->webdir, '=')) {
$url = $this->webdir . rawurlencode($item['local']);
$webdir = $this->webdir;
// When using the built-in file-serving endpoint, include the current page id
// so file.php can enforce DokuWiki ACLs for that page.
if (
is_string($webdir)
&& $webdir !== ''
&& strpos($webdir, 'lib/plugins/luxtools/file.php') !== false
&& strpos($webdir, 'id=') === false
) {
global $ID;
$pageId = isset($ID) ? (string)$ID : '';
if ($pageId !== '') {
if (function_exists('cleanID')) {
$pageId = (string)cleanID($pageId);
}
if ($pageId !== '') {
$encoded = rawurlencode($pageId);
if (strpos($webdir, '&file=') !== false) {
$webdir = str_replace('&file=', '&id=' . $encoded . '&file=', $webdir);
} elseif (strpos($webdir, '?file=') !== false) {
$webdir = str_replace('?file=', '?id=' . $encoded . '&file=', $webdir);
}
}
}
}
if (str_ends_with($webdir, '=')) {
$url = $webdir . rawurlencode($item['local']);
} else {
$url = $this->webdir . $item['local'];
$url = $webdir . $item['local'];
}
if ($cachbuster) {

View File

@@ -49,11 +49,6 @@ class Path
$alias = static::cleanPath($line);
$paths[$lastRoot]['alias'] = $alias;
$paths[$alias] = &$paths[$lastRoot]; // alias references the original
} elseif (str_starts_with($line, 'W>')) {
// this is a web path for the last read root
$line = trim(substr($line, 2));
if (!isset($paths[$lastRoot])) continue; // no last path, no web path
$paths[$lastRoot]['web'] = $line;
} else {
// this is a new path
$line = static::cleanPath($line);

View File

@@ -31,15 +31,11 @@ It also ships a small file-serving endpoint (`lib/plugins/luxtools/file.php`) us
to deliver files and generate cached thumbnails.
## Important security note
## Note on security
The file-serving endpoint (`lib/plugins/luxtools/file.php`) runs inside DokuWiki
and can enforce a simple access restriction based on the currently logged-in
user.
This is intentionally *not* full per-page ACL integration; it is a pragmatic
allowlist to avoid “anyone with a guessed URL can fetch the file”.
and enforces access via per-page ACL: the requester must have at least read
access to the wiki page that rendered the link.
## Installation
@@ -60,33 +56,25 @@ luxtools is configured via its dedicated admin page:
Key settings:
- **access_allow**
Allowed users/groups for the file-serving endpoint.
- Entries can be separated by newlines, commas, or whitespace.
- Use `@group` to allow a whole group.
- Leave empty to allow any authenticated (logged-in) user.
- Anonymous users are always denied.
- **paths**
Allowed base filesystem roots (one per line). Each root can be followed by:
- `A> /Alias/` (optional) alias used in wiki syntax and open links
- `W> https://...` (optional) web base URL used for links instead of `file.php`
Example:
```
/srv/share/Datascape/
A> /Scape/
W> https://files.example.example/Datascape/
```
If no `W>` line is configured, luxtools links will use the plugin endpoint:
luxtools links use the plugin endpoint:
`lib/plugins/luxtools/file.php?root=...&file=...`
Note: if you configure a `W>` web URL to an external file server, that server
must enforce access itself; DokuWiki ACLs and `access_allow` only apply to
`file.php`.
The generated URLs also include the current wiki page id (`id=...`) so
`file.php` can enforce ACLs for the host page.
- **scratchpad_paths**
Scratchpad file map (one file path per line, followed by an `A>` alias line).

View File

@@ -75,11 +75,6 @@ class ScratchpadMap
continue;
}
// Ignore W> lines for compatibility with the Path config style
if (str_starts_with($line, 'W>')) {
continue;
}
// Treat as file path (no trailing slash enforced)
$filePath = Path::cleanPath($line, false);
if ($filePath === '' || str_ends_with($filePath, '/')) {

View File

@@ -27,7 +27,6 @@ C:\\xampp\\htdocs\\wiki\\
/linux/file/path/
/linux/another/path/../..//another/blargh/../path
A> alias
W> webfoo
EOT
);
}
@@ -53,12 +52,12 @@ EOT
'/linux/another/path/' => [
'root' => '/linux/another/path/',
'alias' => 'alias/',
'web' => 'webfoo',
'web' => '/lib/plugins/luxtools/file.php?root=%2Flinux%2Fanother%2Fpath%2F&file=',
],
'alias/' => [
'root' => '/linux/another/path/',
'alias' => 'alias/',
'web' => 'webfoo',
'web' => '/lib/plugins/luxtools/file.php?root=%2Flinux%2Fanother%2Fpath%2F&file=',
],
];

View File

@@ -22,7 +22,6 @@ A> start
C:\\pads\\notes.md
A> notes
W> ignored
\\\\server\\share\\pads\\team.txt
A> team

View File

@@ -29,7 +29,8 @@ class plugin_luxtools_test extends DokuWikiTest
parent::setUp();
// Setup config so that access to the TMP directory will be allowed
$conf ['plugin']['luxtools']['paths'] = TMP_DIR . '/filelistdata/' . "\n" . 'A> /Scape' . "\n" . 'W> http://localhost/';
// Use the built-in file.php endpoint.
$conf ['plugin']['luxtools']['paths'] = TMP_DIR . '/filelistdata/' . "\n" . 'A> /Scape';
}
@@ -244,6 +245,27 @@ class plugin_luxtools_test extends DokuWikiTest
$this->assertStringContainsString('height="150"', $xhtml);
}
/**
* Ensure the built-in file endpoint includes the host page id so file.php can
* enforce per-page ACL.
*/
public function test_file_links_include_page_id_for_acl()
{
global $ID;
$ID = 'luxtools:acltest';
$instructions = p_get_instructions('{{files>' . TMP_DIR . '/filelistdata/*&style=list&direct=1}}');
$xhtml = p_render('xhtml', $instructions, $info);
$doc = new Document();
$doc->html($xhtml);
$href = (string)$doc->find('div.luxtools-plugin a')->first()->attr('href');
$this->assertNotSame('', $href);
$this->assertStringContainsString('lib/plugins/luxtools/file.php', $href);
$this->assertStringContainsString('id=luxtools%3Aacltest', $href);
}
/**
* This function checks that the open syntax renders an inline link.
*/

View File

@@ -10,7 +10,6 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
{
/** @var string[] */
protected $configKeys = [
'access_allow',
'paths',
'scratchpad_paths',
'allow_in_comments',
@@ -49,10 +48,6 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
}
$newConf = [];
$accessAllow = $INPUT->str('access_allow');
$accessAllow = str_replace(["\r\n", "\r"], "\n", $accessAllow);
$newConf['access_allow'] = $accessAllow;
// Normalize newlines to "\n" for consistent parsing
$paths = $INPUT->str('paths');
$paths = str_replace(["\r\n", "\r"], "\n", $paths);
@@ -93,12 +88,6 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
echo '<fieldset>';
echo '<legend>' . hsc($this->getLang('legend')) . '</legend>';
// access_allow: multiline (users/groups)
$accessAllow = (string)$this->getConf('access_allow');
echo '<label class="block"><span>' . hsc($this->getLang('access_allow')) . '</span><br />';
echo '<textarea name="access_allow" rows="3" cols="80" class="edit">' . hsc($accessAllow) . '</textarea>';
echo '</label><br />';
// paths: multiline textarea
$paths = (string)$this->getConf('paths');
echo '<label class="block"><span>' . hsc($this->getLang('paths')) . '</span><br />';

View File

@@ -19,8 +19,3 @@ $conf['gallery_thumb_scale'] = 1;
// Local client service used by {{open>...}}.
$conf['open_service_url'] = 'http://127.0.0.1:8765';
// Access allowlist for the file-serving endpoint (file.php).
// Empty means: any authenticated user may access. Anonymous users are denied.
// Use '@group' to allow a whole group.
$conf['access_allow'] = '';

107
file.php
View File

@@ -13,90 +13,35 @@ global $INPUT;
$syntax = plugin_load('syntax', 'luxtools');
if (!$syntax) die('plugin disabled?');
/**
* Enforce a simple allowlist based access check.
*
* The allowlist supports entries separated by commas, whitespace or newlines.
* - "alice" allows a specific user
* - "@admins" allows a DokuWiki group
*
* If the allowlist is empty, any authenticated user is allowed.
* Anonymous access is always denied.
*
* @param string $allowListRaw
* @return void
*/
function luxtools_require_access(string $allowListRaw): void
{
global $INPUT, $USERINFO;
// We need a logged-in user for any access.
$user = '';
try {
$user = (string)$INPUT->server->str('REMOTE_USER');
} catch (Throwable $e) {
$user = (string)($_SERVER['REMOTE_USER'] ?? '');
}
$user = trim($user);
if ($user === '') {
header('Content-Type: text/plain; charset=utf-8');
http_status(403);
echo 'forbidden';
exit;
}
$allowListRaw = trim($allowListRaw);
if ($allowListRaw === '') {
return; // any authenticated user
}
$tokens = preg_split('/[\s,;]+/', $allowListRaw, -1, PREG_SPLIT_NO_EMPTY);
if (!is_array($tokens) || $tokens === []) {
// If configured but empty after parsing, be conservative.
header('Content-Type: text/plain; charset=utf-8');
http_status(403);
echo 'forbidden';
exit;
}
$groups = [];
if (is_array($USERINFO) && isset($USERINFO['grps']) && is_array($USERINFO['grps'])) {
$groups = $USERINFO['grps'];
}
$allowed = false;
foreach ($tokens as $t) {
$t = trim((string)$t);
if ($t === '') continue;
if ($t[0] === '@') {
$g = trim(substr($t, 1));
if ($g !== '' && in_array($g, $groups, true)) {
$allowed = true;
break;
}
} else {
if ($t === $user) {
$allowed = true;
break;
}
}
}
if (!$allowed) {
header('Content-Type: text/plain; charset=utf-8');
http_status(403);
echo 'forbidden';
exit;
}
}
$pathUtil = new Path($syntax->getConf('paths'));
$path = $INPUT->str('root') . $INPUT->str('file');
// Enforce access before doing any filesystem work.
luxtools_require_access((string)$syntax->getConf('access_allow'));
// Require the user to be able to read the page that rendered the link.
$pageId = (string)$INPUT->str('id');
if (function_exists('cleanID')) {
$pageId = (string)cleanID($pageId);
}
if ($pageId === '') {
header('Content-Type: text/plain; charset=utf-8');
http_status(403);
echo 'forbidden';
exit;
}
if (!function_exists('auth_quickaclcheck') || !defined('AUTH_READ')) {
header('Content-Type: text/plain; charset=utf-8');
http_status(403);
echo 'forbidden';
exit;
}
if (auth_quickaclcheck($pageId) < AUTH_READ) {
header('Content-Type: text/plain; charset=utf-8');
http_status(403);
echo 'forbidden';
exit;
}
/**
* Send a file to the client with basic caching headers.

Binary file not shown.

Binary file not shown.

View File

@@ -25,9 +25,8 @@ $lang['saved'] = 'Settings saved.';
$lang['err_save'] = 'Could not save settings. Please check write permissions for conf/local.php.';
$lang['err_security'] = 'Security token mismatch. Please retry.';
$lang['access_allow'] = 'Allowed users/groups for file access (one per line or comma-separated). Use @group for groups. Leave empty to allow any logged-in user.';
$lang['paths'] = 'Allowed base paths (one per line). Optional: follow a path with A> alias and W> web URL.';
$lang['paths'] = 'Allowed base paths (one per line). Optional: follow a path with A> alias.';
$lang['scratchpad_paths'] = 'Scratchpad files (one per line). Each file path must include the extension. Use a following A> line to set the pad name used in the wiki.';
$lang['allow_in_comments'] = 'Whether to allow luxtools syntax (files/images/directory/scratchpad) to be used in comments.';
$lang['defaults'] = 'Default options. Use the same syntax as in inline configuration.';

View File

@@ -1,6 +1,6 @@
<?php
$lang['paths'] = 'Allowed base paths (one per line). Optional: follow a path with A> alias and W> web URL.';
$lang['paths'] = 'Allowed base paths (one per line). Optional: follow a path with A> alias.';
$lang['scratchpad_paths'] = 'Scratchpad files (one per line). Each file path must include the extension; use a following A> line to set the pad name used in the wiki.';
$lang['allow_in_comments'] = 'Whether to allow luxtools syntax (files/images/directory/scratchpad) to be used in comments.';
$lang['defaults'] = 'Default options. Use the same syntax as in inline configuration';