public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Wolfgang Bumiller <w.bumiller@proxmox.com>
To: Dominik Csapak <d.csapak@proxmox.com>
Cc: pdm-devel@lists.proxmox.com
Subject: Re: [PATCH proxmox v2 1/2] router: compile time check privilege path parameters for existence
Date: Fri, 11 Sep 2026 12:02:45 +0200	[thread overview]
Message-ID: <fj6ftxsxzp7jnu3zs4lh7t5ptak4s2kc6ve6smgiogww6djkrl@24g6e6ahcvpo> (raw)
In-Reply-To: <20260831070855.568455-2-d.csapak@proxmox.com>

On Mon, Aug 31, 2026 at 09:08:47AM +0200, Dominik Csapak wrote:
> In an API privilege, path components can be interpolated from
> parameters, e.g. 'foo/{bar}'. If this parameter does not exist,
> the api privilege check fails and the client gets a 403 error back.
> 
> Instead of only checking this at runtime, check the existence of the
> parameter in the schema during compilation since the schemas need to
> be const anyway. In case of 'additional_properties', we can only
> check it during runtime, but currently there are no such cases for the
> API where a dynamic parameter would be used in the privilege path.
> (And it does not make sense to not statically define such a parameter)
> 
> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
> ---
>  proxmox-router/src/router.rs | 209 ++++++++++++++++++++++++++++++++++-
>  1 file changed, 208 insertions(+), 1 deletion(-)
> 
> diff --git a/proxmox-router/src/router.rs b/proxmox-router/src/router.rs
> index fea47ba6..ca48dcba 100644
> --- a/proxmox-router/src/router.rs
> +++ b/proxmox-router/src/router.rs
> @@ -16,7 +16,7 @@ use proxmox_http::Body;
>  use serde::Serialize;
>  use serde_json::Value;
>  
> -use proxmox_schema::{ObjectSchema, ParameterSchema, ReturnType, Schema};
> +use proxmox_schema::{AllOfSchema, ObjectSchema, OneOfSchema, ParameterSchema, ReturnType, Schema};
>  
>  use super::Permission;
>  use crate::RpcEnvironment;
> @@ -831,6 +831,131 @@ impl std::fmt::Debug for ApiMethod {
>      }
>  }
>  
> +// const helpers to check privilege parameters
> +
> +const fn byte_slice_eq(a: &[u8], b: &[u8]) -> bool {

We should consider adding a helper crate for such things.
We already have a byte slice compare function in `proxmox-schema`.

> +    if a.len() != b.len() {
> +        return false;
> +    }
> +    let mut i = 0;
> +    while i < a.len() {
> +        if a[i] != b[i] {
> +            return false;
> +        }
> +        i += 1;
> +    }
> +    true
> +}
> +
> +const fn object_schema_has_parameter(object: &ObjectSchema, name: &[u8]) -> bool {
> +    // additional properties are not statically known, so any name could exist
> +    if object.additional_properties {
> +        return true;
> +    }
> +    let mut i = 0;
> +    while i < object.properties.len() {
> +        if byte_slice_eq(object.properties[i].0.as_bytes(), name) {
> +            return true;
> +        }
> +        i += 1;
> +    }
> +    false
> +}
> +
> +const fn all_of_schema_has_parameter(all_of: &AllOfSchema, name: &[u8]) -> bool {
> +    let mut i = 0;
> +    while i < all_of.list.len() {
> +        if schema_has_parameter(all_of.list[i], name) {
> +            return true;
> +        }
> +        i += 1;
> +    }
> +    false
> +}
> +
> +const fn one_of_schema_has_parameter(one_of: &OneOfSchema, name: &[u8]) -> bool {
> +    if byte_slice_eq(one_of.type_property_entry.0.as_bytes(), name) {
> +        return true;
> +    }
> +    let mut i = 0;
> +    while i < one_of.list.len() {
> +        if schema_has_parameter(one_of.list[i].1, name) {
> +            return true;
> +        }
> +        i += 1;
> +    }
> +    false
> +}
> +
> +const fn schema_has_parameter(schema: &Schema, name: &[u8]) -> bool {
> +    match schema {
> +        Schema::Object(object) => object_schema_has_parameter(object, name),
> +        Schema::AllOf(all_of) => all_of_schema_has_parameter(all_of, name),
> +        Schema::OneOf(one_of) => one_of_schema_has_parameter(one_of, name),
> +        _ => false,
> +    }
> +}
> +
> +const fn parameter_exists(parameters: ParameterSchema, name: &[u8]) -> bool {
> +    match parameters {
> +        ParameterSchema::Object(object) => object_schema_has_parameter(object, name),
> +        ParameterSchema::AllOf(all_of) => all_of_schema_has_parameter(all_of, name),
> +        ParameterSchema::OneOf(one_of) => one_of_schema_has_parameter(one_of, name),
> +    }
> +}
> +
> +// mirrors the splitting done by check_api_permission: a component can contain multiple '/'
> +// separated parts, each of which may be a '{name}' parameter reference
> +const fn check_privilege_path_components(component: &str, parameters: ParameterSchema) {
> +    let bytes = component.as_bytes();

We should immediately return if `bytes.len() < 3`, see below ↓

> +    let mut component_start = 0;
> +    let mut pos = 0;
> +    while pos <= bytes.len() {
> +        if pos == bytes.len() || bytes[pos] == b'/' {
> +            let component_len = pos - component_start;
> +            let component_end = pos - 1;

Technically the above can underflow on an empty string where it would be
`0 - 1`.
Not an actual issue, but since we only care about components which are
names enclosed in braces, we could skip this entire function if the
string is not at least 3 characters, because an empty `{}` also does not
make sense.
So we could also add a more informative error message for
`if component_len == 2`, and turn the `>= 2` below into a `> 2`.

> +            if component_len >= 2 && bytes[component_start] == b'{' && bytes[component_end] == b'}'
> +            {
> +                let name = bytes
> +                    .split_at(component_end)
> +                    .0
> +                    .split_at(component_start + 1)
> +                    .1;
> +                if !parameter_exists(parameters, name) {
> +                    panic!(
> +                        "privilege path references a parameter that does not exist in the method's parameter schema"

↑ needs line wrapping

> +                    );
> +                }
> +            }
> +            component_start = pos + 1;
> +        }
> +        pos += 1;
> +    }
> +}
> +
> +const fn assert_path_parameters_exist(perm: &Permission, parameters: ParameterSchema) {
> +    match perm {
> +        Permission::WithParam(_, permission) => {

↑ The first entry is a parameter name - shouldn't we check this one,
too? (The content of that parameter is considered to be the user name
to check `permission` against.)

> +            assert_path_parameters_exist(permission, parameters)
> +        }
> +        Permission::Privilege(path, _, _) => {

↑ nit: would pluralize this to "paths"

> +            let mut i = 0;
> +            while i < path.len() {
> +                check_privilege_path_components(path[i], parameters);
> +                i += 1;
> +            }
> +        }
> +        Permission::And(permissions) | Permission::Or(permissions) => {
> +            let mut i = 0;
> +            while i < permissions.len() {
> +                assert_path_parameters_exist(permissions[i], parameters);
> +                i += 1;
> +            }
> +        }
> +        _ => (),
> +    }
> +}
> +
>  impl ApiMethod {
>      pub const fn new_full(handler: &'static ApiHandler, parameters: ParameterSchema) -> Self {
>          Self {
> @@ -890,11 +1015,19 @@ impl ApiMethod {
>          self
>      }
>  
> +    /// Set the access permissions.
> +    ///
> +    /// This asserts that every '{name}' parameter reference in `Privilege` permission paths
> +    /// exists in the method's parameter schema, since such a path could otherwise never match at
> +    /// runtime. Since API methods are usually built in a const context, a violation is a compile
> +    /// time error.
>      pub const fn access(
>          mut self,
>          description: Option<&'static str>,
>          permission: &'static Permission,
>      ) -> Self {
> +        assert_path_parameters_exist(permission, self.parameters);
> +
>          self.access = ApiAccess {
>              description,
>              permission,
> @@ -903,3 +1036,77 @@ impl ApiMethod {
>          self
>      }
>  }
> +
> +#[cfg(test)]
> +mod test {
> +    use super::*;
> +
> +    use proxmox_schema::StringSchema;
> +
> +    const STRING_SCHEMA: Schema = StringSchema::new("test").schema();
> +
> +    const PARAMETERS: ObjectSchema = ObjectSchema::new(
> +        "test parameters",
> +        &[
> +            ("bar", true, &STRING_SCHEMA),
> +            ("foo", false, &STRING_SCHEMA),
> +        ],
> +    );
> +
> +    const ADDITIONAL_PARAMETERS: ObjectSchema =
> +        ObjectSchema::new("test parameters", &[]).additional_properties(true);
> +
> +    // compile time check that valid parameter references are accepted
> +    const _: ApiMethod = ApiMethod::new_dummy(&PARAMETERS).access(
> +        None,
> +        &Permission::And(&[
> +            &Permission::Privilege(&["foo", "{foo}", "{bar}"], 1, true),
> +            &Permission::Privilege(&["foo", "{foo}/{bar}"], 1, true),
> +            &Permission::WithParam(
> +                "foo",
> +                &Permission::Or(&[&Permission::Privilege(&["foo", "{foo}"], 1, true)]),
> +            ),
> +        ]),
> +    );
> +
> +    #[test]
> +    #[should_panic(expected = "privilege path references a parameter")]
> +    fn missing_privilege_path_parameter() {
> +        let _ = ApiMethod::new_dummy(&PARAMETERS)
> +            .access(None, &Permission::Privilege(&["foo", "{baz}"], 1, true));
> +    }
> +
> +    #[test]
> +    #[should_panic(expected = "privilege path references a parameter")]
> +    fn missing_parameter_in_combined_component() {
> +        let _ = ApiMethod::new_dummy(&PARAMETERS).access(
> +            None,
> +            &Permission::Or(&[&Permission::Privilege(
> +                &["datastore", "{foo}/{baz}"],
> +                0b01,
> +                true,
> +            )]),
> +        );
> +    }
> +
> +    #[test]
> +    fn malformed_parameter_in_combined_component() {
> +        // should work, components are not enclosed in brackets properly so no interpolation should
> +        // be done
> +        let _ = ApiMethod::new_dummy(&PARAMETERS).access(
> +            None,
> +            &Permission::Or(&[&Permission::Privilege(&["foo", "bar/{baz/}"], 1, true)]),
> +        );
> +
> +        let _ = ApiMethod::new_dummy(&PARAMETERS).access(
> +            None,
> +            &Permission::Or(&[&Permission::Privilege(&["foo", "{bar/baz}/"], 1, true)]),
> +        );
> +    }
> +
> +    #[test]
> +    fn additional_properties_allow_any_parameter() {
> +        let _ = ApiMethod::new_dummy(&ADDITIONAL_PARAMETERS)
> +            .access(None, &Permission::Privilege(&["foo", "{baz}"], 1, true));
> +    }
> +}
> -- 
> 2.47.3
> 
> 
> 
> 
> 

-- 




  reply	other threads:[~2026-09-11 10:02 UTC|newest]

Thread overview: 5+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-08-31  7:08 [PATCH datacenter-manager/proxmox v2 0/2] check privilege paths during compilation Dominik Csapak
2026-08-31  7:08 ` [PATCH proxmox v2 1/2] router: compile time check privilege path parameters for existence Dominik Csapak
2026-09-11 10:02   ` Wolfgang Bumiller [this message]
2026-08-31  7:08 ` [PATCH datacenter-manager v2 2/2] server: api: pve firewall: fix permission check Dominik Csapak
2026-08-31  7:58   ` partially-applied: " Lukas Wagner

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=fj6ftxsxzp7jnu3zs4lh7t5ptak4s2kc6ve6smgiogww6djkrl@24g6e6ahcvpo \
    --to=w.bumiller@proxmox.com \
    --cc=d.csapak@proxmox.com \
    --cc=pdm-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal