Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/cli-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ name: cli-e2e
# installer scripts themselves change, or on schedule, or on manual
# dispatch — they no longer run on every CLI code change.

# Least-privilege default token (these jobs only read the repo).
permissions:
contents: read

on:
# Source validation (unit + cross-build) runs on PRs. The install jobs
# download the RELEASED CLI, so they run on release:published (against the
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
# (real nsjail) self-skip in CI, where nested sandboxing is unavailable.
name: e2e

# Least-privilege default token (these jobs only read the repo).
permissions:
contents: read

on:
push:
branches: [main, dev]
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/install-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ name: install-e2e
# install scripts themselves) lives in ci.yml — this workflow assumes
# the scripts pass lint and focuses on actually running them.

# Least-privilege default token (these jobs only read the repo).
permissions:
contents: read

on:
# Runs AFTER a release publishes — these download the released binary, so
# they must not run on main-push: during a release cut the old release is
Expand Down
20 changes: 20 additions & 0 deletions backend/internal/builder/activate.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ func ActivateVersion(dataDir, fnID, codeHash string) error {
if codeHash == "" {
return fmt.Errorf("activate: empty code hash")
}
// codeHash becomes part of a filesystem path (versions/<hash>); it is
// always a sha256 hex digest. Reject anything else defensively so a
// traversal value can never reach the symlink target.
if !isHexHash(codeHash) {
return fmt.Errorf("activate: invalid code hash %q", codeHash)
}
fnDir := filepath.Join(dataDir, "functions", fnID)
target := filepath.Join("versions", codeHash) // relative; see comment above

Expand All @@ -38,6 +44,20 @@ func ActivateVersion(dataDir, fnID, codeHash string) error {
return nil
}

// isHexHash reports whether s is a 64-char lowercase-hex sha256 digest.
func isHexHash(s string) bool {
if len(s) != 64 {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
return false
}
}
return true
}

// ResolveActiveHash reads `<dataDir>/functions/<fnID>/current` and returns
// the hash it points at, or "" if the symlink is missing or malformed.
// Used by GC to know which version not to delete.
Expand Down
35 changes: 35 additions & 0 deletions backend/internal/builder/activate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package builder

import (
"strings"
"testing"
)

// TestActivateVersionRejectsBadHash ensures a traversal/garbage code hash never
// reaches the symlink target (path-injection defense).
func TestActivateVersionRejectsBadHash(t *testing.T) {
dir := t.TempDir()
bad := []string{"../../etc", "..", "abc", "ZZZ", strings.Repeat("g", 64), "/abs/path"}
for _, h := range bad {
if err := ActivateVersion(dir, "fn1", h); err == nil {
t.Errorf("ActivateVersion accepted invalid hash %q", h)
}
}
// A well-formed 64-hex hash passes validation (it then fails later only
// because the version dir doesn't exist, which is fine — not our concern).
good := strings.Repeat("a", 64)
if err := ActivateVersion(dir, "fn1", good); err != nil && strings.Contains(err.Error(), "invalid code hash") {
t.Errorf("ActivateVersion wrongly rejected a valid hash: %v", err)
}
}

func TestIsHexHash(t *testing.T) {
if !isHexHash(strings.Repeat("a", 64)) {
t.Error("valid 64-hex rejected")
}
for _, s := range []string{"", "abc", strings.Repeat("A", 64), strings.Repeat("a", 63), "../" + strings.Repeat("a", 61)} {
if isHexHash(s) {
t.Errorf("isHexHash accepted invalid %q", s)
}
}
}
6 changes: 6 additions & 0 deletions backend/internal/builder/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,12 @@ func extractTarGz(archivePath, destDir string) error {
}

target := filepath.Join(destDir, cleanName)
// Defense-in-depth: the joined path must still live under destDir even
// after Clean (mirrors the backup extractor).
if target != filepath.Clean(destDir) &&
!strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) {
return fmt.Errorf("path traversal in archive: %s", hdr.Name)
}

switch hdr.Typeflag {
case tar.TypeDir:
Expand Down
23 changes: 23 additions & 0 deletions backend/internal/server/handlers/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,22 @@ var validRuntimes = map[string]bool{
func runtimeIsNode(r string) bool { return r == "node" }
func runtimeIsPython(r string) bool { return r == "python" }

// isValidCodeHash reports whether s is a 64-char lowercase-hex sha256 digest —
// the exact shape every Orva code_hash has. Used to reject untrusted code_hash
// values before they reach a filesystem path (path-traversal defense).
func isValidCodeHash(s string) bool {
if len(s) != 64 {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
return false
}
}
return true
}

// Create handles POST /api/v1/functions.
func (h *FunctionHandler) Create(w http.ResponseWriter, r *http.Request) {
reqID := r.Header.Get("X-Request-ID")
Expand Down Expand Up @@ -871,6 +887,13 @@ func (h *FunctionHandler) Rollback(w http.ResponseWriter, r *http.Request) {
respond.Error(w, http.StatusBadRequest, "VALIDATION", "deployment_id or code_hash is required", reqID)
return
}
// A code_hash from the request body flows into a filesystem path
// (versions/<hash>); enforce the format it always has (sha256 hex) so a
// crafted value like "../.." can't escape the function's versions dir.
if req.CodeHash != "" && !isValidCodeHash(req.CodeHash) {
respond.Error(w, http.StatusBadRequest, "VALIDATION", "code_hash must be 64 lowercase hex characters", reqID)
return
}

// Serialize against deploys on the same fn.
if h.FnLock != nil {
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading