@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -72,6 +73,7 @@ func (a *app) routes() http.Handler {
|
||||
mux.HandleFunc("GET /api/staging", a.handleStagingList)
|
||||
mux.HandleFunc("GET /api/staging/{key}", a.handleStagingGet)
|
||||
mux.HandleFunc("POST /api/integrations/staging", a.handleIntegrationStaging)
|
||||
mux.HandleFunc("GET /api/integrations/staging/health", a.handleIntegrationStagingHealth)
|
||||
|
||||
if a.config.Writable {
|
||||
mux.HandleFunc("PUT /api/items/{key}", a.handlePut)
|
||||
@@ -93,7 +95,30 @@ func (a *app) routes() http.Handler {
|
||||
|
||||
static := http.FileServer(http.FS(a.web))
|
||||
mux.Handle("GET /", static)
|
||||
return securityHeaders(mux)
|
||||
return securityHeaders(browserWriteSameOrigin(mux))
|
||||
}
|
||||
|
||||
func browserWriteSameOrigin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions || r.URL.Path == "/api/integrations/staging" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
fetchSite := strings.ToLower(strings.TrimSpace(r.Header.Get("Sec-Fetch-Site")))
|
||||
if fetchSite != "" && fetchSite != "same-origin" && fetchSite != "none" {
|
||||
writeError(w, http.StatusForbidden, "cross-origin browser write blocked")
|
||||
return
|
||||
}
|
||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||
if origin != "" {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || !strings.EqualFold(u.Host, r.Host) {
|
||||
writeError(w, http.StatusForbidden, "cross-origin browser write blocked")
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
@@ -265,6 +290,19 @@ func integrationBearerAuthorized(r *http.Request) (bool, bool) {
|
||||
return true, subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
|
||||
}
|
||||
|
||||
func (a *app) handleIntegrationStagingHealth(w http.ResponseWriter, r *http.Request) {
|
||||
enabled, authorized := integrationBearerAuthorized(r)
|
||||
if !enabled || a.staging == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "KB staging integration is disabled")
|
||||
return
|
||||
}
|
||||
if !authorized {
|
||||
writeError(w, http.StatusUnauthorized, "invalid integration token")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "staging": true, "writable": a.config.Writable})
|
||||
}
|
||||
|
||||
// handleIntegrationStaging is a one-way governance boundary: machine-generated
|
||||
// research may enter human review, but it cannot write production knowledge or
|
||||
// enable automatic replies.
|
||||
@@ -485,7 +523,11 @@ func (a *app) promoteStaging(key string) (map[string]any, error) {
|
||||
}
|
||||
archive, err := a.staging.ArchiveApproved(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Produktivdatei wurde erstellt (%s), aber Staging konnte nicht als freigegeben archiviert werden: %w", summary.RelPath, err)
|
||||
rollbackErr := a.store.RollbackImported(summary)
|
||||
if rollbackErr != nil {
|
||||
return nil, fmt.Errorf("staging archive failed after production import (%s); rollback also failed: archive=%v rollback=%v", summary.RelPath, err, rollbackErr)
|
||||
}
|
||||
return nil, fmt.Errorf("staging archive failed; production import %s was rolled back: %w", summary.RelPath, err)
|
||||
}
|
||||
return map[string]any{"ok": true, "production": summary, "staging_key": key, "staging_archive": archive}, nil
|
||||
}
|
||||
|
||||
@@ -459,3 +459,93 @@ func TestIntegrationDraftWithStableKeyUpdatesInsteadOfDuplicating(t *testing.T)
|
||||
t.Fatalf("draft not refreshed: %#v", got.Document)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserWriteSameOriginGuardRejectsCrossSiteWrite(t *testing.T) {
|
||||
s, err := store.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
web, err := fs.Sub(webFS, "web")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := newApp(s, web, appConfig{Mode: "editor", Writable: true}).routes()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/bulk", bytes.NewBufferString(`{"keys":[],"dry_run":true}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
req.Header.Set("Origin", "https://evil.invalid")
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromotionRollsBackProductionWhenStagingArchiveFails(t *testing.T) {
|
||||
knowledgeDir := t.TempDir()
|
||||
stagingDir := t.TempDir()
|
||||
t.Setenv("BACKUP_DIR", filepath.Join(t.TempDir(), "backups"))
|
||||
s, err := store.New(knowledgeDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := staging.New(stagingDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
draft, err := st.Save("rollback", "test", staging.Draft{Title: "Rollback", Text: "Symptom", Answer: "Lösung"}, false, .8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Force ArchiveApproved to fail after ImportDocument by occupying the archive
|
||||
// directory path with a regular file.
|
||||
if err := os.WriteFile(filepath.Join(stagingDir, ".approved"), []byte("block"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
web, err := fs.Sub(webFS, "web")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := newApp(s, web, appConfig{Mode: "editor", Writable: true}).withStaging(st)
|
||||
if _, err := a.promoteStaging(draft.Key); err == nil || !strings.Contains(err.Error(), "rolled back") {
|
||||
t.Fatalf("promotion error=%v", err)
|
||||
}
|
||||
if s.Count() != 0 {
|
||||
t.Fatalf("production count=%d, want rollback to zero", s.Count())
|
||||
}
|
||||
if _, err := st.Get(draft.Key); err != nil {
|
||||
t.Fatalf("staging draft should remain for retry: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationStagingHealthBypassesUIBasicAuthButRequiresBearer(t *testing.T) {
|
||||
t.Setenv("BASIC_AUTH_USER", "editor")
|
||||
t.Setenv("BASIC_AUTH_PASSWORD", "knowledge-password-123456")
|
||||
t.Setenv("KB_INTEGRATION_TOKEN", "integration-token-12345678901234567890")
|
||||
s, err := store.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := staging.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
web, err := fs.Sub(webFS, "web")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := optionalBasicAuth(newApp(s, web, appConfig{Mode: "editor", Writable: true}).withStaging(st).routes())
|
||||
|
||||
unauth := httptest.NewRecorder()
|
||||
h.ServeHTTP(unauth, httptest.NewRequest(http.MethodGet, "/api/integrations/staging/health", nil))
|
||||
if unauth.Code != http.StatusUnauthorized || strings.Contains(unauth.Body.String(), "authentication required") {
|
||||
t.Fatalf("request should reach bearer guard, status=%d body=%s", unauth.Code, unauth.Body.String())
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/integrations/staging/health", nil)
|
||||
req.Header.Set("Authorization", "Bearer integration-token-12345678901234567890")
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"kb-editor/internal/aifallback"
|
||||
@@ -29,6 +33,12 @@ func main() {
|
||||
flag.StringVar(&listen, "listen", envOr("LISTEN_ADDR", ":8080"), "HTTP listen address")
|
||||
flag.Parse()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := validateRuntimeSecrets(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, staticDir, err := configFromEnv()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -58,7 +68,7 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if reloadInterval > 0 {
|
||||
go startAutoReload(s, reloadInterval)
|
||||
go startAutoReload(ctx, s, reloadInterval)
|
||||
}
|
||||
|
||||
sub, err := fs.Sub(webFS, staticDir)
|
||||
@@ -95,9 +105,58 @@ func main() {
|
||||
if u := os.Getenv("BASIC_AUTH_USER"); u != "" {
|
||||
log.Printf("Basic authentication enabled for user %q", u)
|
||||
}
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
err := srv.ListenAndServe()
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
err = nil
|
||||
}
|
||||
errCh <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
log.Printf("KB HTTP server stopped unexpectedly: %v", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
log.Printf("KB shutdown requested")
|
||||
}
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("KB graceful shutdown failed: %v", err)
|
||||
_ = srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func validateRuntimeSecrets() error {
|
||||
check := func(name string, min int) error {
|
||||
v := strings.TrimSpace(os.Getenv(name))
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
upper := strings.ToUpper(v)
|
||||
if strings.Contains(upper, "CHANGE_ME") || strings.Contains(upper, "CHANGEME") || strings.Contains(upper, "PLACEHOLDER") {
|
||||
return fmt.Errorf("%s still contains a placeholder", name)
|
||||
}
|
||||
if len(v) < min {
|
||||
return fmt.Errorf("%s must be at least %d characters", name, min)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
min int
|
||||
}{
|
||||
{"KB_INTEGRATION_TOKEN", 24},
|
||||
{"BASIC_AUTH_PASSWORD", 12},
|
||||
{"BRAIN_ACTIVITY_API_KEY", 24},
|
||||
} {
|
||||
if err := check(item.name, item.min); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func configFromEnv() (appConfig, string, error) {
|
||||
@@ -143,12 +202,17 @@ func autoReloadInterval(mode string) (time.Duration, error) {
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func startAutoReload(s *store.Store, interval time.Duration) {
|
||||
func startAutoReload(ctx context.Context, s *store.Store, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if err := s.Reload(); err != nil {
|
||||
log.Printf("automatic index reload failed: %v", err)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := s.Reload(); err != nil {
|
||||
log.Printf("automatic index reload failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,7 +318,7 @@ func optionalBasicAuth(next http.Handler) http.Handler {
|
||||
log.Fatal("BASIC_AUTH_USER and BASIC_AUTH_PASSWORD must either both be set or both be empty")
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if (r.Method == http.MethodGet && r.URL.Path == "/api/health") || (r.Method == http.MethodPost && r.URL.Path == "/api/integrations/staging") {
|
||||
if (r.Method == http.MethodGet && (r.URL.Path == "/api/health" || r.URL.Path == "/api/integrations/staging/health")) || (r.Method == http.MethodPost && r.URL.Path == "/api/integrations/staging") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user