198 lines
8.1 KiB
Go
198 lines
8.1 KiB
Go
package identityprovider
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"vpn-platform/backend/internal/store"
|
|
)
|
|
|
|
func TestQueryTenantUsesAppCredentialsWithoutPersistingOrLeaking(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("VPN_DATA_DIR", dir)
|
|
if err := store.Init(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
previous := upstreamClient
|
|
defer func() { upstreamClient = previous }()
|
|
upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
|
|
response := `{"code":0,"tenant_access_token":"temporary-token"}`
|
|
switch req.URL.String() {
|
|
case feishuTenantToken:
|
|
if req.Method != http.MethodPost || req.Header.Get("Content-Type") != "application/json" {
|
|
t.Fatal("invalid token request")
|
|
}
|
|
var creds tenantCredentials
|
|
if json.NewDecoder(req.Body).Decode(&creds) != nil || creds.AppID != "cli_test" || creds.AppSecret != "private-value" {
|
|
t.Fatal("invalid credentials")
|
|
}
|
|
case feishuTenantQuery:
|
|
if req.Method != http.MethodGet || req.Header.Get("Authorization") != "Bearer temporary-token" {
|
|
t.Fatal("invalid tenant request")
|
|
}
|
|
response = `{"code":0,"data":{"tenant":{"tenant_key":"tenant_123","name":"Example"}}}`
|
|
default:
|
|
t.Fatalf("unexpected endpoint: %s", req.URL)
|
|
}
|
|
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(response)), Header: make(http.Header)}, nil
|
|
})}
|
|
gin.SetMode(gin.TestMode)
|
|
server := gin.New()
|
|
p := New()
|
|
server.POST("/tenant", p.QueryTenant)
|
|
server.PUT("/config", p.Configure)
|
|
query := []byte(`{"app_id":"cli_test","app_secret":"private-value"}`)
|
|
request := httptest.NewRequest(http.MethodPost, "/tenant", bytes.NewReader(query))
|
|
request.RemoteAddr = "127.0.0.1:12345"
|
|
request.Header.Set("X-Forwarded-Proto", "https")
|
|
resp := httptest.NewRecorder()
|
|
server.ServeHTTP(resp, request)
|
|
if resp.Code != 200 || !strings.Contains(resp.Body.String(), `"tenant_key":"tenant_123"`) || strings.Contains(resp.Body.String(), "temporary-token") || strings.Contains(resp.Body.String(), "private-value") {
|
|
t.Fatalf("unexpected query response: %d %s", resp.Code, resp.Body.String())
|
|
}
|
|
if _, err := current(); err == nil {
|
|
t.Fatal("query unexpectedly persisted configuration")
|
|
}
|
|
request = httptest.NewRequest(http.MethodPut, "/config", strings.NewReader(`{"app_id":"cli_test","app_secret":"private-value","enabled":false}`))
|
|
request.RemoteAddr = "127.0.0.1:12345"
|
|
request.Header.Set("X-Forwarded-Proto", "https")
|
|
resp = httptest.NewRecorder()
|
|
server.ServeHTTP(resp, request)
|
|
if resp.Code != 200 {
|
|
t.Fatalf("save failed: %d %s", resp.Code, resp.Body.String())
|
|
}
|
|
request = httptest.NewRequest(http.MethodPost, "/tenant", strings.NewReader(`{"app_id":"cli_test"}`))
|
|
request.RemoteAddr = "127.0.0.1:12345"
|
|
request.Header.Set("X-Forwarded-Proto", "https")
|
|
resp = httptest.NewRecorder()
|
|
server.ServeHTTP(resp, request)
|
|
if resp.Code != 200 {
|
|
t.Fatalf("saved credentials not reused: %d %s", resp.Code, resp.Body.String())
|
|
}
|
|
request = httptest.NewRequest(http.MethodPut, "/config", strings.NewReader(`{"app_id":"cli_other","tenant_key":"tenant_123","enabled":false}`))
|
|
request.RemoteAddr = "127.0.0.1:12345"
|
|
request.Header.Set("X-Forwarded-Proto", "https")
|
|
resp = httptest.NewRecorder()
|
|
server.ServeHTTP(resp, request)
|
|
if resp.Code != 400 {
|
|
t.Fatalf("new app reused old secret: %d %s", resp.Code, resp.Body.String())
|
|
}
|
|
cfg, err := current()
|
|
if err != nil || cfg.AppID != "cli_test" {
|
|
t.Fatal("rejected app change modified saved configuration")
|
|
}
|
|
request = httptest.NewRequest(http.MethodPost, "/tenant", bytes.NewReader(query))
|
|
resp = httptest.NewRecorder()
|
|
server.ServeHTTP(resp, request)
|
|
if resp.Code != 403 {
|
|
t.Fatalf("plaintext request accepted: %d", resp.Code)
|
|
}
|
|
}
|
|
|
|
func TestQueryTenantRejectsWrongAppAndUpstreamFailure(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("VPN_DATA_DIR", dir)
|
|
if err := store.Init(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
secret, err := store.EncryptSecret("private-value")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := resolveTenantCredentials(tenantCredentials{AppID: "cli_other"}, "cli_test", secret); err == nil {
|
|
t.Fatal("old secret reused for another app")
|
|
}
|
|
creds, err := resolveTenantCredentials(tenantCredentials{AppID: "cli_test"}, "cli_test", secret)
|
|
if err != nil || creds.AppSecret != "private-value" {
|
|
t.Fatal("saved secret not reused for same app")
|
|
}
|
|
previous := upstreamClient
|
|
defer func() { upstreamClient = previous }()
|
|
upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":999,"msg":"private-value"}`)), Header: make(http.Header)}, nil
|
|
})}
|
|
_, _, err = queryFeishuTenant(httptest.NewRequest(http.MethodGet, "/", nil).Context(), creds)
|
|
if err == nil || strings.Contains(err.Error(), "private-value") {
|
|
t.Fatalf("unsafe upstream error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTenantErrorsIdentifyStageWithoutLeakingFeishuMessage(t *testing.T) {
|
|
previous := upstreamClient
|
|
defer func() { upstreamClient = previous }()
|
|
for _, tc := range []struct {
|
|
name, endpoint, payload, want string
|
|
status int
|
|
}{
|
|
{"tenant denied", feishuTenantQuery, `{"code":99991672,"msg":"secret in upstream error"}`, "99991672", 403},
|
|
{"unexpected tenant endpoint", feishuTenantQuery, `not json`, "HTTP 404", 404},
|
|
{"token denied", feishuTenantToken, `{"code":10003,"msg":"secret in upstream error"}`, "10003", 401},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
upstreamClient = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
|
|
body := `{"code":0,"tenant_access_token":"temporary-token"}`
|
|
status := 200
|
|
if req.URL.String() == tc.endpoint {
|
|
body, status = tc.payload, tc.status
|
|
}
|
|
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
|
})}
|
|
_, _, err := queryFeishuTenant(context.Background(), tenantCredentials{AppID: "cli_test", AppSecret: "private-value"})
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) || strings.Contains(err.Error(), "secret in upstream error") || strings.Contains(err.Error(), "private-value") {
|
|
t.Fatalf("unsafe or unhelpful error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAdminNeverEchoesFeishuSecretAndKeepsEncryptedValue(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("VPN_DATA_DIR", dir)
|
|
if err := store.Init(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gin.SetMode(gin.TestMode)
|
|
server := gin.New()
|
|
p := New()
|
|
server.PUT("/config", func(c *gin.Context) { c.Set("username", "admin"); p.Configure(c) })
|
|
server.GET("/config", p.Status)
|
|
body := []byte(`{"app_id":"cli_test","app_secret":"feishu-private-value","tenant_key":"tenant_1","enabled":false}`)
|
|
resp := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodPut, "/config", bytes.NewReader(body))
|
|
request.RemoteAddr = "127.0.0.1:12345"
|
|
request.Header.Set("X-Forwarded-Proto", "https")
|
|
server.ServeHTTP(resp, request)
|
|
if resp.Code != 200 || strings.Contains(resp.Body.String(), "feishu-private-value") {
|
|
t.Fatalf("unsafe response: %d %s", resp.Code, resp.Body.String())
|
|
}
|
|
rejected := httptest.NewRecorder()
|
|
server.ServeHTTP(rejected, httptest.NewRequest(http.MethodPut, "/config", bytes.NewReader(body)))
|
|
if rejected.Code != http.StatusForbidden {
|
|
t.Fatal("plaintext management request accepted credentials")
|
|
}
|
|
cfg, err := current()
|
|
if err != nil || cfg.AppSecret == "feishu-private-value" {
|
|
t.Fatal("app secret stored in plaintext")
|
|
}
|
|
plain, err := store.DecryptSecret(cfg.AppSecret)
|
|
if err != nil || plain != "feishu-private-value" {
|
|
t.Fatal("app secret not recoverable")
|
|
}
|
|
var saved map[string]any
|
|
if json.Unmarshal(resp.Body.Bytes(), &saved) != nil || saved["client_id"] == "" || saved["client_secret"] != nil {
|
|
t.Fatal("client secret must remain server-side")
|
|
}
|
|
resp = httptest.NewRecorder()
|
|
server.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/config", nil))
|
|
if resp.Code != 200 || strings.Contains(resp.Body.String(), "feishu-private-value") || strings.Contains(resp.Body.String(), cfg.ClientSecret) {
|
|
t.Fatal("secret leaked from status")
|
|
}
|
|
}
|