|
|
|
|
package handlers
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"net/url"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"teraclone/internal/service"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestLoginCaptchaIsPublicPeerBoundAndForwardedWithCredentials(t *testing.T) {
|
|
|
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Header.Get("Cookie") != "" || r.Header.Get("Authorization") != "" {
|
|
|
|
|
t.Error("credentials leaked through public login request")
|
|
|
|
|
}
|
|
|
|
|
switch r.URL.Path {
|
|
|
|
|
case "/api/v1/auth/startup":
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ready": true, "state": "ready"})
|
|
|
|
|
case "/api/v1/auth/login-policy":
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]bool{"captcha_enabled": true})
|
|
|
|
|
case "/api/v1/auth/captcha":
|
|
|
|
|
if r.Header.Get("X-Forwarded-For") != "192.0.2.44" {
|
|
|
|
|
t.Errorf("challenge peer = %q", r.Header.Get("X-Forwarded-For"))
|
|
|
|
|
}
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"challenge_id": "test-challenge", "image_data_url": "data:image/png;base64,test", "expires_at": "2026-09-07T12:00:00Z"})
|
|
|
|
|
case "/api/v1/auth/login":
|
|
|
|
|
var input map[string]string
|
|
|
|
|
_ = json.NewDecoder(r.Body).Decode(&input)
|
|
|
|
|
if r.Header.Get("X-Forwarded-For") != "192.0.2.44" {
|
|
|
|
|
t.Error("login and challenge peers differ")
|
|
|
|
|
}
|
|
|
|
|
if input["browser_time"] != "2026-09-08T03:00:00Z" {
|
|
|
|
|
t.Error("browser time was not forwarded")
|
|
|
|
|
}
|
|
|
|
|
if input["captcha_id"] != "test-challenge" || input["captcha_answer"] != "23456789" {
|
|
|
|
|
w.WriteHeader(401)
|
|
|
|
|
_, _ = w.Write([]byte(`{"error":{"code":"captcha_failed","message":"login verification failed"}}`))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"actor": "admin", "role": "administrator", "session_token": "private-token"})
|
|
|
|
|
default:
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
defer backend.Close()
|
|
|
|
|
app, err := service.NewAppService(filepath.Join(t.TempDir(), "frontend.db"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
defer app.Close()
|
|
|
|
|
handler, err := NewDeviceHandler(app, backend.URL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
handler.RegisterRoutes(mux)
|
|
|
|
|
for _, path := range []string{"/login/backend-status", "/login/captcha"} {
|
|
|
|
|
method := http.MethodGet
|
|
|
|
|
if path == "/login/captcha" {
|
|
|
|
|
method = http.MethodPost
|
|
|
|
|
}
|
|
|
|
|
req := httptest.NewRequest(method, path, nil)
|
|
|
|
|
req.RemoteAddr = "192.0.2.44:45000"
|
|
|
|
|
req.Header.Set("X-Forwarded-For", "198.51.100.99")
|
|
|
|
|
req.Header.Set("Authorization", "Bearer untrusted")
|
|
|
|
|
req.Header.Set("Cookie", "untrusted=yes")
|
|
|
|
|
res := httptest.NewRecorder()
|
|
|
|
|
mux.ServeHTTP(res, req)
|
|
|
|
|
if res.Code != 200 || res.Header().Get("Cache-Control") != "no-store" {
|
|
|
|
|
t.Fatalf("public endpoint = %d: %s", res.Code, res.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if path == "/login/backend-status" && !strings.Contains(res.Body.String(), `"captcha_enabled":true`) {
|
|
|
|
|
t.Fatal("CAPTCHA policy lost")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, answer := range []string{"wrong", "23456789"} {
|
|
|
|
|
body := url.Values{"username": {"admin"}, "password": {"test-password"}, "captcha_id": {"test-challenge"}, "captcha_answer": {answer}, "browser_time": {"2026-09-08T03:00:00Z"}}
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(body.Encode()))
|
|
|
|
|
req.RemoteAddr = "192.0.2.44:45001"
|
|
|
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
|
res := httptest.NewRecorder()
|
|
|
|
|
mux.ServeHTTP(res, req)
|
|
|
|
|
if answer == "wrong" {
|
|
|
|
|
if len(res.Result().Cookies()) != 0 || !strings.Contains(res.Body.String(), "틀렸거나 만료") {
|
|
|
|
|
t.Fatal("CAPTCHA failure did not remain on login")
|
|
|
|
|
}
|
|
|
|
|
} else if res.Code != 303 || len(res.Result().Cookies()) == 0 {
|
|
|
|
|
t.Fatalf("valid challenge login = %d", res.Code)
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(res.Body.String(), "test-password") || strings.Contains(res.Body.String(), "private-token") {
|
|
|
|
|
t.Fatal("login response leaked secret")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestFactoryResetPageRequiresAdministratorAndExplainsScope(t *testing.T) {
|
|
|
|
|
app := service.NewStatelessAppService()
|
|
|
|
|
defer app.Close()
|
|
|
|
|
|
|
|
|
|
handler, err := NewDeviceHandler(app, "http://127.0.0.1:1")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
handler.RegisterRoutes(mux)
|
|
|
|
|
for _, role := range []string{"administrator", "port_operator"} {
|
|
|
|
|
handler.sessions[role] = browserSession{Username: "test", BackendToken: "test", Role: role}
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/device/factory-reset", nil)
|
|
|
|
|
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: role})
|
|
|
|
|
res := httptest.NewRecorder()
|
|
|
|
|
mux.ServeHTTP(res, req)
|
|
|
|
|
if role == "port_operator" {
|
|
|
|
|
if res.Code != 403 {
|
|
|
|
|
t.Fatal("operator can open factory reset")
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for _, expected := range []string{"공장 초기화", `name="password"`, "terauser / terauser", "자동 재부팅됩니다"} {
|
|
|
|
|
if !strings.Contains(res.Body.String(), expected) {
|
|
|
|
|
t.Errorf("missing reset warning: %s", expected)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(res.Body.String(), `href="/ports/restart"`) {
|
|
|
|
|
t.Fatal("old restart sidebar entry still present")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|