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