fix(api): don't run validate commands from a caller-supplied registry_url

#311 let callers point GET /osm/api/registry-info at any registry via the
registry_url query param. Both modes then called IsBinaryInstalled() on every
entry, which runs `sh -c <valide-command>` — so a GET with a hostile registry
executed arbitrary shell on the server. With SameSite=Lax session cookies and
reflect-all CORS, that was reachable by CSRF from any page an operator visits.

- add installer.IsBinaryInstalledNoExec() and use it whenever registry_url is
  set; only the embedded registry is trusted to run validate commands
- match isGitHubURL() on the parsed hostname so a lookalike host such as
  evil.tld/?x=github.com no longer receives the GitHub token
- cap remote registry reads at 32MB instead of an unbounded io.ReadAll
- surface LoadRegistry errors in nix-build mode rather than returning a
  success response with all metadata silently missing
- report the same registry_url semantics in both modes, and document the
  no-exec behaviour in docs/api/install.mdx
This commit is contained in:
j3ssie
2026-08-08 14:10:26 +08:00
parent ee80ef873e
commit df0683b607
6 changed files with 258 additions and 17 deletions
+40 -3
View File
@@ -36,9 +36,27 @@ func GetRegistryInfo(cfg *config.Config) fiber.Handler {
}
}
// isTrustedRegistry reports whether the registry source is one the server controls.
// An empty registry_url means the embedded registry; anything else was supplied by the
// caller and its entries must never be executed (see IsBinaryInstalledNoExec).
func isTrustedRegistry(registryPathOrURL string) bool {
return registryPathOrURL == ""
}
// registryInstalledStatus reports install status for one entry, shelling out to its
// valide-command only when the registry came from a trusted source.
func registryInstalledStatus(name string, entry *installer.BinaryEntry, trusted bool) bool {
if trusted {
return installer.IsBinaryInstalled(name, entry)
}
return installer.IsBinaryInstalledNoExec(name, entry)
}
// getDirectFetchRegistry returns the direct-fetch registry (existing behavior)
func getDirectFetchRegistry(c *fiber.Ctx) error {
registryPathOrURL := c.Query("registry_url", "")
trusted := isTrustedRegistry(registryPathOrURL)
registry, err := installer.LoadRegistry(registryPathOrURL, nil)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
@@ -70,7 +88,7 @@ func getDirectFetchRegistry(c *fiber.Ctx) error {
CommandDual: entry.CommandDual,
MultiCommandsLinux: entry.MultiCommandsLinux,
MultiCommandsDarwin: entry.MultiCommandsDarwin,
Installed: installer.IsBinaryInstalled(name, &entry),
Installed: registryInstalledStatus(name, &entry, trusted),
Path: path,
Optional: containsOptionalTag(entry.Tags),
}
@@ -104,7 +122,26 @@ func getNixBuildRegistry(c *fiber.Ctx) error {
// Load registry for metadata (desc, tags) - use custom registry_url if provided
registryPathOrURL := c.Query("registry_url", "")
registry, _ := installer.LoadRegistry(registryPathOrURL, nil)
trusted := isTrustedRegistry(registryPathOrURL)
registry, err := installer.LoadRegistry(registryPathOrURL, nil)
if err != nil {
// Metadata is optional for the embedded registry, but a caller-supplied source
// that fails to load is a mistake the caller needs to see rather than a silent
// response with every desc/tags field missing.
if !trusted {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": true,
"message": "Failed to load registry: " + err.Error(),
})
}
registry = nil
}
// Report the concrete source used, matching direct-fetch mode
if registryPathOrURL == "" {
registryPathOrURL = installer.DefaultRegistryURL
}
// Build response with categories and tool metadata
categoriesData := make([]map[string]interface{}, 0)
@@ -121,7 +158,7 @@ func getNixBuildRegistry(c *fiber.Ctx) error {
toolData := map[string]interface{}{
"name": tool,
"installed": installer.IsBinaryInstalled(tool, entryPtr),
"installed": registryInstalledStatus(tool, entryPtr, trusted),
}
if entryPtr != nil {
toolData["desc"] = entryPtr.Desc
+113
View File
@@ -0,0 +1,113 @@
package handlers
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/j3ssie/osmedeus/v5/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// writeRegistryFile writes a registry JSON file and returns its path
func writeRegistryFile(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "registry.json")
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
return path
}
func callRegistryInfo(t *testing.T, query string) (*http.Response, map[string]interface{}) {
t.Helper()
app := fiber.New()
app.Get("/osm/api/registry-info", GetRegistryInfo(&config.Config{}))
resp, err := app.Test(httptest.NewRequest("GET", "/osm/api/registry-info"+query, nil), 10000)
require.NoError(t, err)
raw, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var body map[string]interface{}
require.NoError(t, json.Unmarshal(raw, &body), "response: %s", raw)
return resp, body
}
// TestGetRegistryInfo_CustomRegistryDoesNotExecuteValidateCommand guards the endpoint
// against command execution: valide-command is run as shell, so a registry supplied
// through the registry_url query param must never have it executed. "amass" is used as
// the entry name because nix-build mode only reads metadata for tools listed in flake.nix.
func TestGetRegistryInfo_CustomRegistryDoesNotExecuteValidateCommand(t *testing.T) {
marker := filepath.Join(t.TempDir(), "executed.txt")
registryPath := writeRegistryFile(t, `{
"amass": {"desc": "hi", "valide-command": "touch `+marker+`"}
}`)
for _, mode := range []string{"direct-fetch", "nix-build"} {
t.Run(mode, func(t *testing.T) {
resp, _ := callRegistryInfo(t, "?registry_mode="+mode+"&registry_url="+registryPath)
assert.Equal(t, 200, resp.StatusCode)
_, statErr := os.Stat(marker)
assert.True(t, os.IsNotExist(statErr),
"valide-command from a caller-supplied registry must not be executed")
})
}
}
// TestGetRegistryInfo_EmbeddedRegistry checks the default response shape is unchanged.
func TestGetRegistryInfo_EmbeddedRegistry(t *testing.T) {
resp, body := callRegistryInfo(t, "")
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, "direct-fetch", body["registry_mode"])
assert.NotEmpty(t, body["registry_url"], "embedded registry should report a concrete source")
assert.NotEmpty(t, body["binaries"])
}
// TestGetRegistryInfo_CustomRegistryReturnsItsBinaries checks the feature itself still
// works: a caller-supplied registry replaces the binary list in direct-fetch mode.
func TestGetRegistryInfo_CustomRegistryReturnsItsBinaries(t *testing.T) {
registryPath := writeRegistryFile(t, `{"only-tool": {"desc": "the only one"}}`)
resp, body := callRegistryInfo(t, "?registry_url="+registryPath)
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, registryPath, body["registry_url"])
binaries, ok := body["binaries"].(map[string]interface{})
require.True(t, ok)
assert.Contains(t, binaries, "only-tool")
assert.Len(t, binaries, 1)
}
// TestGetRegistryInfo_NixBuildReportsRegistrySource checks both modes name a concrete
// source, so callers can tell which registry produced the metadata.
func TestGetRegistryInfo_NixBuildReportsRegistrySource(t *testing.T) {
registryPath := writeRegistryFile(t, `{"amass": {"desc": "custom desc"}}`)
resp, body := callRegistryInfo(t, "?registry_mode=nix-build&registry_url="+registryPath)
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, "nix-build", body["registry_mode"])
assert.Equal(t, registryPath, body["registry_url"])
_, defaultBody := callRegistryInfo(t, "?registry_mode=nix-build")
assert.NotEmpty(t, defaultBody["registry_url"], "embedded metadata source should be reported too")
}
// TestGetRegistryInfo_BadCustomRegistryIsReported ensures a registry_url that cannot be
// loaded surfaces an error rather than a success response with the metadata silently gone.
func TestGetRegistryInfo_BadCustomRegistryIsReported(t *testing.T) {
missing := filepath.Join(t.TempDir(), "does-not-exist.json")
for _, mode := range []string{"direct-fetch", "nix-build"} {
t.Run(mode, func(t *testing.T) {
resp, body := callRegistryInfo(t, "?registry_mode="+mode+"&registry_url="+missing)
assert.Equal(t, 500, resp.StatusCode)
assert.Equal(t, true, body["error"])
})
}
}