F8: persist controller.yaml as 0600 (holds infra secrets)
The Hub config-apply handler wrote controller.yaml 0644; it holds cf_api_token, cf_tunnel_token and hub api_key in plaintext. New writeConfig0600 helper writes 0600 atomically (tmp+rename, bind-mount fallback) and chmods to enforce 0600 even when the file pre-existed 0644 (os.WriteFile doesn't chmod existing files). Test asserts mode 0600 (Linux; skipped on Windows). Setup path already used 0600.
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWriteConfig0600 asserts F8: controller.yaml is persisted 0600 (it holds infra secrets), even when
|
||||||
|
// the target file already existed with looser (0644) permissions. POSIX modes only — skipped on Windows.
|
||||||
|
func TestWriteConfig0600(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("POSIX file modes not represented on Windows")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "controller.yaml")
|
||||||
|
|
||||||
|
// Pre-create with world-readable 0644 to prove the helper tightens an existing file.
|
||||||
|
if err := os.WriteFile(path, []byte("old: true\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeConfig0600(path, []byte("hub:\n api_key: redacted\n")); err != nil {
|
||||||
|
t.Fatalf("writeConfig0600: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if mode := fi.Mode().Perm(); mode != 0o600 {
|
||||||
|
t.Fatalf("config mode = %o, want 0600", mode)
|
||||||
|
}
|
||||||
|
// No leftover temp file.
|
||||||
|
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("temp file not cleaned up")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1011,25 +1011,14 @@ func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write config: try atomic rename first, fall back to direct write
|
// Write config 0600: it holds infra credentials (cf_api_token, cf_tunnel_token, hub api_key) in
|
||||||
// (os.Rename fails on Docker bind mounts with "device or resource busy")
|
// plaintext (F8), so it must not be world-readable. writeConfig0600 enforces the mode even when the
|
||||||
tmpPath := r.configPath + ".tmp"
|
// target file already existed with looser perms (os.WriteFile does not chmod an existing file).
|
||||||
if err := os.WriteFile(tmpPath, body, 0644); err != nil {
|
if err := writeConfig0600(r.configPath, body); err != nil {
|
||||||
r.logger.Printf("[ERROR] [api] Config apply: failed to write temp file: %v", err)
|
|
||||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to write config"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Rename(tmpPath, r.configPath); err != nil {
|
|
||||||
os.Remove(tmpPath)
|
|
||||||
// Rename failed (likely Docker bind mount) — write directly
|
|
||||||
if err := os.WriteFile(r.configPath, body, 0644); err != nil {
|
|
||||||
r.logger.Printf("[ERROR] [api] Config apply: failed to write config: %v", err)
|
r.logger.Printf("[ERROR] [api] Config apply: failed to write config: %v", err)
|
||||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to apply config"})
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to apply config"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.logger.Printf("[INFO] [api] Config apply: rename failed, wrote directly (bind mount)")
|
|
||||||
}
|
|
||||||
|
|
||||||
r.logger.Printf("[INFO] [api] Config applied from Hub (%d bytes), restart needed to take effect", len(body))
|
r.logger.Printf("[INFO] [api] Config applied from Hub (%d bytes), restart needed to take effect", len(body))
|
||||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Config applied. Restart controller to apply changes."})
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Config applied. Restart controller to apply changes."})
|
||||||
@@ -1040,6 +1029,27 @@ func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeConfig0600 writes config bytes to path with mode 0600, atomically when possible (tmp+rename),
|
||||||
|
// falling back to a direct write for Docker bind mounts (where os.Rename returns EBUSY). It ALWAYS
|
||||||
|
// enforces 0600 on the final file — even if it already existed with looser perms — because controller.yaml
|
||||||
|
// holds infra credentials in plaintext (F8); os.WriteFile only applies the mode when creating a new file.
|
||||||
|
func writeConfig0600(path string, body []byte) error {
|
||||||
|
tmpPath := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmpPath, body, 0600); err != nil {
|
||||||
|
return fmt.Errorf("writing temp config: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, path); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
if err := os.WriteFile(path, body, 0600); err != nil { // bind-mount fallback
|
||||||
|
return fmt.Errorf("writing config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Chmod(path, 0600); err != nil {
|
||||||
|
return fmt.Errorf("chmod config 0600: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Router) configHash(w http.ResponseWriter, _ *http.Request) {
|
func (r *Router) configHash(w http.ResponseWriter, _ *http.Request) {
|
||||||
hash, err := config.FileHash(r.configPath)
|
hash, err := config.FileHash(r.configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user