harden sandbox-name handling and Bluefile grammar validation - #1
Conversation
Two changes from a source security review (analysis in bluebox-hardening/): 1. Sandbox names are now validated once, inside the sandbox package, by ValidName (letters/digits/._- , max 64, alphanumeric first char). Dir/DataDir/LogPath enforce it, so no command -- including destroy --data, reset and rename, which run os.RemoveAll/os.Rename on name-derived paths -- can act outside BLUEBOX_HOME via a crafted name like "../../something". 2. Bluefile fields that are data, not instructions, are validated at parse time so the generated Containerfile is a faithful rendering of the parsed spec: env keys must be identifiers and values cannot contain newlines (a newline would inject a Containerfile instruction), write_files modes must be octal (a mode like "0755; curl ...|sh" ran as a second shell command), blueprint user names/shells are constrained, packages must be plain tokens, and base must be whitespace-free. All shipped examples still parse; both packages gain table-driven regression tests.
|
Your agent is so bad! |
chxmxii
left a comment
There was a problem hiding this comment.
Thanks for this — it's a well-crafted PR. Putting ValidName at the path builders so future commands inherit it, rejecting bad grammar at parse time with field/index in the message, keeping Render pure, and the examples/*/Bluefile regression test are all exactly the right calls. I checked it out and confirmed go build/vet/gofmt/test are clean and the traversal-name protection blocks destroy ../../../.config.
One blocking issue before merge, and it's in the same class the PR closes.
write_files[].path isn't validated, and it reaches a RUN line
path is only checked for a leading /, but it's interpolated into RUN chmod <mode> <path>. So it doubles as build grammar the same way mode did. This Bluefile parses cleanly:
base: docker.io/library/alpine:latest
blueprint:
write_files:
- path: "/tmp/x; touch /tmp/PWNED # "
content: "hi"
mode: "0644"and Containerfile() renders:
COPY .blueprint/f0 /tmp/x; touch /tmp/PWNED #
RUN chmod 0644 /tmp/x; touch /tmp/PWNED #
so touch /tmp/PWNED runs at build time. It triggers only when mode is set (that's the one line that shell-interpolates the path; the COPY line isn't shell), but the vector is real.
The fix fits the pattern you already established — a path regex alongside the write_files loop, mirroring your shellRe:
pathRe = regexp.MustCompile(`^/[A-Za-z0-9/._-]+$`)if !pathRe.MatchString(f.Path) {
return fmt.Errorf("blueprint.write_files[%d]: path must be an absolute path (letters, digits, '/', '.', '_', '-'), got %q", i, f.Path)
}That also subsumes the existing leading-/ check, and a rejection test case in the same table as the others would round it out.
Non-blocking nit
SnapshotsDir doesn't call ValidName itself. It's safe today because every caller reaches it after DataDir/Dir has already validated, but a direct ValidName there would match the "validate where the path is built" principle the rest of the PR follows.
Everything else looks great — fix the path validation and I'll merge.
|
Merging this now — the name and grammar validation is solid and I'd rather land it than hold it. I'll take the two follow-ups myself in a separate commit so you don't have to round-trip: the |
Two follow-ups to #1, completing the v0.2.1 input-hardening pass. write_files[].path was only checked for a leading slash, but it is interpolated into a COPY and, when a mode is set, into a RUN chmod line -- so it is build grammar the same way mode is. A path like "/tmp/x; touch /PWNED # " passed validation and injected a second shell command at build time. It now must match the same absolute-path grammar as a user shell. SnapshotsDir did not call ValidName, unlike the other path builders. It was safe in practice because every caller reached it after Dir/DataDir had validated, but validating where the path is built removes the standing assumption. Closes #2 Closes #3
Address review on chxperiments#1: - blueprint.write_files[].path reaches `RUN chmod <mode> <path>`, so a path like "/tmp/x; touch /tmp/PWNED #" ran as a second build command. It is now constrained to plain absolute paths by pathRe, which subsumes the old leading-"/" check. Regression test added. - SnapshotsDir now calls ValidName itself, matching the "validate where the path is built" rule the other path builders follow.
Add a mounts: list so the host state a sandbox can touch is explicit in the
spec rather than the single implicit /data share.
mounts:
- host: ~/projects/demo
guest: /work
mode: ro # ro (default) or rw
Every field is validated and normalized at parse time (the fail-loudly pattern
from #1): host must be absolute (~ expands to home) and free of ':', guest
matches the absolute-path grammar and cannot shadow /data, mode is ro|rw and
defaults to the fail-safe ro, and guest targets must be unique. At run time each
mount becomes a -v host:guest:mode after the /data share, which vmArgs already
builds in one place.
Closes #5
Co-authored-by: chxmxii <mouhib2000@yahoo.com>
Summary
Two hardening changes from a source security review (commit
c017e1b). The microVM boundary is left untouched — both fixes are host-side, at the points where inputs enter the system.1. Sandbox names are validated once, in one place
internal/sandboxnow owns aValidNamecheck ([A-Za-z0-9][A-Za-z0-9._-]{0,63}), enforced by the three filesystem path builders (Dir,DataDir,LogPath). Every destructive sink —destroy --dataandreset(os.RemoveAll),rename(os.Rename) — routes through those builders, so a crafted name can no longer escape~/.bluebox:The check lives where paths are built rather than per command, so future commands inherit it instead of having to remember it.
2. Bluefile data fields can no longer change Containerfile structure
Spec fields that are data were interpolated raw into the generated Containerfile and its
RUNshell lines, so they doubled as build grammar:\nbecame an extra instruction (ENV A=one+RUN curl attacker…)write_files[].mode: "0755; curl …|sh"ran as a second shell command during buildThese are now rejected at parse time with errors naming field and index, matching the existing fail-loudly philosophy (unknown keys already error).
Renderstays a pure function; escaping logic was deliberately not added.Compatibility
/, leading., or >64 chars stop working — such names never behaved coherently (nested dirs, inconsistent listing).run:).examples/*/Bluefilestill parse — enforced by a new regression test globbingexamples/*/Bluefile.Tests
internal/sandbox: table tests forValidName; assertions that all three builders reject traversal names.internal/bluefile: seven new grammar-rejection cases (newline env value, bad env key, non-octal mode, user name with space, relative shell, package injection token, base with whitespace) plus the examples regression test.go build,go vet,gofmt, andgo test ./...all clean.Full analysis, options considered, tradeoffs, and implementation plans are in the accompanying hardening portfolio (proposals under
sandbox-name-boundaryandcontainerfile-spec-injection).