From 133f2cc891281771e0592388e55059fc1d0c0bf9 Mon Sep 17 00:00:00 2001
From: githubflyideas <36397539+githubflyideas@users.noreply.github.com>
Date: Tue, 10 Nov 2026 07:49:53 +0900
Subject: [PATCH] Add security headers, root-owned archives, doc fixes
- Web: X-Frame-Options DENY, nosniff, Referrer-Policy and CSP
(frame-ancestors 'none'; pages allow inline scripts by hash).
- Release archives: owner/group root; docs use tar --no-same-owner.
- Docs: drop presets/ mentions; GUIDE startup log uses X.Y.Z.
---
.goreleaser.yaml | 12 ++-
README.md | 4 +-
docs/GUIDE.md | 8 +-
docs/targets-format.md | 4 -
docs_test.go | 39 ++++++++++
headers.go | 63 ++++++++++++++++
headers_test.go | 162 +++++++++++++++++++++++++++++++++++++++++
web.go | 9 ++-
8 files changed, 285 insertions(+), 16 deletions(-)
create mode 100644 docs_test.go
create mode 100644 headers.go
create mode 100644 headers_test.go
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index d2d3d16..897334b 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -31,8 +31,16 @@ archives:
- id: tar
formats: [tar.gz]
name_template: "{{ .ProjectName }}-v{{ .Version }}-{{ .Os }}-{{ .Arch }}"
+ # Owner root (uid/gid 0) instead of the CI runner's 1001, so `tar -x` run as
+ # root inside a container or user namespace does not fail on chown.
+ builds_info:
+ owner: root
+ group: root
files:
- - LICENSE
+ - src: LICENSE
+ info:
+ owner: root
+ group: root
checksum:
name_template: "SHA256SUMS"
@@ -46,7 +54,7 @@ release:
## Install
```bash
- tar xzf fogping-*-linux-amd64.tar.gz && ./fogping
+ tar --no-same-owner -xzf fogping-*-linux-amd64.tar.gz && ./fogping
```
Storage is SQLite; history is kept for 93 days by default (`--days=N` for less; 93 is the max).
linux/amd64 only in this release — SQLite needs CGO, so the other platforms are
diff --git a/README.md b/README.md
index a66adb4..d282d61 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
One command (Linux x86-64) — downloads, unpacks and starts fogping:
```bash
-mkdir -p ~/fogping && cd ~/fogping && curl -L https://github.com/githubflyideas/fogping/releases/download/v1.3.0/fogping-v1.3.0-linux-amd64.tar.gz | tar -xz && ./fogping --edit user=admin passwd=change-me
+mkdir -p ~/fogping && cd ~/fogping && curl -L https://github.com/githubflyideas/fogping/releases/download/v1.3.0/fogping-v1.3.0-linux-amd64.tar.gz | tar --no-same-owner -xz && ./fogping --edit user=admin passwd=change-me
```
Or with Docker (amd64 / arm64 / armv7):
@@ -48,7 +48,7 @@ Arguments can come in any order; the port is always 8518.
**Compare:** the **COMPARE** menu under the chart overlays the previous period, yesterday or last week as a dashed median line.
-**Export / import targets:** ✎ targets → ⇩ export / ⇧ import… moves a target list as a JSON file, shared with pingping — see [the guide](docs/GUIDE.md#export-and-import) and the [format spec](docs/targets-format.md). Ready-made lists: see presets/ (coming).
+**Export / import targets:** ✎ targets → ⇩ export / ⇧ import… moves a target list as a JSON file, shared with pingping — see [the guide](docs/GUIDE.md#export-and-import) and the [format spec](docs/targets-format.md).
> **PING shows 100% loss?** Allow unprivileged ICMP once:
> `echo 'net.ipv4.ping_group_range = 0 2147483647' | sudo tee /etc/sysctl.d/99-fogping.conf && sudo sysctl --system`
diff --git a/docs/GUIDE.md b/docs/GUIDE.md
index 9f1e1f1..96c6cda 100644
--- a/docs/GUIDE.md
+++ b/docs/GUIDE.md
@@ -63,7 +63,7 @@ sudo mkdir -p /opt/fogping && sudo chown "$USER": /opt/fogping && cd /opt/fogpin
wget https://github.com/githubflyideas/fogping/releases/download/v1.3.0/fogping-v1.3.0-linux-amd64.tar.gz
wget https://github.com/githubflyideas/fogping/releases/download/v1.3.0/SHA256SUMS
sha256sum --ignore-missing -c SHA256SUMS # expect: fogping-v1.3.0-linux-amd64.tar.gz: OK
-tar -xzf fogping-v1.3.0-linux-amd64.tar.gz
+tar --no-same-owner -xzf fogping-v1.3.0-linux-amd64.tar.gz
./fogping --version
```
@@ -115,7 +115,7 @@ starts probing. The log looks like this:
```
new database — seeded a demo target (www.google.com)
[Demo] probing www.google.com
-fogping 1.1.2 up · 1 targets · targets editable in the web UI · listening on 0.0.0.0:8518 · data in ./data · 93-day retention
+fogping X.Y.Z up · 1 targets · targets editable in the web UI · listening on 0.0.0.0:8518 · data in ./data · 93-day retention
➜ open http://localhost:8518 for the smoke graph
web login enabled for 1 user(s)
```
@@ -281,8 +281,6 @@ optional. Files are limited to 1 MB and 500 targets. Full spec:
> Importing makes this host probe every address in the file. Read the preview,
> and only import lists you trust.
-Ready-made lists: see presets/ (coming).
-
## Run as a systemd service
Create a dedicated user and hand it the install directory:
@@ -422,7 +420,7 @@ cd /opt/fogping
sudo systemctl stop fogping
sudo cp -a data data.bak # optional, lets you roll back
sudo wget https://github.com/githubflyideas/fogping/releases/download/vX.Y.Z/fogping-vX.Y.Z-linux-amd64.tar.gz
-sudo tar -xzf fogping-vX.Y.Z-linux-amd64.tar.gz # replaces the binary only
+sudo tar --no-same-owner -xzf fogping-vX.Y.Z-linux-amd64.tar.gz # replaces the binary only
sudo setcap cap_net_raw+ep fogping # only if you used ICMP option B
sudo systemctl start fogping
```
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..bc8eca6
--- /dev/null
+++ b/docs_test.go
@@ -0,0 +1,39 @@
+package main
+
+import (
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+// Docs regressions: no pointer to a presets/ directory that does not exist, no
+// pinned version in the sample startup log, and every documented tar extraction
+// ignores archive ownership (older release tarballs carry uid/gid 1001, which a
+// root user inside a container cannot chown to).
+func TestDocsStayAccurate(t *testing.T) {
+ docs := []string{"README.md", "docs/GUIDE.md", "docs/targets-format.md"}
+ tarX := regexp.MustCompile(`\btar\s+(-?x\S*|-\S*x\S*)`)
+ for _, p := range docs {
+ b, err := os.ReadFile(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := string(b)
+ if _, err := os.Stat("presets"); err != nil && strings.Contains(s, "presets/") {
+ t.Errorf("%s mentions presets/, which does not exist", p)
+ }
+ for _, line := range strings.Split(s, "\n") {
+ if tarX.MatchString(line) && !strings.Contains(line, "--no-same-owner") {
+ t.Errorf("%s: tar extraction without --no-same-owner: %s", p, strings.TrimSpace(line))
+ }
+ }
+ }
+ g, _ := os.ReadFile("docs/GUIDE.md")
+ if !strings.Contains(string(g), "fogping X.Y.Z up ·") {
+ t.Error("GUIDE: sample startup log should show `fogping X.Y.Z up`, not a pinned version")
+ }
+ if regexp.MustCompile(`fogping \d+\.\d+\.\d+ up`).Match(g) {
+ t.Error("GUIDE: sample startup log pins a version that goes stale")
+ }
+}
diff --git a/headers.go b/headers.go
new file mode 100644
index 0000000..756f3b9
--- /dev/null
+++ b/headers.go
@@ -0,0 +1,63 @@
+package main
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "net/http"
+ "regexp"
+ "strings"
+)
+
+// Security headers.
+//
+// Every response says it must not be framed (X-Frame-Options plus CSP
+// frame-ancestors) and must not be MIME-sniffed. The read-only UI is meant to be
+// shared as a link, not embedded in someone else's page, so nothing here needs
+// framing.
+//
+// HTML pages get a full Content-Security-Policy on top. 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 HTML 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:",
+ "connect-src 'self'",
+ "object-src 'none'",
+ "base-uri 'none'",
+ "form-action 'self'",
+ frameOnlyCSP,
+ }, "; ")
+}
diff --git a/headers_test.go b/headers_test.go
new file mode 100644
index 0000000..f7704ff
--- /dev/null
+++ b/headers_test.go
@@ -0,0 +1,162 @@
+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)
+ }
+ if got := h.Get("Content-Security-Policy"); !strings.Contains(got, "frame-ancestors 'none'") {
+ t.Errorf("%s: CSP %q lacks frame-ancestors 'none'", label, got)
+ }
+}
+
+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'") {
+ 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))
+ do := func(h http.Handler, method, path, body, cookie string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, "http://fp.local"+path, strings.NewReader(body))
+ if body != "" {
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Origin", "http://fp.local")
+ }
+ if cookie != "" {
+ req.Header.Set("Cookie", "fogping_session="+cookie)
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, req)
+ return w
+ }
+
+ // Open, read-only UI (no users): the shareable page itself.
+ open := newMux(&Config{}, s, run, nil)
+ w := do(open, "GET", "/", "", "")
+ if w.Code != 200 {
+ t.Fatalf("open GET /: %d", w.Code)
+ }
+ checkSecurityHeaders(t, "open /", w)
+ checkPageCSP(t, "open /", w, "index.html")
+
+ // With a login.
+ auth := newMux(&Config{Editable: true}, s, run, map[string]string{"admin": "pw"})
+ w = do(auth, "GET", "/login", "", "")
+ checkSecurityHeaders(t, "/login", w)
+ checkPageCSP(t, "/login", w, "login.html")
+
+ w = do(auth, "GET", "/", "", "")
+ if w.Code != http.StatusFound {
+ t.Errorf("unauthenticated GET / = %d, want 302", w.Code)
+ }
+ checkSecurityHeaders(t, "redirect /", w)
+
+ w = do(auth, "POST", "/api/login", `{"User":"admin","Pass":"pw"}`, "")
+ if w.Code != 200 {
+ t.Fatalf("login: %d %s", w.Code, w.Body)
+ }
+ checkSecurityHeaders(t, "/api/login", w)
+ var tok string
+ for _, c := range w.Result().Cookies() {
+ if c.Name == "fogping_session" {
+ tok = c.Value
+ }
+ }
+
+ w = do(auth, "GET", "/", "", tok)
+ if w.Code != 200 {
+ t.Fatalf("authed GET /: %d", w.Code)
+ }
+ checkSecurityHeaders(t, "authed /", w)
+ checkPageCSP(t, "authed /", w, "index.html")
+
+ cases := []struct {
+ method, path, body, cookie string
+ code int
+ }{
+ {"GET", "/api/targets", "", "", http.StatusUnauthorized},
+ {"GET", "/api/targets", "", tok, 200},
+ {"GET", "/api/version", "", "", 200},
+ {"GET", "/api/targets/export", "", tok, 200},
+ {"POST", "/api/targets", `{"name":"t1","type":"tcp","host":"127.0.0.1","port":1,"interval_sec":3600}`, tok, 200},
+ {"GET", "/static/echarts.min.js", "", "", 200},
+ {"GET", "/static/nope.js", "", "", 404},
+ {"GET", "/nope", "", "", 404},
+ }
+ for _, c := range cases {
+ w := do(auth, c.method, c.path, c.body, c.cookie)
+ label := c.method + " " + c.path
+ if c.code != 0 && w.Code != c.code {
+ t.Errorf("%s: status %d, want %d", label, w.Code, c.code)
+ }
+ checkSecurityHeaders(t, label, w)
+ }
+}
+
+// Every `)
+ handler := regexp.MustCompile(`(?i)<[a-z][^>]*\son[a-z]+\s*=`)
+ for _, page := range []string{"index.html", "login.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