| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161 |
- package api
- import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "path/filepath"
- "strings"
- "testing"
- "proxmox-router/internal/aerotun"
- "proxmox-router/internal/auth"
- "proxmox-router/internal/vpn"
- )
- func aeroRequest(method, path, body string, role auth.Role) *http.Request {
- r := httptest.NewRequest(method, path, strings.NewReader(body))
- return r.WithContext(context.WithValue(r.Context(), sessionContextKey, &auth.Session{Role: role}))
- }
- func TestAeroTUNSecretRedactionAndEdit(t *testing.T) {
- m := aerotun.NewManagerWithPath(filepath.Join(t.TempDir(), "aerotun.json"))
- defer m.StopAll()
- key := strings.Repeat("12", 32)
- saved, err := m.Save(aerotun.TunnelInstance{Name: "atest", Mode: "server", LocalAddress: "10.99.0.1/24", PSK: key, Disabled: true})
- if err != nil {
- t.Fatal(err)
- }
- s := &Server{vpnMgr: &vpn.Manager{AeroTUN: m}}
- w := httptest.NewRecorder()
- s.handleListAeroTUNTunnels(w, aeroRequest("GET", "/api/vpn/aerotun/tunnels", "", auth.RoleRead))
- if strings.Contains(w.Body.String(), key) || strings.Contains(w.Body.String(), `"psk":`) {
- t.Fatal("list disclosed PSK")
- }
- var views []aerotun.TunnelInstance
- if err = json.Unmarshal(w.Body.Bytes(), &views); err != nil || !views[0].HasPSK {
- t.Fatal("missing safe key presence indicator", err)
- }
- saved.PSK = ""
- saved.Comment = "edited without revealing secret"
- body, _ := json.Marshal(saved)
- w = httptest.NewRecorder()
- s.handleSaveAeroTUNTunnel(w, aeroRequest("POST", "/api/vpn/aerotun/tunnels", string(body), auth.RoleFull))
- if w.Code != 200 || strings.Contains(w.Body.String(), key) {
- t.Fatalf("save failed or disclosed PSK: %d", w.Code)
- }
- got, _ := m.Get(saved.ID)
- if got.PSK != key {
- t.Fatal("redacted edit erased the stored key")
- }
- for _, role := range []auth.Role{auth.RoleRead, auth.RoleWrite} {
- w = httptest.NewRecorder()
- s.handleSaveAeroTUNTunnel(w, aeroRequest("POST", "/api/vpn/aerotun/tunnels", string(body), role))
- if w.Code != 403 {
- t.Fatal("non-administrator modified tunnel")
- }
- w = httptest.NewRecorder()
- s.handleGenerateAeroTUNKey(w, aeroRequest("GET", "/api/vpn/aerotun/genkey", "", role))
- if w.Code != 403 {
- t.Fatal("non-administrator generated key")
- }
- }
- }
- func TestAeroTUNPrivateIdentityRedacted(t *testing.T) {
- private, public, err := aerotun.GenerateIdentity()
- if err != nil {
- t.Fatal(err)
- }
- view := aerotun.TunnelInstance{PSK: strings.Repeat("ab", 32), PrivateKey: private, PublicKey: public}.PublicView()
- encoded, _ := json.Marshal(view)
- if strings.Contains(string(encoded), private) || !view.HasPrivateKey || view.PublicKey != public {
- t.Fatal("private identity exposed or public identity lost")
- }
- }
- func TestAeroTUNHealthAPIDoesNotDiscloseKeysAndRestrictsProbe(t *testing.T) {
- m := aerotun.NewManagerWithPath(filepath.Join(t.TempDir(), "aerotun.json"))
- defer m.StopAll()
- key := strings.Repeat("ab", 32)
- saved, err := m.Save(aerotun.TunnelInstance{Name: "health0", Mode: "server", LocalAddress: "10.99.0.1/24", PSK: key, Disabled: true})
- if err != nil {
- t.Fatal(err)
- }
- s := &Server{vpnMgr: &vpn.Manager{AeroTUN: m}}
- w := httptest.NewRecorder()
- s.handleAeroTUNHealth(w, aeroRequest("GET", "/", "", auth.RoleRead))
- if w.Code != 200 || strings.Contains(w.Body.String(), key) || strings.Contains(w.Body.String(), `"private_key"`) {
- t.Fatalf("unsafe health response: %d", w.Code)
- }
- r := aeroRequest("POST", "/", "", auth.RoleRead)
- r.SetPathValue("id", saved.ID)
- w = httptest.NewRecorder()
- s.handleAeroTUNHealthCheck(w, r)
- if w.Code != http.StatusForbidden {
- t.Fatalf("read-only user triggered probe: %d", w.Code)
- }
- r = aeroRequest("POST", "/", "", auth.RoleFull)
- r.SetPathValue("id", saved.ID)
- w = httptest.NewRecorder()
- s.handleAeroTUNHealthCheck(w, r)
- if w.Code != http.StatusUnprocessableEntity {
- t.Fatalf("disabled tunnel probe accepted: %d", w.Code)
- }
- }
- func TestAeroTUNDraftIdentityIsSessionBoundAndSaved(t *testing.T) {
- m := aerotun.NewManagerWithPath(filepath.Join(t.TempDir(), "aerotun.json"))
- defer m.StopAll()
- s := &Server{vpnMgr: &vpn.Manager{AeroTUN: m}}
- request := func(method, path, body, session string, role auth.Role) *http.Request {
- r := httptest.NewRequest(method, path, strings.NewReader(body))
- return r.WithContext(context.WithValue(r.Context(), sessionContextKey, &auth.Session{Role: role, Token: session}))
- }
- w := httptest.NewRecorder()
- s.handleCreateAeroIdentityDraft(w, request("POST", "/", "", "owner", auth.RoleRead))
- if w.Code != http.StatusForbidden {
- t.Fatal("read-only user generated an identity")
- }
- w = httptest.NewRecorder()
- s.handleCreateAeroIdentityDraft(w, request("POST", "/", "", "owner", auth.RoleFull))
- var draft struct {
- ID string `json:"draft_id"`
- Public string `json:"public_key"`
- }
- if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &draft) != nil || draft.ID == "" || draft.Public == "" || strings.Contains(w.Body.String(), "private") || w.Header().Get("Cache-Control") != "no-store" {
- t.Fatal("invalid or unsafe identity response")
- }
- payload := map[string]any{"identity_draft_id": draft.ID, "protocol_version": 3, "name": "atest", "mode": "server", "local_address": "10.99.0.1/24", "allowed_ips": []string{"10.99.0.2/32"}, "psk": strings.Repeat("12", 32), "disabled": true}
- body, _ := json.Marshal(payload)
- w = httptest.NewRecorder()
- s.handleSaveAeroTUNTunnel(w, request("POST", "/", string(body), "other", auth.RoleFull))
- if w.Code != 422 {
- t.Fatalf("other session used identity: %d", w.Code)
- }
- bad := make(map[string]any)
- for k, v := range payload {
- bad[k] = v
- }
- bad["local_address"] = "invalid"
- badBody, _ := json.Marshal(bad)
- w = httptest.NewRecorder()
- s.handleSaveAeroTUNTunnel(w, request("POST", "/", string(badBody), "owner", auth.RoleFull))
- if w.Code == 200 {
- t.Fatal("invalid draft configuration was saved")
- }
- w = httptest.NewRecorder()
- s.handleSaveAeroTUNTunnel(w, request("POST", "/", string(body), "owner", auth.RoleFull))
- var saved aerotun.TunnelInstance
- if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &saved) != nil || saved.PublicKey != draft.Public || strings.Contains(w.Body.String(), `"private_key":`) {
- t.Fatalf("identity not preserved on save: %d", w.Code)
- }
- stored, ok := m.Get(saved.ID)
- if !ok || stored.PublicKey != draft.Public || stored.PrivateKey == "" {
- t.Fatal("identity was not stored")
- }
- w = httptest.NewRecorder()
- s.handleSaveAeroTUNTunnel(w, request("POST", "/", string(body), "owner", auth.RoleFull))
- if w.Code != 422 {
- t.Fatalf("identity draft reused: %d", w.Code)
- }
- }
|