A page on another site opened on a tailnet device could have a browser restore a backup, schedule a recording or start a tune here: the API and the admin API took cross-site POSTs, and the JSON handlers don't check the content type. Every unsafe request a browser marks as cross-site is now refused; the app's own page, the desktop app and other non-browser clients are unaffected. The antenna deployment's compose file passes AIRWAVES_ADMIN_PASSWORD through, as the standalone one already did, so the admin page and /admin/mcp can be locked there too.
- commit
- 4b85bfca5a23026c1979eb07e95295d4831ee50d
- parent
- 90921085bc61369075eeae7b429435e8ec53b788
- author
- FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
- kind
- fix risk score 1.8 of 3, Jev's estimate
| M | README.md | 3 | ++- |
| M | deploy/ | 3 | +++ |
| M | internal/ | 5 | ++++- |
| M | internal/ | 32 | ++++++++++++++++++++++++++++++ |
@@ -57,7 +57,8 @@ only. Tvheadend listens on 127.0.0.1 only; reach its admin UI with
5757 `ssh -L 9981:127.0.0.1:9981 nas`, then http://localhost:9981. Other `.env` settings:5858 `AIRWAVES_LAT` / `AIRWAVES_LON` for the5959 antenna's exact position (better than the ZIP centroid, since terrain decides60-reception); `AIRWAVES_TOKEN` (requires the same token in the app); `RECORDINGS` (host60+reception); `AIRWAVES_TOKEN` (requires the same token in the app);61+`AIRWAVES_ADMIN_PASSWORD` (for the admin page and `/admin/mcp`); `RECORDINGS` (host6162 path for recordings, default `~/airwaves/recordings`); `MUSIC` and `CHANNELS` (host6263 folders for the custom channels); `TZ`. Location is deployment6364 configuration only; the app never shows or edits it.@@ -42,6 +42,9 @@ services:
4242 AIRWAVES_LAT: ${AIRWAVES_LAT:-}4343 AIRWAVES_LON: ${AIRWAVES_LON:-}4444 AIRWAVES_TOKEN: ${AIRWAVES_TOKEN:-}45+ # The admin page's and /admin/mcp's password (recommended: backups46+ # downloaded there hold the Jellyfin account).47+ AIRWAVES_ADMIN_PASSWORD: ${AIRWAVES_ADMIN_PASSWORD:-}4548 # The folders mounted below, as the admin page names them.4649 AIRWAVES_HOST_CHANNELS: ${CHANNELS:-./channels}4750 AIRWAVES_HOST_MUSIC: ${MUSIC:-./music}@@ -237,7 +237,10 @@ func (s *Server) Handler() http.Handler {
237237 mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {238238 _, _ = io.WriteString(w, "ok\n")239239 })240- return s.auth(gzipJSON(mux))240+ // A page on another site can't make a browser here change anything:241+ // cross-site POSTs, PUTs and DELETEs are refused. The app's own page,242+ // the desktop app and other non-browser clients aren't affected.243+ return http.NewCrossOriginProtection().Handler(s.auth(gzipJSON(mux)))241244 }242245 243246 // adminAuth asks for the admin password. Both sides are hashed before the@@ -118,3 +118,35 @@ func TestNotAvailable(t *testing.T) {
118118 t.Error("other errors aren't 500")119119 }120120 }121+122+// TestCrossSite: a browser on another site can't change anything, while123+// the app's own page and clients that aren't browsers can.124+func TestCrossSite(t *testing.T) {125+ admin := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "admin "+r.URL.Path) })126+ srv := httptest.NewServer((&Server{Backend: &fake{}, Admin: admin}).Handler())127+ defer srv.Close()128+ for _, tc := range []struct {129+ name, fetchSite string130+ want int131+ }{132+ {"another site", "cross-site", http.StatusForbidden},133+ {"the app's page", "same-origin", http.StatusOK},134+ {"not a browser", "", http.StatusOK},135+ } {136+ for _, path := range []string{"/api/signal/measure", "/admin/api/backups/x/restore"} {137+ req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader("{}"))138+ req.Header.Set("Content-Type", "text/plain")139+ if tc.fetchSite != "" {140+ req.Header.Set("Sec-Fetch-Site", tc.fetchSite)141+ }142+ resp, err := http.DefaultClient.Do(req)143+ if err != nil {144+ t.Fatal(err)145+ }146+ resp.Body.Close()147+ if refused := resp.StatusCode == http.StatusForbidden; refused != (tc.want == http.StatusForbidden) {148+ t.Errorf("%s, POST %s: %d", tc.name, path, resp.StatusCode)149+ }150+ }151+ }152+}