runtimes: reject unshipped KATA_CONF_FILE paths

Fail startup when KATA_CONF_FILE does not resolve to one of the shipped
default config files, preventing untrusted config path injection via
environment overrides.

Signed-off-by: Fabiano Fidêncio <ffidencio@nvidia.com>
This commit is contained in:
Fabiano Fidêncio
2026-05-29 09:24:22 +02:00
parent 03cc670076
commit ab2e7f0c58
9 changed files with 182 additions and 10 deletions

2
Cargo.lock generated
View File

@@ -6665,11 +6665,13 @@ dependencies = [
"protobuf",
"protocols",
"resource",
"rstest 0.18.2",
"runtime-spec",
"serde_json",
"shim-interface",
"slog",
"slog-scope",
"tempfile",
"tokio",
"tracing",
"tracing-opentelemetry 0.18.0",

View File

@@ -235,7 +235,7 @@ This `ConfigPath` option is optional. If you want to use a different configurati
> **Note:** In this example, the specified `ConfigPath` is valid in Kubernetes/Containerd workflow with containerd v1.7+ but doesn't work with ctr and nerdctl.
If you do not specify it, `shimv2` first tries to get the configuration file from the environment variable `KATA_CONF_FILE`. If you want to adopt this way, you should first create a shell script as `containerd-shim-kata-v2` which is placed under the path of `/usr/local/bin/`. The following is an example of the shell script `containerd-shim-kata-qemu-v2` which specifies the configuration file with `KATA_CONF_FILE`
If you do not specify it, `shimv2` first tries to get the configuration file from the environment variable `KATA_CONF_FILE`. For security, this value is accepted only when it resolves to one of Kata's shipped default configuration files. If you want to adopt this way, you should first create a shell script as `containerd-shim-kata-v2` which is placed under the path of `/usr/local/bin/`. The following is an example of the shell script `containerd-shim-kata-qemu-v2` which specifies the configuration file with `KATA_CONF_FILE`
> **Note:** Just use containerd 2.x configuration as an example, the configuration for containerd 1.7.x is similar, just replace `io.containerd.cri.v1.runtime` with `io.containerd.grpc.v1.cri`

View File

@@ -224,7 +224,7 @@ Next, we need to configure containerd. Add a file in your path (e.g. `/usr/local
#!/bin/bash
KATA_CONF_FILE=/etc/kata-containers/configuration-fc.toml /usr/local/bin/containerd-shim-kata-v2 $@
```
> **Note:** You may need to edit the paths of the configuration file and the `containerd-shim-kata-v2` to correspond to your setup.
> **Note:** You may need to edit the paths of the configuration file and the `containerd-shim-kata-v2` to correspond to your setup. `KATA_CONF_FILE` is only accepted when it resolves to one of Kata's shipped default configuration files.
Make it executable:

View File

@@ -38,8 +38,6 @@ pub const KATA_ANNO_PREFIX: &str = "io.katacontainers.";
pub const KATA_ANNO_CFG_PREFIX: &str = "io.katacontainers.config.";
/// Prefix for Kata container annotations
pub const KATA_ANNO_CONTAINER_PREFIX: &str = "io.katacontainers.container.";
/// The annotation key to fetch runtime configuration file.
// OCI section
/// The annotation key to fetch the OCI configuration file path.
pub const BUNDLE_PATH_KEY: &str = "io.katacontainers.pkg.oci.bundle_path";

View File

@@ -5,6 +5,10 @@ authors = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
[dev-dependencies]
rstest = { workspace = true }
tempfile = { workspace = true }
[dependencies]
anyhow = { workspace = true }
lazy_static = { workspace = true }

View File

@@ -817,9 +817,18 @@ impl Env for RootlessEnv {
fn load_config(an: &HashMap<String, String>, option: &Option<Vec<u8>>) -> Result<TomlConfig> {
const KATA_CONF_FILE: &str = "KATA_CONF_FILE";
let annotation = Annotation::new(an.clone());
// Clone a logger from global logger to ensure the logs in this function get flushed when drop
let logger = slog::Logger::clone(&slog_scope::logger());
let config_path = if let Ok(path) = std::env::var(KATA_CONF_FILE) {
path
if is_shipped_kata_config_path(&path) {
path
} else {
return Err(anyhow!(
"invalid KATA_CONF_FILE {:?}: only shipped Kata configuration files are accepted",
path
));
}
} else if let Some(option) = option {
// Parse the containerd runtime options protobuf message to extract the config path.
// The options are passed as a serialized runtimeoptions.v1.Options protobuf message
@@ -841,9 +850,6 @@ fn load_config(an: &HashMap<String, String>, option: &Option<Vec<u8>>) -> Result
String::from("")
};
// Clone a logger from global logger to ensure the logs in this function get flushed when drop
let logger = slog::Logger::clone(&slog_scope::logger());
info!(logger, "get config path {:?}", &config_path);
let (mut toml_config, _) = TomlConfig::load_from_file(&config_path).context(format!(
"load TOML config failed (tried {:?})",
@@ -859,6 +865,21 @@ fn load_config(an: &HashMap<String, String>, option: &Option<Vec<u8>>) -> Result
Ok(toml_config)
}
fn is_shipped_kata_config_path(config_path: &str) -> bool {
config_path_matches_defaults(config_path, TomlConfig::get_default_config_file_list())
}
fn config_path_matches_defaults(config_path: &str, default_config_paths: Vec<PathBuf>) -> bool {
let Ok(resolved_config_path) = std::fs::canonicalize(config_path) else {
return false;
};
default_config_paths
.into_iter()
.filter_map(|path| std::fs::canonicalize(path).ok())
.any(|path| path == resolved_config_path)
}
// this update the agent-specfic kernel parameters into hypervisor's bootinfo
// the agent inside the VM will read from file cmdline to get the params and function
fn update_agent_kernel_params(config: &mut TomlConfig) -> Result<()> {
@@ -1001,6 +1022,7 @@ fn configure_non_root_hypervisor(config: &mut Hypervisor) -> Result<()> {
mod tests {
use super::*;
use common::types::ShutdownRequest;
use rstest::rstest;
use tokio::sync::mpsc::channel;
// A ShutdownContainer RPC that arrives before any runtime instance was
@@ -1036,4 +1058,50 @@ mod tests {
assert_eq!(effective_log_level(true, "trace"), "trace");
assert_eq!(effective_log_level(true, "warn"), "warn");
}
#[derive(Debug)]
enum ConfigPathCase {
Shipped,
NonShipped,
NonExistent,
Empty,
}
#[rstest]
#[case::shipped_config_is_accepted(ConfigPathCase::Shipped, true)]
#[case::non_shipped_config_is_rejected(ConfigPathCase::NonShipped, false)]
#[case::non_existent_path_is_rejected(ConfigPathCase::NonExistent, false)]
#[case::empty_path_is_rejected(ConfigPathCase::Empty, false)]
fn test_config_path_matches_defaults(
#[case] path_case: ConfigPathCase,
#[case] expected: bool,
) {
let tmpdir = tempfile::tempdir().unwrap();
let shipped_path = tmpdir.path().join("shipped.toml");
let non_shipped_path = tmpdir.path().join("malicious.toml");
std::fs::write(&shipped_path, b"[hypervisor.qemu]\n").unwrap();
std::fs::write(&non_shipped_path, b"[hypervisor.qemu]\n").unwrap();
// Only the shipped path is treated as a default config location.
let default_config_paths = vec![shipped_path.clone()];
let config_path = match path_case {
ConfigPathCase::Shipped => shipped_path.to_string_lossy().to_string(),
ConfigPathCase::NonShipped => non_shipped_path.to_string_lossy().to_string(),
ConfigPathCase::NonExistent => tmpdir
.path()
.join("nonexistent.toml")
.to_string_lossy()
.to_string(),
ConfigPathCase::Empty => String::new(),
};
assert_eq!(
config_path_matches_defaults(&config_path, default_config_paths),
expected,
"case {:?}: unexpected result for path {:?}",
path_case,
config_path,
);
}
}

View File

@@ -65,7 +65,8 @@ The shimv2 runtime looks for its configuration in the following places (in order
[shimv2](/docs/design/architecture/README.md#shim-v2-architecture)
options passed to the runtime.
- The value of the `KATA_CONF_FILE` environment variable.
- The value of the `KATA_CONF_FILE` environment variable, if it resolves to one
of the shipped default configuration files.
- The [default configuration paths](#stateless-systems).

View File

@@ -290,7 +290,14 @@ func loadRuntimeConfig(s *service, r *taskAPI.CreateTaskRequest) (*oci.RuntimeCo
// Try to get the config file from the env KATA_CONF_FILE
if configPath == "" {
configPath = os.Getenv("KATA_CONF_FILE")
envConfigPath := os.Getenv("KATA_CONF_FILE")
if envConfigPath != "" {
if isShippedKataConfigPath(envConfigPath) {
configPath = envConfigPath
} else {
return nil, fmt.Errorf("invalid KATA_CONF_FILE %q: only shipped Kata configuration files are accepted", envConfigPath)
}
}
}
_, runtimeConfig, err := katautils.LoadConfiguration(configPath, false)
@@ -306,6 +313,26 @@ func loadRuntimeConfig(s *service, r *taskAPI.CreateTaskRequest) (*oci.RuntimeCo
return &runtimeConfig, nil
}
func isShippedKataConfigPath(configPath string) bool {
resolvedConfigPath, err := katautils.ResolvePath(configPath)
if err != nil {
return false
}
for _, defaultConfigPath := range katautils.GetDefaultConfigFilePaths() {
resolvedDefaultPath, err := katautils.ResolvePath(defaultConfigPath)
if err != nil {
continue
}
if resolvedConfigPath == resolvedDefaultPath {
return true
}
}
return false
}
func checkAndMount(s *service, r *taskAPI.CreateTaskRequest) (bool, error) {
if len(r.Rootfs) == 1 {
m := r.Rootfs[0]

View File

@@ -19,9 +19,11 @@ import (
crioption "github.com/containerd/cri-containerd/pkg/api/runtimeoptions/v1"
specs "github.com/opencontainers/runtime-spec/specs-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/kata-containers/kata-containers/src/runtime/pkg/device/config"
ktu "github.com/kata-containers/kata-containers/src/runtime/pkg/katatestutils"
"github.com/kata-containers/kata-containers/src/runtime/pkg/katautils"
vc "github.com/kata-containers/kata-containers/src/runtime/virtcontainers"
"github.com/kata-containers/kata-containers/src/runtime/virtcontainers/pkg/compatoci"
"github.com/kata-containers/kata-containers/src/runtime/virtcontainers/pkg/vcmock"
@@ -406,6 +408,18 @@ func TestCreateLoadRuntimeConfig(t *testing.T) {
_, err = loadRuntimeConfig(s, r)
assert.Error(err)
// existing but non-shipped config should be rejected
maliciousConfig := path.Join(tmpdir, "malicious.toml")
err = os.WriteFile(maliciousConfig, []byte("[hypervisor.qemu]\n"), os.FileMode(0640))
assert.NoError(err)
err = os.Setenv("KATA_CONF_FILE", maliciousConfig)
assert.NoError(err)
option.ConfigPath = ""
r.Options, err = protobuf.MarshalAnyToProto(option)
assert.NoError(err)
_, err = loadRuntimeConfig(s, r)
assert.Error(err)
// 1. shimv2 create task option
option.ConfigPath = config
r.Options, err = protobuf.MarshalAnyToProto(option)
@@ -422,3 +436,61 @@ func TestCreateLoadRuntimeConfig(t *testing.T) {
_, err = loadRuntimeConfig(s, r)
assert.NoError(err)
}
func TestIsShippedKataConfigPath(t *testing.T) {
tmpdir := t.TempDir()
// Setup: Create test config files
shippedConfigPath := path.Join(tmpdir, "shipped.toml")
maliciousConfigPath := path.Join(tmpdir, "malicious.toml")
nonExistentPath := path.Join(tmpdir, "nonexistent.toml")
require.NoError(t, os.WriteFile(shippedConfigPath, []byte("[hypervisor.qemu]\n"), 0640))
require.NoError(t, os.WriteFile(maliciousConfigPath, []byte("[hypervisor.qemu]\n"), 0640))
// Configure shipped path
defaultConfigPaths := katautils.GetDefaultConfigFilePaths()
defer katautils.SetConfigOptions("", defaultConfigPaths[1], defaultConfigPaths[0])
katautils.SetConfigOptions("", shippedConfigPath, "")
tests := []struct {
name string
path string
expected bool
reason string
}{
{
name: "shipped config is accepted",
path: shippedConfigPath,
expected: true,
reason: "path matches configured default",
},
{
name: "malicious config is rejected",
path: maliciousConfigPath,
expected: false,
reason: "path does not match any default",
},
{
name: "non-existent path is rejected",
path: nonExistentPath,
expected: false,
reason: "path cannot be resolved",
},
{
name: "empty path is rejected",
path: "",
expected: false,
reason: "empty path is invalid",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isShippedKataConfigPath(tt.path)
assert.Equal(t, tt.expected, result,
"isShippedKataConfigPath(%q) = %v, want %v: %s",
tt.path, result, tt.expected, tt.reason)
})
}
}