diff --git a/README.md b/README.md
index b9e27a5..2ca230d 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,8 @@ mv pingping-linux-amd64 pingping && chmod +x pingping
### Export / import targets
+> **Unreleased:** on the `main` branch only. The v0.6.2 downloads above do not include it.
+
In the console, **✎ targets → ⇩ export** downloads the active targets as `pingping-targets-YYYYMMDD.json`; **⇧ import…** (or dropping a file on the panel) shows a preview — what will be added, skipped because the name already exists, or rejected and why — before anything changes. Import only adds: existing targets are never changed or removed, and the whole file is applied in one go or not at all. Export is open to every signed-in account; import is admin-only. The same file works in [fogping](https://github.com/githubflyideas/fogping).
```json
@@ -48,8 +50,6 @@ In the console, **✎ targets → ⇩ export** downloads the active targets as `
`type` is `ping` or `tcp`, `port` is for tcp only, `pace` is `fast` / `normal` / `slow`, `interval` (seconds) is optional. Importing makes this host probe every address in the file, so only import lists you trust. Full spec: [docs/targets-format.md](docs/targets-format.md) · JSON Schema: [docs/targets.schema.json](docs/targets.schema.json).
-Ready-made lists: see presets/ (coming).
-
---
### ✨ Key Features
diff --git a/docs/targets-format.md b/docs/targets-format.md
index 6612fc6..39a02ac 100644
--- a/docs/targets-format.md
+++ b/docs/targets-format.md
@@ -114,7 +114,3 @@ before confirming, and only import lists from sources you trust.
Import also takes the same cross-site protection as the other write endpoints: a
request whose `Origin` is a different site is refused, and the JSON content type
means a page on another site cannot submit it with your cookie.
-
-## Ready-made lists
-
-Ready-made lists: see `presets/` (coming).
diff --git a/docs_test.go b/docs_test.go
new file mode 100644
index 0000000..16c9f10
--- /dev/null
+++ b/docs_test.go
@@ -0,0 +1,23 @@
+package main
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+// The docs must not point at a presets/ directory that does not exist.
+func TestDocsNoMissingPresets(t *testing.T) {
+ if _, err := os.Stat("presets"); err == nil {
+ return
+ }
+ for _, p := range []string{"README.md", "docs/targets-format.md"} {
+ b, err := os.ReadFile(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(b), "presets/") {
+ t.Errorf("%s mentions presets/, which does not exist", p)
+ }
+ }
+}
diff --git a/headers.go b/headers.go
new file mode 100644
index 0000000..23d7f28
--- /dev/null
+++ b/headers.go
@@ -0,0 +1,80 @@
+package main
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "io/fs"
+ "net/http"
+ "regexp"
+ "strings"
+ "sync"
+)
+
+// Security headers.
+//
+// Every response says it must not be framed (X-Frame-Options plus CSP
+// frame-ancestors) and must not be MIME-sniffed. The console is never meant to
+// be embedded in another page. Handlers that set a stricter policy of their own
+// (the custom logo and sponsor images: "default-src 'none'; sandbox") replace
+// the default one, which is what they want.
+//
+// The console pages get a full Content-Security-Policy. Their inline `)
+
+func inlineScriptHashes(page []byte) []string {
+ var out []string
+ for _, m := range inlineScriptRe.FindAllSubmatch(page, -1) {
+ sum := sha256.Sum256(m[1])
+ out = append(out, "'sha256-"+base64.StdEncoding.EncodeToString(sum[:])+"'")
+ }
+ return out
+}
+
+// pageCSP is the policy for one console page.
+func pageCSP(page []byte) string {
+ script := append([]string{"'self'"}, inlineScriptHashes(page)...)
+ return strings.Join([]string{
+ "default-src 'self'",
+ "script-src " + strings.Join(script, " "),
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data: https:",
+ "connect-src 'self' https://raw.githubusercontent.com",
+ "object-src 'none'",
+ "base-uri 'none'",
+ "form-action 'self'",
+ frameOnlyCSP,
+ }, "; ")
+}
+
+var pageCSPs sync.Map // embedded page path → policy
+
+func pageCSPFor(fsys fs.FS, name string) string {
+ if v, ok := pageCSPs.Load(name); ok {
+ return v.(string)
+ }
+ b, _ := fs.ReadFile(fsys, name)
+ csp := pageCSP(b)
+ pageCSPs.Store(name, csp)
+ return csp
+}
diff --git a/headers_test.go b/headers_test.go
new file mode 100644
index 0000000..ac4011d
--- /dev/null
+++ b/headers_test.go
@@ -0,0 +1,192 @@
+package main
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+func checkSecurityHeaders(t *testing.T, label string, w *httptest.ResponseRecorder) {
+ t.Helper()
+ h := w.Header()
+ if got := h.Get("X-Frame-Options"); got != "DENY" {
+ t.Errorf("%s: X-Frame-Options = %q, want DENY", label, got)
+ }
+ if got := h.Get("X-Content-Type-Options"); got != "nosniff" {
+ t.Errorf("%s: X-Content-Type-Options = %q, want nosniff", label, got)
+ }
+ if got := h.Get("Referrer-Policy"); got != "same-origin" {
+ t.Errorf("%s: Referrer-Policy = %q, want same-origin", label, got)
+ }
+ csp := h.Get("Content-Security-Policy")
+ if !strings.Contains(csp, "frame-ancestors 'none'") && csp != "default-src 'none'; sandbox" {
+ t.Errorf("%s: CSP %q neither forbids framing nor is the sandbox policy", label, csp)
+ }
+ if n := len(h.Values("Content-Security-Policy")); n > 1 {
+ t.Errorf("%s: %d CSP headers, want one", label, n)
+ }
+}
+
+func checkPageCSP(t *testing.T, label string, w *httptest.ResponseRecorder, page string) {
+ t.Helper()
+ csp := w.Header().Get("Content-Security-Policy")
+ b, _ := staticFS.ReadFile("static/" + page)
+ hashes := inlineScriptHashes(b)
+ if len(hashes) == 0 {
+ t.Fatalf("%s: no inline scripts found in %s — the matcher has stopped working", label, page)
+ }
+ for _, hs := range hashes {
+ if !strings.Contains(csp, hs) {
+ t.Errorf("%s: CSP lacks hash %s", label, hs)
+ }
+ }
+ for _, bad := range []string{"'unsafe-eval'", "script-src 'self' 'unsafe-inline'", " *"} {
+ if strings.Contains(csp, bad) {
+ t.Errorf("%s: CSP contains %s: %q", label, bad, csp)
+ }
+ }
+ if !strings.Contains(csp, "default-src 'self'") || !strings.Contains(csp, "object-src 'none'") ||
+ !strings.Contains(csp, "frame-ancestors 'none'") {
+ t.Errorf("%s: page CSP too weak: %q", label, csp)
+ }
+}
+
+func TestSecurityHeadersEverywhere(t *testing.T) {
+ s, _ := NewStore(t.TempDir(), nil)
+ defer s.Close()
+ run := NewRunner(ProbeCfg{Packets: 1, TimeoutMs: 50}, s, NewDetector(s))
+ defer run.Stop()
+ h := newMux(&Config{}, s, run)
+ do := func(method, path, origin, body, cookie string, hdr ...string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, "http://st.local"+path, strings.NewReader(body))
+ if body != "" {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ if origin != "" {
+ req.Header.Set("Origin", origin)
+ }
+ if cookie != "" {
+ req.Header.Set("Cookie", sessionCookie+"="+cookie)
+ }
+ for i := 0; i+1 < len(hdr); i += 2 {
+ req.Header.Set(hdr[i], hdr[i+1])
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, req)
+ return w
+ }
+ sessionOf := func(w *httptest.ResponseRecorder) string {
+ for _, c := range w.Result().Cookies() {
+ if c.Name == sessionCookie {
+ return c.Value
+ }
+ }
+ t.Fatalf("no session cookie: %d %s", w.Code, w.Body)
+ return ""
+ }
+ want := func(label string, w *httptest.ResponseRecorder, code int) {
+ t.Helper()
+ if w.Code != code {
+ t.Errorf("%s: status %d, want %d", label, w.Code, code)
+ }
+ checkSecurityHeaders(t, label, w)
+ }
+
+ // Before the first account exists.
+ want("GET / (no setup)", do("GET", "/", "", "", ""), http.StatusFound)
+ w := do("GET", "/setup", "", "", "")
+ want("GET /setup", w, 200)
+ checkPageCSP(t, "/setup", w, "setup.html")
+
+ adm := sessionOf(do("POST", "/api/setup", "http://st.local", `{"User":"admin","Pass":"correct horse battery"}`, ""))
+ want("POST /api/users", do("POST", "/api/users", "", `{"Name":"viewer","Pass":"just looking around","Role":"viewer"}`, adm), 200)
+ w = do("POST", "/api/login", "", `{"User":"viewer","Pass":"just looking around"}`, "")
+ want("POST /api/login", w, 200)
+ view := sessionOf(w)
+
+ for _, c := range []struct{ path, page, cookie string }{
+ {"/login", "login.html", ""},
+ {"/", "index.html", adm},
+ {"/", "index.html", view},
+ {"/settings", "settings.html", adm},
+ } {
+ w := do("GET", c.path, "", "", c.cookie)
+ want("GET "+c.path, w, 200)
+ checkPageCSP(t, c.path, w, c.page)
+ if got := w.Header().Get("ETag"); got == "" {
+ t.Errorf("GET %s: lost its ETag", c.path)
+ }
+ // Revalidation still answers 304 with the same headers.
+ w = do("GET", c.path, "", "", c.cookie, "If-None-Match", w.Header().Get("ETag"))
+ want("GET "+c.path+" (304)", w, http.StatusNotModified)
+ }
+ want("GET / (no session)", do("GET", "/", "", "", ""), http.StatusFound)
+
+ for _, c := range []struct {
+ method, path, origin, body, cookie string
+ code int
+ }{
+ {"GET", "/api/version", "", "", "", 200},
+ {"GET", "/api/health", "", "", "", http.StatusNotFound}, // loopback only; httptest is 192.0.2.1
+ {"GET", "/api/i18n", "", "", "", 200},
+ {"GET", "/api/targets", "", "", "", http.StatusUnauthorized},
+ {"GET", "/api/targets", "", "", view, 200},
+ {"GET", "/api/me", "", "", view, 200},
+ {"GET", "/api/targets/export", "", "", view, 200},
+ {"POST", "/api/targets", "", `{"type":"tcp","host":"127.0.0.1","port":1}`, view, http.StatusForbidden},
+ {"POST", "/api/targets", "http://evil.example", `{"type":"tcp","host":"127.0.0.1","port":1}`, adm, http.StatusForbidden},
+ {"GET", "/api/brand/logo", "", "", view, http.StatusNotFound},
+ {"GET", "/static/i18n.js", "", "", "", 200},
+ {"GET", "/static/nope.js", "", "", "", http.StatusNotFound},
+ {"GET", "/nope", "", "", "", http.StatusNotFound},
+ } {
+ want(c.method+" "+c.path, do(c.method, c.path, c.origin, c.body, c.cookie), c.code)
+ }
+
+ // The custom logo keeps its own, stricter policy.
+ if err := s.SetLogo(append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 32)...)); err != nil {
+ t.Fatal(err)
+ }
+ w = do("GET", "/api/brand/logo", "", "", view)
+ want("GET /api/brand/logo", w, 200)
+ if got := w.Header().Get("Content-Security-Policy"); got != "default-src 'none'; sandbox" {
+ t.Errorf("logo CSP = %q, want the sandbox policy", got)
+ }
+ if got := w.Header().Get("Content-Type"); got != "image/png" {
+ t.Errorf("logo Content-Type = %q", got)
+ }
+}
+
+// Every `)
+ handler := regexp.MustCompile(`(?i)<[a-z][^>]*\son[a-z]+\s*=`)
+ for _, page := range []string{"index.html", "login.html", "setup.html", "settings.html"} {
+ b, err := staticFS.ReadFile("static/" + page)
+ if err != nil {
+ t.Fatal(err)
+ }
+ src := string(b)
+ inline := len(inlineScriptRe.FindAllString(src, -1))
+ ext := 0
+ for _, loc := range anyScript.FindAllStringIndex(src, -1) {
+ if external.MatchString(src[loc[0]:]) {
+ ext++
+ }
+ }
+ if total := len(anyScript.FindAllString(src, -1)); total != inline+ext {
+ t.Errorf("%s: %d