From bb5ff9caa70591edff7a489b4f35b1732d48a9a5 Mon Sep 17 00:00:00 2001 From: u7k4rs6 Date: Mon, 3 Aug 2026 12:30:54 +0530 Subject: [PATCH 1/2] test(unikontainers): add fuzz targets for the unikernel config Add two native fuzz targets over the urunc.json config path. Both assert that any config GetUnikernelConfig accepts carries a non-empty unikernelType, hypervisor and binary, since sixteen references in unikontainers.go read those straight out of the state annotations without rechecking them. FuzzConfigMandatoryFields supplies the four field values directly, so the fuzzer mutates the base64 payloads instead of having to build the surrounding JSON. FuzzGetUnikernelConfigJSON supplies arbitrary bytes to the same entry point to cover parsing. Signed-off-by: u7k4rs6 --- pkg/unikontainers/config_fuzz_test.go | 156 ++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 pkg/unikontainers/config_fuzz_test.go diff --git a/pkg/unikontainers/config_fuzz_test.go b/pkg/unikontainers/config_fuzz_test.go new file mode 100644 index 000000000..d8e48ba6e --- /dev/null +++ b/pkg/unikontainers/config_fuzz_test.go @@ -0,0 +1,156 @@ +// Copyright (c) 2023-2026, Nubificus LTD +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package unikontainers + +import ( + "encoding/base64" + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + + specs "github.com/opencontainers/runtime-spec/specs-go" + "github.com/sirupsen/logrus" +) + +// b64 is a helper for building seed corpus entries that are valid base64. +func b64(s string) string { + return base64.StdEncoding.EncodeToString([]byte(s)) +} + +// fuzzBundle creates a bundle directory once per fuzz worker process and +// returns the bundle dir, the path of the urunc.json inside its rootfs, and a +// spec pointing at that rootfs. +// +// This is deliberately hoisted out of the fuzz function. Creating the bundle +// per iteration with t.TempDir() costs a mkdir plus a recursive cleanup on +// every execution and drops throughput by roughly two orders of magnitude, +// which is enough to stop the fuzzer finding shallow bugs in a CI-length run. +// Reusing one directory and overwriting the file is safe here because each +// worker process gets its own f.TempDir() and executes iterations serially. +func fuzzBundle(f *testing.F) (string, string, *specs.Spec) { + f.Helper() + + // Workload-controlled input makes getConfigFromJSON emit an Error-level + // log line per field that fails to base64-decode. Left enabled, that + // serializes every iteration on stderr and costs about two orders of + // magnitude of throughput. + logrus.SetOutput(io.Discard) + + dir := f.TempDir() + rootfs := filepath.Join(dir, "rootfs") + if err := os.MkdirAll(rootfs, 0o750); err != nil { + f.Fatal(err) + } + + return dir, filepath.Join(rootfs, uruncJSONFilename), &specs.Spec{ + Root: &specs.Root{Path: rootfs}, + Annotations: map[string]string{}, + } +} + +// FuzzConfigMandatoryFields asserts the contract that validate() exists to +// enforce: any config GetUnikernelConfig hands back must carry non-empty +// unikernelType, hypervisor and binary. Sixteen references in +// unikontainers.go read those three straight out of the state annotations +// without rechecking them, and Map() silently drops empty values, so an +// accepted config with an empty mandatory field corrupts container state +// rather than failing loudly. +// +// Input is the four field values, so the fuzzer mutates the base64 payloads +// directly instead of having to synthesize the surrounding JSON. That makes +// it far more effective than FuzzGetUnikernelConfigJSON at reaching the +// decoder, at the cost of not exercising JSON parsing itself. +func FuzzConfigMandatoryFields(f *testing.F) { + f.Add(b64("unikraft"), b64("qemu"), b64("/unikernel/app"), b64("nginx -c /nginx.conf")) + f.Add(b64("rumprun"), b64("hvt"), b64("/unikernel/app.hvt"), b64("")) + f.Add("", "", "", "") + f.Add("not-base64!", "qemu", "bin", "") + + dir, jsonPath, spec := fuzzBundle(f) + + f.Fuzz(func(t *testing.T, unikernelType, hypervisor, binary, cmdline string) { + data, err := json.Marshal(map[string]string{ + annotType: unikernelType, + annotHypervisor: hypervisor, + annotBinary: binary, + annotCmdLine: cmdline, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(jsonPath, data, 0o600); err != nil { + t.Fatal(err) + } + + conf, err := GetUnikernelConfig(dir, spec) + if err != nil { + return // rejected, a valid outcome for arbitrary input + } + if conf == nil { + t.Fatal("GetUnikernelConfig returned a nil config and a nil error") + } + + if conf.UnikernelType == "" || conf.Hypervisor == "" || conf.UnikernelBinary == "" { + t.Fatalf("accepted config is missing a mandatory field\n"+ + "encoded: type=%q hypervisor=%q binary=%q\n"+ + "decoded: type=%q hypervisor=%q binary=%q", + unikernelType, hypervisor, binary, + conf.UnikernelType, conf.Hypervisor, conf.UnikernelBinary) + } + }) +} + +// FuzzGetUnikernelConfigJSON drives arbitrary urunc.json bytes through the +// public entry point. It asserts that a config urunc accepts always carries +// the mandatory fields that downstream code reads out of the state +// annotations, and that no input panics the parser. +func FuzzGetUnikernelConfigJSON(f *testing.F) { + seed, err := json.Marshal(map[string]string{ + annotType: b64("unikraft"), + annotHypervisor: b64("qemu"), + annotBinary: b64("/unikernel/app"), + annotCmdLine: b64("nginx"), + }) + if err != nil { + f.Fatal(err) + } + f.Add(seed) + f.Add([]byte(`{}`)) + f.Add([]byte(`not json`)) + + dir, jsonPath, spec := fuzzBundle(f) + + f.Fuzz(func(t *testing.T, data []byte) { + if err := os.WriteFile(jsonPath, data, 0o600); err != nil { + t.Fatal(err) + } + + conf, err := GetUnikernelConfig(dir, spec) + if err != nil { + return // rejected, which is a valid outcome for arbitrary input + } + if conf == nil { + t.Fatal("GetUnikernelConfig returned a nil config and a nil error") + } + + if conf.UnikernelType == "" || conf.Hypervisor == "" || conf.UnikernelBinary == "" { + t.Fatalf("accepted config is missing a mandatory field: "+ + "type=%q hypervisor=%q binary=%q\ninput: %q", + conf.UnikernelType, conf.Hypervisor, conf.UnikernelBinary, data) + } + }) +} From c99090551b8000fe49828d2f90957c01ef5507c1 Mon Sep 17 00:00:00 2001 From: u7k4rs6 Date: Mon, 3 Aug 2026 12:30:54 +0530 Subject: [PATCH 2/2] fix(config): decode urunc.json before validating mandatory fields GetUnikernelConfig validated the urunc.json config while its values were still base64 and only decoded afterwards. The base64 decoder ignores CR and LF, so a field holding only newlines is non-empty when validated and decodes to the empty string. A config whose mandatory fields are newlines was therefore accepted with all three fields empty. Map() drops empty values, so the mandatory keys never reach the state annotations and every later Get on that container fails with ErrNotUnikernel. Swap the order so that validation runs on decoded values. Add the case to the FuzzConfigMandatoryFields seed corpus as a regression test. Fixes: #885 Signed-off-by: u7k4rs6 --- pkg/unikontainers/config.go | 12 +++++++++--- .../newline_decodes_to_empty | 5 +++++ 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 pkg/unikontainers/testdata/fuzz/FuzzConfigMandatoryFields/newline_decodes_to_empty diff --git a/pkg/unikontainers/config.go b/pkg/unikontainers/config.go index 14a4cb1a7..3d00531b5 100644 --- a/pkg/unikontainers/config.go +++ b/pkg/unikontainers/config.go @@ -100,13 +100,19 @@ func GetUnikernelConfig(bundleDir string, spec *specs.Spec) (*UnikernelConfig, e return nil, fmt.Errorf("config not found in spec annotations or in %s: %w", uruncJSONFilename, err) } + // Decode before validating. The values in urunc.json are base64, and + // base64 ignores CR and LF, so a field holding only newlines is + // non-empty while encoded but decodes to the empty string. Validating + // the encoded form would let such a config through with mandatory + // fields that downstream code reads as empty. + if err := jsonConf.decode(); err != nil { + return nil, err + } + if err := jsonConf.validate(); err != nil { return nil, fmt.Errorf("invalid unikernel config from %s: %w", uruncJSONFilename, err) } - if err := jsonConf.decode(); err != nil { - return nil, err - } return jsonConf, nil } diff --git a/pkg/unikontainers/testdata/fuzz/FuzzConfigMandatoryFields/newline_decodes_to_empty b/pkg/unikontainers/testdata/fuzz/FuzzConfigMandatoryFields/newline_decodes_to_empty new file mode 100644 index 000000000..bff5ee1c0 --- /dev/null +++ b/pkg/unikontainers/testdata/fuzz/FuzzConfigMandatoryFields/newline_decodes_to_empty @@ -0,0 +1,5 @@ +go test fuzz v1 +string("\n") +string("\n") +string("\n") +string("")