mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-09-10 19:57:47 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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+"®istry_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®istry_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+"®istry_url="+missing)
|
||||
assert.Equal(t, 500, resp.StatusCode)
|
||||
assert.Equal(t, true, body["error"])
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user