Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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`
Expand Down
8 changes: 3 additions & 5 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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)
```
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
```
Expand Down
4 changes: 0 additions & 4 deletions docs/targets-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
39 changes: 39 additions & 0 deletions docs_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
63 changes: 63 additions & 0 deletions headers.go
Original file line number Diff line number Diff line change
@@ -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 <script>
// blocks are allowed by SHA-256 hash, computed from the embedded file itself, so
// editing a page never leaves a stale hash behind. Inline styles stay allowed:
// the pages use style="" attributes and ECharts writes inline styles.

const frameOnlyCSP = "frame-ancestors 'none'"

func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Frame-Options", "DENY")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy", frameOnlyCSP)
next.ServeHTTP(w, r)
})
}

// inlineScriptRe matches attribute-less <script> blocks: the only inline form the
// pages use. TestPageScriptsAllCovered keeps it that way.
var inlineScriptRe = regexp.MustCompile(`(?s)<script>(.*?)</script>`)

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,
}, "; ")
}
162 changes: 162 additions & 0 deletions headers_test.go
Original file line number Diff line number Diff line change
@@ -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 <script> in a page is either external (same origin) or an attribute-less
// inline block the hash matcher sees, and no markup uses inline event handlers
// or javascript: URLs, which a hash cannot allow.
func TestPageScriptsAllCovered(t *testing.T) {
anyScript := regexp.MustCompile(`<script\b[^>]*>`)
external := regexp.MustCompile(`^<script src="/static/[^"]+"></script>`)
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 <script> tags but only %d inline + %d external are covered", page, total, inline, ext)
}
if m := handler.FindString(src); m != "" {
t.Errorf("%s: inline event handler %q is blocked by the CSP", page, m)
}
if strings.Contains(src, "javascript:") {
t.Errorf("%s: javascript: URL is blocked by the CSP", page)
}
}
}
9 changes: 6 additions & 3 deletions web.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,16 @@ func newMux(cfg *Config, store *Store, run *Runner, users map[string]string) htt
}
}
page := func(name string) http.HandlerFunc {
b, _ := staticFS.ReadFile("static/" + name)
csp := pageCSP(b)
return func(w http.ResponseWriter, r *http.Request) {
b, _ := staticFS.ReadFile("static/" + name)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Security-Policy", csp)
w.Write(b)
}
}

index := page("index.html")
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
Expand All @@ -92,7 +95,7 @@ func newMux(cfg *Config, store *Store, run *Runner, users map[string]string) htt
http.Redirect(w, r, "/login", http.StatusFound)
return
}
page("index.html")(w, r)
index(w, r)
})
mux.HandleFunc("/login", page("login.html"))
mux.Handle("/static/", http.FileServer(http.FS(staticFS)))
Expand Down Expand Up @@ -274,7 +277,7 @@ func newMux(cfg *Config, store *Store, run *Runner, users map[string]string) htt
writeJSON(w, store.ReadRange(r.Context(), name, from, to))
}))

return mux
return securityHeaders(mux)
}

// sameOrigin rejects browser requests whose Origin doesn't match the Host they were
Expand Down
Loading