From fdc8eb01d0ee2d1c62cdecae80908f3d495e3469 Mon Sep 17 00:00:00 2001 From: Dirk Brenken Date: Tue, 12 May 2026 21:28:44 +0200 Subject: [PATCH] luci-app-lxc: fix authenticated path traversal and ACL bypass (host root) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ucode fixes: - tighten `is_valid_lxc_name` regex to `^[A-Za-z0-9_][A-Za-z0-9_-]{0,63}$` - apply the validator in `lxc_configuration_get` and `lxc_configuration_set` before any filesystem access - reject the `'lxc error: …'` sentinel string returned by `lxc_get_config_path()` on failure, rather than concatenating it into a path. - shellquote `LXC_URL` in `lxc_get_downloadable` and `lxc_create` * ACL fix: add `depends.acl = ["luci-app-lxc"]` to each of the five backend entries, so the routes share the same authorization gate as the view Signed-off-by: Dirk Brenken --- .../usr/share/luci/menu.d/luci-app-lxc.json | 60 +++++++++++++++---- .../luci-app-lxc/ucode/controller/lxc.uc | 46 +++++++++++--- 2 files changed, 86 insertions(+), 20 deletions(-) diff --git a/applications/luci-app-lxc/root/usr/share/luci/menu.d/luci-app-lxc.json b/applications/luci-app-lxc/root/usr/share/luci/menu.d/luci-app-lxc.json index 0c1f9516c7..44c178b934 100644 --- a/applications/luci-app-lxc/root/usr/share/luci/menu.d/luci-app-lxc.json +++ b/applications/luci-app-lxc/root/usr/share/luci/menu.d/luci-app-lxc.json @@ -11,7 +11,6 @@ } } }, - "admin/services/lxccm/overview": { "title": "Overview", "order": 10, @@ -20,66 +19,103 @@ "path": "lxc/overview" }, "depends": { - "acl": [ "luci-app-lxc" ] + "acl": [ + "luci-app-lxc" + ] } }, - "admin/services/lxc/lxc_create/*": { "action": { "type": "function", "module": "luci.controller.lxc", "function": "lxc_create" }, + "depends": { + "acl": [ + "luci-app-lxc" + ] + }, "auth": { - "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ], + "methods": [ + "cookie:sysauth_https", + "cookie:sysauth_http" + ], "login": true } }, - "admin/services/lxc/lxc_action/*": { "action": { "type": "function", "module": "luci.controller.lxc", "function": "lxc_action" }, + "depends": { + "acl": [ + "luci-app-lxc" + ] + }, "auth": { - "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ], + "methods": [ + "cookie:sysauth_https", + "cookie:sysauth_http" + ], "login": true } }, - "admin/services/lxc/lxc_get_downloadable/*": { "action": { "type": "function", "module": "luci.controller.lxc", "function": "lxc_get_downloadable" }, + "depends": { + "acl": [ + "luci-app-lxc" + ] + }, "auth": { - "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ], + "methods": [ + "cookie:sysauth_https", + "cookie:sysauth_http" + ], "login": true } }, - "admin/services/lxc/lxc_configuration_get/*": { "action": { "type": "function", "module": "luci.controller.lxc", "function": "lxc_configuration_get" }, + "depends": { + "acl": [ + "luci-app-lxc" + ] + }, "auth": { - "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ], + "methods": [ + "cookie:sysauth_https", + "cookie:sysauth_http" + ], "login": true } }, - "admin/services/lxc/lxc_configuration_set/*": { "action": { "type": "function", "module": "luci.controller.lxc", "function": "lxc_configuration_set" }, + "depends": { + "acl": [ + "luci-app-lxc" + ] + }, "auth": { - "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ], + "methods": [ + "cookie:sysauth_https", + "cookie:sysauth_http" + ], "login": true } } diff --git a/applications/luci-app-lxc/ucode/controller/lxc.uc b/applications/luci-app-lxc/ucode/controller/lxc.uc index 28d7748efa..1f4f041c7d 100644 --- a/applications/luci-app-lxc/ucode/controller/lxc.uc +++ b/applications/luci-app-lxc/ucode/controller/lxc.uc @@ -18,7 +18,9 @@ function shellquote(value) { } function is_valid_lxc_name(value) { - return type(value) == 'string' && match(value, /^[A-Za-z0-9._-]{1,64}$/) != null; + // LXC container names: start with alphanumeric or underscore, followed by + // alphanumeric, underscore, dash. No periods, no slashes, no leading dash + return type(value) == 'string' && match(value, /^[A-Za-z0-9_][A-Za-z0-9_-]{0,63}$/) != null; } function is_valid_lxc_template(value) { @@ -49,7 +51,7 @@ const LXCController = { lxc_get_downloadable: function() { let target = this.lxc_get_arch_target(LXC_URL); let templates = []; - let content = fs.popen(`sh /usr/share/lxc/templates/lxc-download --list --server ${LXC_URL} 2>/dev/null`, 'r').read('all'); + let content = fs.popen(`sh /usr/share/lxc/templates/lxc-download --list --server ${shellquote(LXC_URL)} 2>/dev/null`, 'r').read('all'); content = split(content, '\n'); for (let line in content) { let arr = match(line, /^(\S+)\s+(\S+)\s+(\S+)\s+default\s+(\S+)\s*$/); @@ -80,7 +82,7 @@ const LXCController = { let arr = match(lxc_template, /^(.+):(.+)$/); let lxc_dist = arr[1], lxc_release = arr[2]; - system(`/usr/bin/lxc-create --quiet --name ${shellquote(lxc_name)} --bdev best --template download -- --dist ${shellquote(lxc_dist)} --release ${shellquote(lxc_release)} --arch ${this.lxc_get_arch_target(LXC_URL)} --server ${LXC_URL}`); + system(`/usr/bin/lxc-create --quiet --name ${shellquote(lxc_name)} --bdev best --template download -- --dist ${shellquote(lxc_dist)} --release ${shellquote(lxc_release)} --arch ${this.lxc_get_arch_target(LXC_URL)} --server ${shellquote(LXC_URL)}`); while (fs.access(path + lxc_name + '/partial')) { sleep(1000); @@ -123,23 +125,51 @@ const LXCController = { }, lxc_configuration_get: function(lxc_name) { - let content = fs.readfile(this.lxc_get_config_path() + lxc_name + '/config'); - http.prepare_content('text/plain'); + + // Re-validate before fs.readfile as lxc_name, + // escapes the lxcpath base and reaches arbitrary files + if (!is_valid_lxc_name(lxc_name)) { + http.status(400, 'Bad Request'); + return; + } + + // lxc_get_config_path() returns an 'lxc error: …' string on failure + // rather than null. Refuse to proceed instead of concatenating that + // into a real filesystem path + let base = this.lxc_get_config_path(); + if (!base || index(base, 'lxc error') == 0) { + http.status(500, base); + return; + } + let content = fs.readfile(base + lxc_name + '/config'); http.write(content); }, lxc_configuration_set: function(lxc_name) { http.prepare_content('text/plain'); + // Re-validate before fs.writefile as lxc_name, + // escapes the lxcpath + if (!is_valid_lxc_name(lxc_name)) { + http.status(400, 'Bad Request'); + return; + } + + // lxc_get_config_path() returns an 'lxc error: …' string on failure + // rather than null. Refuse to proceed instead of concatenating that + // into a real filesystem path + let base = this.lxc_get_config_path(); + if (!base || index(base, 'lxc error') == 0) { + http.status(500, base); + return; + } let lxc_configuration = http.formvalue('lxc_conf'); lxc_configuration = http.urldecode(lxc_configuration, true); if (!lxc_configuration) { return 'lxc error: config formvalue is empty'; } - - fs.writefile(this.lxc_get_config_path() + lxc_name + '/config', lxc_configuration); - + fs.writefile(base + lxc_name + '/config', lxc_configuration); http.write('0'); },