270 lines
11 KiB
Go
270 lines
11 KiB
Go
package module
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/tetratelabs/wazero"
|
|
)
|
|
|
|
func sourceRCONPacket(id, typ uint32, body string) []byte {
|
|
value := make([]byte, len(body)+14)
|
|
binary.LittleEndian.PutUint32(value[:4], uint32(len(body)+10))
|
|
binary.LittleEndian.PutUint32(value[4:8], id)
|
|
binary.LittleEndian.PutUint32(value[8:12], typ)
|
|
copy(value[12:], body)
|
|
return value
|
|
}
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
|
|
|
|
func TestValidRelativeAPIPath(t *testing.T) {
|
|
for _, value := range []string{"http://metadata/v1/api/info", "//other/v1/api/info", "/v1/api/../secret", "/v1/api/info?q=1", "/etc/passwd"} {
|
|
if validRelativeAPIPath(value) {
|
|
t.Fatalf("unsafe path accepted: %q", value)
|
|
}
|
|
}
|
|
if !validRelativeAPIPath("/v1/api/info") {
|
|
t.Fatal("documented Palworld endpoint was rejected")
|
|
}
|
|
}
|
|
|
|
func TestPalworldAdapterReportsBoundedFailures(t *testing.T) {
|
|
wasm, err := os.ReadFile("../../catalog/palworld/module/module.wasm")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
digest := sha256.Sum256(wasm)
|
|
tests := []struct {
|
|
name string
|
|
transport roundTripFunc
|
|
code string
|
|
}{
|
|
{name: "unauthorized", code: "unauthorized", transport: func(request *http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: http.StatusUnauthorized, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("unauthorized")), Request: request}, nil
|
|
}},
|
|
{name: "malformed", code: "invalid_response", transport: func(request *http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not-json")), Request: request}, nil
|
|
}},
|
|
{name: "offline", code: "unreachable", transport: func(*http.Request) (*http.Response, error) {
|
|
return nil, errors.New("offline")
|
|
}},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
runtime, err := newWithTransport(wasm, hex.EncodeToString(digest[:]), []string{"server_info"}, Limits{MemoryMB: 32, Timeout: 10 * time.Second, MaxResponseBytes: 1 << 20, MaxConcurrentCall: 2}, Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 8212, AllowedMethods: map[string]bool{"GET": true}, Configuration: map[string]string{"username": "admin"}, Secrets: map[string]string{"admin_password": "secret"}}, test.transport)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var response struct {
|
|
OK bool `json:"ok"`
|
|
Data map[string]any `json:"data"`
|
|
Error *struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Retryable bool `json:"retryable"`
|
|
} `json:"error"`
|
|
}
|
|
if err := runtime.Call(context.Background(), "get_server_info", struct{}{}, &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if response.OK || response.Error == nil || response.Error.Code != test.code || strings.Contains(response.Error.Message, "secret") {
|
|
t.Fatalf("unexpected safe failure: %+v", response)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPalworldAdapterExecutesInSandbox(t *testing.T) {
|
|
wasm, err := os.ReadFile("../../catalog/palworld/module/module.wasm")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
digest := sha256.Sum256(wasm)
|
|
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
|
if request.URL.Path != "/v1/api/info" || request.Header.Get("Authorization") == "" {
|
|
t.Fatalf("unexpected adapter request: %s", request.URL)
|
|
}
|
|
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"version":"v1","servername":"test","description":"fixture","worldguid":"world"}`)), Request: request}, nil
|
|
})
|
|
runtime, err := newWithTransport(wasm, hex.EncodeToString(digest[:]), []string{"server_info"}, Limits{MemoryMB: 32, Timeout: 10 * time.Second, MaxResponseBytes: 1 << 20, MaxConcurrentCall: 2}, Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 8212, AllowedMethods: map[string]bool{"GET": true, "POST": true}, Configuration: map[string]string{"username": "admin"}, Secrets: map[string]string{"admin_password": "secret"}}, transport)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var response struct {
|
|
OK bool `json:"ok"`
|
|
Error any `json:"error"`
|
|
Data struct {
|
|
Name string `json:"name"`
|
|
GameVersion string `json:"game_version"`
|
|
Description string `json:"description"`
|
|
WorldID string `json:"world_id"`
|
|
} `json:"data"`
|
|
}
|
|
if err := runtime.Call(context.Background(), "get_server_info", struct{}{}, &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !response.OK || response.Data.Name != "test" {
|
|
t.Fatalf("unexpected normalized response: %+v", response)
|
|
}
|
|
}
|
|
|
|
func TestNewRejectsChecksumAndUnknownCapability(t *testing.T) {
|
|
limits := Limits{MemoryMB: 32, Timeout: time.Second, MaxResponseBytes: 1024, MaxConcurrentCall: 1}
|
|
binding := Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 8212}
|
|
if _, err := newWithTransport([]byte("wasm"), strings.Repeat("0", 64), nil, limits, binding, roundTripFunc(nil)); err == nil {
|
|
t.Fatal("checksum mismatch accepted")
|
|
}
|
|
digest := sha256.Sum256([]byte("wasm"))
|
|
if _, err := newWithTransport([]byte("wasm"), hex.EncodeToString(digest[:]), []string{"shell"}, limits, binding, roundTripFunc(nil)); err == nil {
|
|
t.Fatal("unknown capability accepted")
|
|
}
|
|
}
|
|
|
|
func TestHTTPRequestPinsInstanceOriginAndDisablesRedirects(t *testing.T) {
|
|
instanceID := strings.Repeat("A", 20)
|
|
seen := false
|
|
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
|
seen = true
|
|
if request.URL.String() != "http://dogama-aaaaaaaaaaaaaaaaaaaa:8212/v1/api/info" {
|
|
t.Fatalf("unexpected destination %q", request.URL)
|
|
}
|
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), Request: request}, nil
|
|
})
|
|
digest := sha256.Sum256([]byte("wasm"))
|
|
runtime, err := newWithTransport([]byte("wasm"), hex.EncodeToString(digest[:]), nil, Limits{MemoryMB: 32, Timeout: time.Second, MaxResponseBytes: 1024, MaxConcurrentCall: 1}, Binding{InstanceID: instanceID, ContainerPort: 8212, AllowedMethods: map[string]bool{"GET": true}}, transport)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://dogama-aaaaaaaaaaaaaaaaaaaa:8212/v1/api/info", nil)
|
|
response, err := runtime.client.Do(request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = response.Body.Close()
|
|
if !seen {
|
|
t.Fatal("bound transport was not used")
|
|
}
|
|
}
|
|
|
|
func TestVRisingAdapterUsesPinnedBoundedTCPRCON(t *testing.T) {
|
|
wasm, err := os.ReadFile("../../catalog/vrising/module/module.wasm")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
digest := sha256.Sum256(wasm)
|
|
var seen []byte
|
|
dial := func(context.Context, string, string) (net.Conn, error) {
|
|
client, server := net.Pipe()
|
|
go func() {
|
|
defer server.Close()
|
|
buffer := make([]byte, 4096)
|
|
n, readErr := server.Read(buffer)
|
|
if readErr != nil {
|
|
return
|
|
}
|
|
seen = append([]byte(nil), buffer[:n]...)
|
|
_, _ = server.Write(sourceRCONPacket(1, 2, ""))
|
|
}()
|
|
return client, nil
|
|
}
|
|
runtime, err := newWithTransportAndDial(wasm, hex.EncodeToString(digest[:]), nil, Limits{MemoryMB: 64, Timeout: 10 * time.Second, MaxResponseBytes: 262144, MaxConcurrentCall: 2}, Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 9878, Configuration: map[string]string{"rcon_enabled": "true", "rcon_port": "9878"}, Secrets: map[string]string{"rcon_password": "not-in-output"}}, roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("HTTP must not be used") }), dial)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var response struct {
|
|
OK bool `json:"ok"`
|
|
Data any `json:"data"`
|
|
Error any `json:"error"`
|
|
}
|
|
if err := runtime.Call(context.Background(), "test_connection", struct{}{}, &response); err != nil || !response.OK {
|
|
t.Fatalf("connection = %#v, %v", response, err)
|
|
}
|
|
if len(seen) < 14 || binary.LittleEndian.Uint32(seen[8:12]) != 3 {
|
|
t.Fatalf("unexpected RCON auth packet: %x", seen)
|
|
}
|
|
}
|
|
|
|
func TestVRisingAdapterRejectsAuthenticationWithoutLeakingSecret(t *testing.T) {
|
|
wasm, err := os.ReadFile("../../catalog/vrising/module/module.wasm")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
digest := sha256.Sum256(wasm)
|
|
dial := func(context.Context, string, string) (net.Conn, error) {
|
|
client, server := net.Pipe()
|
|
go func() {
|
|
defer server.Close()
|
|
buffer := make([]byte, 256)
|
|
_, _ = server.Read(buffer)
|
|
_, _ = server.Write(sourceRCONPacket(^uint32(0), 2, ""))
|
|
}()
|
|
return client, nil
|
|
}
|
|
runtime, err := newWithTransportAndDial(wasm, hex.EncodeToString(digest[:]), nil, Limits{MemoryMB: 32, Timeout: 10 * time.Second, MaxResponseBytes: 262144, MaxConcurrentCall: 1}, Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 9878, Configuration: map[string]string{"rcon_enabled": "true", "rcon_port": "9878"}, Secrets: map[string]string{"rcon_password": "very-secret-value"}}, roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("HTTP must not be used") }), dial)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var response struct {
|
|
OK bool `json:"ok"`
|
|
Data any `json:"data"`
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Retryable bool `json:"retryable"`
|
|
} `json:"error"`
|
|
}
|
|
if err := runtime.Call(context.Background(), "test_connection", struct{}{}, &response); err != nil || response.OK || response.Error.Code != "unauthorized" || strings.Contains(response.Error.Message, "very-secret-value") {
|
|
t.Fatalf("unsafe authentication response: %#v, %v", response, err)
|
|
}
|
|
}
|
|
|
|
func TestVRisingAuthSuccessWithBothWazeroEngines(t *testing.T) {
|
|
wasm, err := os.ReadFile("../../catalog/vrising/module/module.wasm")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
digest := sha256.Sum256(wasm)
|
|
dial := func(context.Context, string, string) (net.Conn, error) {
|
|
client, server := net.Pipe()
|
|
go func() {
|
|
defer server.Close()
|
|
buffer := make([]byte, 256)
|
|
if _, err := server.Read(buffer); err == nil {
|
|
_, _ = server.Write(sourceRCONPacket(1, 2, ""))
|
|
}
|
|
}()
|
|
return client, nil
|
|
}
|
|
runtime, err := newWithTransportAndDial(wasm, hex.EncodeToString(digest[:]), nil, Limits{MemoryMB: 32, Timeout: 10 * time.Second, MaxResponseBytes: 262144, MaxConcurrentCall: 1}, Binding{InstanceID: strings.Repeat("a", 20), ContainerPort: 9878, Configuration: map[string]string{"rcon_enabled": "true", "rcon_port": "9878"}, Secrets: map[string]string{"rcon_password": "secret"}}, roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("HTTP must not be used") }), dial)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, engine := range []struct {
|
|
name string
|
|
config wazero.RuntimeConfig
|
|
}{
|
|
{name: "compiler", config: wazero.NewRuntimeConfigCompiler()},
|
|
{name: "interpreter", config: wazero.NewRuntimeConfigInterpreter()},
|
|
} {
|
|
t.Run(engine.name, func(t *testing.T) {
|
|
result, err := runtime.invokeWithConfig(context.Background(), "test_connection", []byte("{}"), engine.config)
|
|
if err != nil || !strings.Contains(string(result), `"connected":true`) {
|
|
t.Fatalf("auth success result=%s err=%v", result, err)
|
|
}
|
|
})
|
|
}
|
|
}
|