luci-app-lxc: fix authenticated path traversal and ACL bypass (host root)
authorDirk Brenken <redacted>
Tue, 12 May 2026 19:28:44 +0000 (21:28 +0200)
committerPaul Donald <redacted>
Wed, 27 May 2026 11:21:12 +0000 (14:21 +0300)
* 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 <redacted>
applications/luci-app-lxc/root/usr/share/luci/menu.d/luci-app-lxc.json
applications/luci-app-lxc/ucode/controller/lxc.uc

index 0c1f9516c77a76825780adc8957649a48b9e8bc0..44c178b934fcd92075a006f43515880af500f1ce 100644 (file)
@@ -11,7 +11,6 @@
                        }
                }
        },
-
        "admin/services/lxccm/overview": {
                "title": "Overview",
                "order": 10,
                        "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
                }
        }
index 28d7748efa2748d0f4754f20c93efc3b6cbd2e03..1f4f041c7da60886c0e8b6696632e553ab86cc1b 100644 (file)
@@ -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');
        },
 
git clone https://git.99rst.org/PROJECT