security: validate revision parameter to prevent Nix injection
The revision parameter was interpolated directly into a Nix expression, allowing potential injection of arbitrary Nix code. An attacker could craft a revision string like: "; builtins.readFile /etc/passwd; " This adds ValidateRevision() which ensures revisions only contain safe characters (alphanumeric, hyphens, underscores, dots) and are at most 64 characters long. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,12 +11,18 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
"git.t-juice.club/torjus/labmcp/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// revisionPattern validates revision strings to prevent injection attacks.
|
||||||
|
// Allows: alphanumeric, hyphens, underscores, dots (for channel names like "nixos-24.11"
|
||||||
|
// and git hashes). Must be 1-64 characters.
|
||||||
|
var revisionPattern = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`)
|
||||||
|
|
||||||
// Indexer handles indexing of nixpkgs revisions.
|
// Indexer handles indexing of nixpkgs revisions.
|
||||||
type Indexer struct {
|
type Indexer struct {
|
||||||
store database.Store
|
store database.Store
|
||||||
@@ -42,10 +48,24 @@ type IndexResult struct {
|
|||||||
AlreadyIndexed bool // True if revision was already indexed (skipped)
|
AlreadyIndexed bool // True if revision was already indexed (skipped)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateRevision checks if a revision string is safe to use.
|
||||||
|
// Returns an error if the revision contains potentially dangerous characters.
|
||||||
|
func ValidateRevision(revision string) error {
|
||||||
|
if !revisionPattern.MatchString(revision) {
|
||||||
|
return fmt.Errorf("invalid revision format: must be 1-64 alphanumeric characters, hyphens, underscores, or dots")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// IndexRevision indexes a nixpkgs revision by git hash or channel name.
|
// IndexRevision indexes a nixpkgs revision by git hash or channel name.
|
||||||
func (idx *Indexer) IndexRevision(ctx context.Context, revision string) (*IndexResult, error) {
|
func (idx *Indexer) IndexRevision(ctx context.Context, revision string) (*IndexResult, error) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
|
// Validate revision to prevent injection attacks
|
||||||
|
if err := ValidateRevision(revision); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve channel names to git refs
|
// Resolve channel names to git refs
|
||||||
ref := resolveRevision(revision)
|
ref := resolveRevision(revision)
|
||||||
|
|
||||||
@@ -116,6 +136,11 @@ func (idx *Indexer) IndexRevision(ctx context.Context, revision string) (*IndexR
|
|||||||
|
|
||||||
// ReindexRevision forces re-indexing of a revision, deleting existing data first.
|
// ReindexRevision forces re-indexing of a revision, deleting existing data first.
|
||||||
func (idx *Indexer) ReindexRevision(ctx context.Context, revision string) (*IndexResult, error) {
|
func (idx *Indexer) ReindexRevision(ctx context.Context, revision string) (*IndexResult, error) {
|
||||||
|
// Validate revision to prevent injection attacks
|
||||||
|
if err := ValidateRevision(revision); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
ref := resolveRevision(revision)
|
ref := resolveRevision(revision)
|
||||||
|
|
||||||
// Delete existing revision if present
|
// Delete existing revision if present
|
||||||
|
|||||||
@@ -12,6 +12,57 @@ import (
|
|||||||
// TestNixpkgsRevision is the revision from flake.lock used for testing.
|
// TestNixpkgsRevision is the revision from flake.lock used for testing.
|
||||||
const TestNixpkgsRevision = "e6eae2ee2110f3d31110d5c222cd395303343b08"
|
const TestNixpkgsRevision = "e6eae2ee2110f3d31110d5c222cd395303343b08"
|
||||||
|
|
||||||
|
// TestValidateRevision tests the revision validation function.
|
||||||
|
func TestValidateRevision(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
revision string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
// Valid cases
|
||||||
|
{"valid git hash", "e6eae2ee2110f3d31110d5c222cd395303343b08", false},
|
||||||
|
{"valid short hash", "e6eae2e", false},
|
||||||
|
{"valid channel name", "nixos-unstable", false},
|
||||||
|
{"valid channel with version", "nixos-24.11", false},
|
||||||
|
{"valid underscore", "some_branch", false},
|
||||||
|
{"valid mixed", "release-24.05_beta", false},
|
||||||
|
|
||||||
|
// Invalid cases - injection attempts
|
||||||
|
{"injection semicolon", "foo; rm -rf /", true},
|
||||||
|
{"injection quotes", `"; builtins.readFile /etc/passwd; "`, true},
|
||||||
|
{"injection backticks", "foo`whoami`", true},
|
||||||
|
{"injection dollar", "foo$(whoami)", true},
|
||||||
|
{"injection newline", "foo\nbar", true},
|
||||||
|
{"injection space", "foo bar", true},
|
||||||
|
{"injection slash", "foo/bar", true},
|
||||||
|
{"injection backslash", "foo\\bar", true},
|
||||||
|
{"injection pipe", "foo|bar", true},
|
||||||
|
{"injection ampersand", "foo&bar", true},
|
||||||
|
{"injection redirect", "foo>bar", true},
|
||||||
|
{"injection less than", "foo<bar", true},
|
||||||
|
{"injection curly braces", "foo{bar}", true},
|
||||||
|
{"injection parens", "foo(bar)", true},
|
||||||
|
{"injection brackets", "foo[bar]", true},
|
||||||
|
|
||||||
|
// Edge cases
|
||||||
|
{"empty string", "", true},
|
||||||
|
{"too long", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true},
|
||||||
|
{"just dots", "...", false}, // dots are allowed, path traversal is handled elsewhere
|
||||||
|
{"single char", "a", false},
|
||||||
|
{"max length 64", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false},
|
||||||
|
{"65 chars", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateRevision(tt.revision)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ValidateRevision(%q) error = %v, wantErr %v", tt.revision, err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// BenchmarkIndexRevision benchmarks indexing a full nixpkgs revision.
|
// BenchmarkIndexRevision benchmarks indexing a full nixpkgs revision.
|
||||||
// This is a slow benchmark that requires nix to be installed.
|
// This is a slow benchmark that requires nix to be installed.
|
||||||
// Run with: go test -bench=BenchmarkIndexRevision -benchtime=1x -timeout=30m ./internal/nixos/...
|
// Run with: go test -bench=BenchmarkIndexRevision -benchtime=1x -timeout=30m ./internal/nixos/...
|
||||||
|
|||||||
Reference in New Issue
Block a user