From 756b14a61de138889130c239406dae43f1f115cb Mon Sep 17 00:00:00 2001
From: Ainar Garipov <a.garipov@adguard.com>
Date: Thu, 29 Sep 2022 19:04:26 +0300
Subject: [PATCH] Pull request: HOFTIX-csrf

Merge in DNS/adguard-home from HOFTIX-csrf to master

Squashed commit of the following:

commit 75ab27bf6c52b80ab4e7347d7c254fa659eac244
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Thu Sep 29 18:45:54 2022 +0300

    all: imp cookie security; rm plain-text apis
---
 CHANGELOG.md                                  |  35 ++
 SECURITY.md                                   |  18 ++
 client/src/actions/filtering.js               |   4 +-
 client/src/actions/index.js                   |  11 +-
 client/src/api/Api.js                         |  21 +-
 internal/aghhttp/aghhttp.go                   |  39 +++
 internal/aghhttp/header.go                    |  22 ++
 internal/dhcpd/http_unix.go                   |  57 ++--
 .../{controlfiltering.go => http.go}          |  19 +-
 internal/home/auth.go                         | 300 +++++++++---------
 internal/home/auth_test.go                    |  18 +-
 internal/home/clientshttp.go                  |  14 +-
 internal/home/config.go                       |   8 +-
 internal/home/control.go                      |  26 +-
 internal/home/controlinstall.go               |  38 +--
 internal/home/controlupdate.go                |   7 +-
 internal/home/home.go                         |   8 +-
 internal/home/i18n.go                         |  55 ++--
 internal/home/service.go                      |   3 +-
 internal/home/tls.go                          |  13 +-
 internal/home/upgrade.go                      |   4 +-
 openapi/CHANGELOG.md                          |  64 ++++
 openapi/openapi.yaml                          |  54 ++--
 23 files changed, 494 insertions(+), 344 deletions(-)
 create mode 100644 SECURITY.md
 create mode 100644 internal/aghhttp/header.go
 rename internal/filtering/{controlfiltering.go => http.go} (97%)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index dfeae3b1..ed20426c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,41 @@ and this project adheres to
 
 ### Security
 
+#### `SameSite` Policy
+
+The `SameSite` policy on the AdGuard Home session cookies is now set to `Lax`.
+Which means that the only cross-site HTTP request for which the browser is
+allowed to send the session cookie is navigating to the AdGuard Home domain.
+
+**Users are strongly advised to log out, clear browser cache, and log in again
+after updating.**
+
+#### Removal Of Plain-Text APIs (BREAKING API CHANGE)
+
+A Cross-Site Request Forgery (CSRF) vulnerability has been discovered.  We have
+implemented several measures to prevent such vulnerabilities in the future, but
+some of these measures break backwards compatibility for the sake of better
+protection.
+
+The following APIs, which previously accepted or returned `text/plain` data,
+now accept or return data as JSON.  All new formats for the request and response
+bodies are documented in `openapi/openapi.yaml` and `openapi/CHANGELOG.md`.
+
+- `GET  /control/i18n/current_language`;
+- `POST /control/dhcp/find_active_dhcp`;
+- `POST /control/filtering/set_rules`;
+- `POST /control/i18n/change_language`.
+
+The CVE number is to be assigned.  We thank Daniel Elkabes from Mend.io for
+reporting this vulnerability to us.
+
+#### Stricter Content-Type Checks (BREAKING API CHANGE)
+
+All JSON APIs now check if the request actually has the `application/json`
+content-type.
+
+#### Other Security Changes
+
 - Weaker cipher suites that use the CBC (cipher block chaining) mode of
   operation have been disabled ([#2993]).
 
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..2fa3a67b
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,18 @@
+ #  Security Policy
+
+## Reporting a Vulnerability
+
+Please send your vulnerability reports to <security@adguard.com>.  To make sure
+that your report reaches us, please:
+
+1.  Include the words “AdGuard Home” and “vulnerability” to the subject line as
+    well as a short description of the vulnerability.  For example:
+
+     >  AdGuard Home API vulnerability: possible XSS attack
+
+2.  Make sure that the message body contains a clear description of the
+    vulnerability.
+
+If you have not received a reply to your email within 7 days, please make sure
+to follow up with us again at <security@adguard.com>.  Once again, make sure
+that the word “vulnerability” is in the subject line.
diff --git a/client/src/actions/filtering.js b/client/src/actions/filtering.js
index 02bc97f8..6039fdcc 100644
--- a/client/src/actions/filtering.js
+++ b/client/src/actions/filtering.js
@@ -31,7 +31,9 @@ export const setRulesSuccess = createAction('SET_RULES_SUCCESS');
 export const setRules = (rules) => async (dispatch) => {
     dispatch(setRulesRequest());
     try {
-        const normalizedRules = normalizeRulesTextarea(rules);
+        const normalizedRules = {
+            rules: normalizeRulesTextarea(rules)?.split('\n'),
+        };
         await apiClient.setRules(normalizedRules);
         dispatch(addSuccessToast('updated_custom_filtering_toast'));
         dispatch(setRulesSuccess());
diff --git a/client/src/actions/index.js b/client/src/actions/index.js
index e1b4e96c..2242490b 100644
--- a/client/src/actions/index.js
+++ b/client/src/actions/index.js
@@ -355,7 +355,7 @@ export const changeLanguageSuccess = createAction('CHANGE_LANGUAGE_SUCCESS');
 export const changeLanguage = (lang) => async (dispatch) => {
     dispatch(changeLanguageRequest());
     try {
-        await apiClient.changeLanguage(lang);
+        await apiClient.changeLanguage({ language: lang });
         dispatch(changeLanguageSuccess());
     } catch (error) {
         dispatch(addErrorToast({ error }));
@@ -370,8 +370,8 @@ export const getLanguageSuccess = createAction('GET_LANGUAGE_SUCCESS');
 export const getLanguage = () => async (dispatch) => {
     dispatch(getLanguageRequest());
     try {
-        const language = await apiClient.getCurrentLanguage();
-        dispatch(getLanguageSuccess(language));
+        const langSettings = await apiClient.getCurrentLanguage();
+        dispatch(getLanguageSuccess(langSettings.language));
     } catch (error) {
         dispatch(addErrorToast({ error }));
         dispatch(getLanguageFailure());
@@ -421,7 +421,10 @@ export const findActiveDhcpFailure = createAction('FIND_ACTIVE_DHCP_FAILURE');
 export const findActiveDhcp = (name) => async (dispatch, getState) => {
     dispatch(findActiveDhcpRequest());
     try {
-        const activeDhcp = await apiClient.findActiveDhcp(name);
+        const req = {
+            interface: name,
+        };
+        const activeDhcp = await apiClient.findActiveDhcp(req);
         dispatch(findActiveDhcpSuccess(activeDhcp));
         const { check, interface_name, interfaces } = getState().dhcp;
         const selectedInterface = getState().form[FORM_NAME.DHCP_INTERFACES].values.interface_name;
diff --git a/client/src/api/Api.js b/client/src/api/Api.js
index d5693bfe..113c2c00 100644
--- a/client/src/api/Api.js
+++ b/client/src/api/Api.js
@@ -130,7 +130,7 @@ class Api {
         const { path, method } = this.FILTERING_SET_RULES;
         const parameters = {
             data: rules,
-            headers: { 'Content-Type': 'text/plain' },
+            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -173,12 +173,7 @@ class Api {
 
     enableParentalControl() {
         const { path, method } = this.PARENTAL_ENABLE;
-        const parameter = 'sensitivity=TEEN'; // this parameter TEEN is hardcoded
-        const config = {
-            data: parameter,
-            headers: { 'Content-Type': 'text/plain' },
-        };
-        return this.makeRequest(path, method, config);
+        return this.makeRequest(path, method);
     }
 
     disableParentalControl() {
@@ -240,11 +235,11 @@ class Api {
         return this.makeRequest(path, method);
     }
 
-    changeLanguage(lang) {
+    changeLanguage(config) {
         const { path, method } = this.CHANGE_LANGUAGE;
         const parameters = {
-            data: lang,
-            headers: { 'Content-Type': 'text/plain' },
+            data: config,
+            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
@@ -285,11 +280,11 @@ class Api {
         return this.makeRequest(path, method, parameters);
     }
 
-    findActiveDhcp(name) {
+    findActiveDhcp(req) {
         const { path, method } = this.DHCP_FIND_ACTIVE;
         const parameters = {
-            data: name,
-            headers: { 'Content-Type': 'text/plain' },
+            data: req,
+            headers: { 'Content-Type': 'application/json' },
         };
         return this.makeRequest(path, method, parameters);
     }
diff --git a/internal/aghhttp/aghhttp.go b/internal/aghhttp/aghhttp.go
index 23f9f5d3..8f786749 100644
--- a/internal/aghhttp/aghhttp.go
+++ b/internal/aghhttp/aghhttp.go
@@ -2,10 +2,12 @@
 package aghhttp
 
 import (
+	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
 
+	"github.com/AdguardTeam/AdGuardHome/internal/version"
 	"github.com/AdguardTeam/golibs/log"
 )
 
@@ -34,3 +36,40 @@ func Error(r *http.Request, w http.ResponseWriter, code int, format string, args
 	log.Error("%s %s: %s", r.Method, r.URL, text)
 	http.Error(w, text, code)
 }
+
+// UserAgent returns the ID of the service as a User-Agent string.  It can also
+// be used as the value of the Server HTTP header.
+func UserAgent() (ua string) {
+	return fmt.Sprintf("AdGuardHome/%s", version.Version())
+}
+
+// textPlainDeprMsg is the message returned to API users when they try to use
+// an API that used to accept "text/plain" but doesn't anymore.
+const textPlainDeprMsg = `using this api with the text/plain content-type is deprecated; ` +
+	`use application/json`
+
+// WriteTextPlainDeprecated responds to the request with a message about
+// deprecation and removal of a plain-text API if the request is made with the
+// "text/plain" content-type.
+func WriteTextPlainDeprecated(w http.ResponseWriter, r *http.Request) (isPlainText bool) {
+	if r.Header.Get(HdrNameContentType) != HdrValTextPlain {
+		return false
+	}
+
+	Error(r, w, http.StatusUnsupportedMediaType, textPlainDeprMsg)
+
+	return true
+}
+
+// WriteJSONResponse sets the content-type header in w.Header() to
+// "application/json", encodes resp to w, calls Error on any returned error, and
+// returns it as well.
+func WriteJSONResponse(w http.ResponseWriter, r *http.Request, resp any) (err error) {
+	w.Header().Set(HdrNameContentType, HdrValApplicationJSON)
+	err = json.NewEncoder(w).Encode(resp)
+	if err != nil {
+		Error(r, w, http.StatusInternalServerError, "encoding resp: %s", err)
+	}
+
+	return err
+}
diff --git a/internal/aghhttp/header.go b/internal/aghhttp/header.go
new file mode 100644
index 00000000..1509a7e0
--- /dev/null
+++ b/internal/aghhttp/header.go
@@ -0,0 +1,22 @@
+package aghhttp
+
+// HTTP Headers
+
+// HTTP header name constants.
+//
+// TODO(a.garipov): Remove unused.
+const (
+	HdrNameAcceptEncoding           = "Accept-Encoding"
+	HdrNameAccessControlAllowOrigin = "Access-Control-Allow-Origin"
+	HdrNameContentType              = "Content-Type"
+	HdrNameContentEncoding          = "Content-Encoding"
+	HdrNameServer                   = "Server"
+	HdrNameTrailer                  = "Trailer"
+	HdrNameUserAgent                = "User-Agent"
+)
+
+// HTTP header value constants.
+const (
+	HdrValApplicationJSON = "application/json"
+	HdrValTextPlain       = "text/plain"
+)
diff --git a/internal/dhcpd/http_unix.go b/internal/dhcpd/http_unix.go
index 8a32dab6..de06431f 100644
--- a/internal/dhcpd/http_unix.go
+++ b/internal/dhcpd/http_unix.go
@@ -5,11 +5,9 @@ package dhcpd
 import (
 	"encoding/json"
 	"fmt"
-	"io"
 	"net"
 	"net/http"
 	"os"
-	"strings"
 
 	"github.com/AdguardTeam/AdGuardHome/internal/aghalg"
 	"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
@@ -410,31 +408,37 @@ type dhcpSearchResult struct {
 	V6 dhcpSearchV6Result `json:"v6"`
 }
 
-// Perform the following tasks:
-// . Search for another DHCP server running
-// . Check if a static IP is configured for the network interface
-// Respond with results
+// findActiveServerReq is the JSON structure for the request to find active DHCP
+// servers.
+type findActiveServerReq struct {
+	Interface string `json:"interface"`
+}
+
+// handleDHCPFindActiveServer performs the following tasks:
+//  1. searches for another DHCP server in the network;
+//  2. check if a static IP is configured for the network interface;
+//  3. responds with the results.
 func (s *server) handleDHCPFindActiveServer(w http.ResponseWriter, r *http.Request) {
-	// This use of ReadAll is safe, because request's body is now limited.
-	body, err := io.ReadAll(r.Body)
+	if aghhttp.WriteTextPlainDeprecated(w, r) {
+		return
+	}
+
+	req := &findActiveServerReq{}
+	err := json.NewDecoder(r.Body).Decode(req)
 	if err != nil {
-		msg := fmt.Sprintf("failed to read request body: %s", err)
-		log.Error(msg)
-		http.Error(w, msg, http.StatusBadRequest)
+		aghhttp.Error(r, w, http.StatusBadRequest, "reading req: %s", err)
 
 		return
 	}
 
-	ifaceName := strings.TrimSpace(string(body))
+	ifaceName := req.Interface
 	if ifaceName == "" {
-		msg := "empty interface name specified"
-		log.Error(msg)
-		http.Error(w, msg, http.StatusBadRequest)
+		aghhttp.Error(r, w, http.StatusBadRequest, "empty interface name")
 
 		return
 	}
 
-	result := dhcpSearchResult{
+	result := &dhcpSearchResult{
 		V4: dhcpSearchV4Result{
 			OtherServer: dhcpSearchOtherResult{
 				Found: "no",
@@ -459,6 +463,14 @@ func (s *server) handleDHCPFindActiveServer(w http.ResponseWriter, r *http.Reque
 		result.V4.StaticIP.IP = aghnet.GetSubnet(ifaceName).String()
 	}
 
+	setOtherDHCPResult(ifaceName, result)
+
+	_ = aghhttp.WriteJSONResponse(w, r, result)
+}
+
+// setOtherDHCPResult sets the results of the check for another DHCP server in
+// result.
+func setOtherDHCPResult(ifaceName string, result *dhcpSearchResult) {
 	found4, found6, err4, err6 := aghnet.CheckOtherDHCP(ifaceName)
 	if err4 != nil {
 		result.V4.OtherServer.Found = "error"
@@ -466,24 +478,13 @@ func (s *server) handleDHCPFindActiveServer(w http.ResponseWriter, r *http.Reque
 	} else if found4 {
 		result.V4.OtherServer.Found = "yes"
 	}
+
 	if err6 != nil {
 		result.V6.OtherServer.Found = "error"
 		result.V6.OtherServer.Error = err6.Error()
 	} else if found6 {
 		result.V6.OtherServer.Found = "yes"
 	}
-
-	w.Header().Set("Content-Type", "application/json")
-	err = json.NewEncoder(w).Encode(result)
-	if err != nil {
-		aghhttp.Error(
-			r,
-			w,
-			http.StatusInternalServerError,
-			"Failed to marshal DHCP found json: %s",
-			err,
-		)
-	}
 }
 
 func (s *server) handleDHCPAddStaticLease(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/filtering/controlfiltering.go b/internal/filtering/http.go
similarity index 97%
rename from internal/filtering/controlfiltering.go
rename to internal/filtering/http.go
index 1cce8ded..50890f93 100644
--- a/internal/filtering/controlfiltering.go
+++ b/internal/filtering/http.go
@@ -3,13 +3,11 @@ package filtering
 import (
 	"encoding/json"
 	"fmt"
-	"io"
 	"net"
 	"net/http"
 	"net/url"
 	"os"
 	"path/filepath"
-	"strings"
 	"time"
 
 	"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
@@ -249,16 +247,25 @@ func (d *DNSFilter) handleFilteringSetURL(w http.ResponseWriter, r *http.Request
 	}
 }
 
+// filteringRulesReq is the JSON structure for settings custom filtering rules.
+type filteringRulesReq struct {
+	Rules []string `json:"rules"`
+}
+
 func (d *DNSFilter) handleFilteringSetRules(w http.ResponseWriter, r *http.Request) {
-	// This use of ReadAll is safe, because request's body is now limited.
-	body, err := io.ReadAll(r.Body)
+	if aghhttp.WriteTextPlainDeprecated(w, r) {
+		return
+	}
+
+	req := &filteringRulesReq{}
+	err := json.NewDecoder(r.Body).Decode(req)
 	if err != nil {
-		aghhttp.Error(r, w, http.StatusBadRequest, "Failed to read request body: %s", err)
+		aghhttp.Error(r, w, http.StatusBadRequest, "reading req: %s", err)
 
 		return
 	}
 
-	d.UserRules = strings.Split(string(body), "\n")
+	d.UserRules = req.Rules
 	d.ConfigModified()
 	d.EnableFilters(true)
 }
diff --git a/internal/home/auth.go b/internal/home/auth.go
index ca708f4a..14d2f52b 100644
--- a/internal/home/auth.go
+++ b/internal/home/auth.go
@@ -8,12 +8,14 @@ import (
 	"fmt"
 	"net"
 	"net/http"
+	"path"
 	"strconv"
 	"strings"
 	"sync"
 	"time"
 
 	"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
+	"github.com/AdguardTeam/golibs/errors"
 	"github.com/AdguardTeam/golibs/log"
 	"github.com/AdguardTeam/golibs/netutil"
 	"github.com/AdguardTeam/golibs/timeutil"
@@ -32,7 +34,8 @@ const sessionTokenSize = 16
 
 type session struct {
 	userName string
-	expire   uint32 // expiration time (in seconds)
+	// expire is the expiration time, in seconds.
+	expire uint32
 }
 
 func (s *session) serialize() []byte {
@@ -64,29 +67,29 @@ func (s *session) deserialize(data []byte) bool {
 
 // Auth - global object
 type Auth struct {
-	db         *bbolt.DB
-	blocker    *authRateLimiter
-	sessions   map[string]*session
-	users      []User
-	lock       sync.Mutex
-	sessionTTL uint32
+	db          *bbolt.DB
+	raleLimiter *authRateLimiter
+	sessions    map[string]*session
+	users       []webUser
+	lock        sync.Mutex
+	sessionTTL  uint32
 }
 
-// User object
-type User struct {
+// webUser represents a user of the Web UI.
+type webUser struct {
 	Name         string `yaml:"name"`
-	PasswordHash string `yaml:"password"` // bcrypt hash
+	PasswordHash string `yaml:"password"`
 }
 
 // InitAuth - create a global object
-func InitAuth(dbFilename string, users []User, sessionTTL uint32, blocker *authRateLimiter) *Auth {
+func InitAuth(dbFilename string, users []webUser, sessionTTL uint32, rateLimiter *authRateLimiter) *Auth {
 	log.Info("Initializing auth module: %s", dbFilename)
 
 	a := &Auth{
-		sessionTTL: sessionTTL,
-		blocker:    blocker,
-		sessions:   make(map[string]*session),
-		users:      users,
+		sessionTTL:  sessionTTL,
+		raleLimiter: rateLimiter,
+		sessions:    make(map[string]*session),
+		users:       users,
 	}
 	var err error
 	a.db, err = bbolt.Open(dbFilename, 0o644, nil)
@@ -326,35 +329,25 @@ func newSessionToken() (data []byte, err error) {
 	return randData, nil
 }
 
-// cookieTimeFormat is the format to be used in (time.Time).Format for cookie's
-// expiry field.
-const cookieTimeFormat = "Mon, 02 Jan 2006 15:04:05 GMT"
-
-// cookieExpiryFormat returns the formatted exp to be used in cookie string.
-// It's quite simple for now, but probably will be expanded in the future.
-func cookieExpiryFormat(exp time.Time) (formatted string) {
-	return exp.Format(cookieTimeFormat)
-}
-
-func (a *Auth) httpCookie(req loginJSON, addr string) (cookie string, err error) {
-	blocker := a.blocker
-	u := a.UserFind(req.Name, req.Password)
-	if len(u.Name) == 0 {
-		if blocker != nil {
-			blocker.inc(addr)
+// newCookie creates a new authentication cookie.
+func (a *Auth) newCookie(req loginJSON, addr string) (c *http.Cookie, err error) {
+	rateLimiter := a.raleLimiter
+	u, ok := a.findUser(req.Name, req.Password)
+	if !ok {
+		if rateLimiter != nil {
+			rateLimiter.inc(addr)
 		}
 
-		return "", err
+		return nil, errors.Error("invalid username or password")
 	}
 
-	if blocker != nil {
-		blocker.remove(addr)
+	if rateLimiter != nil {
+		rateLimiter.remove(addr)
 	}
 
-	var sess []byte
-	sess, err = newSessionToken()
+	sess, err := newSessionToken()
 	if err != nil {
-		return "", err
+		return nil, fmt.Errorf("generating token: %w", err)
 	}
 
 	now := time.Now().UTC()
@@ -364,11 +357,15 @@ func (a *Auth) httpCookie(req loginJSON, addr string) (cookie string, err error)
 		expire:   uint32(now.Unix()) + a.sessionTTL,
 	})
 
-	return fmt.Sprintf(
-		"%s=%s; Path=/; HttpOnly; Expires=%s",
-		sessionCookieName, hex.EncodeToString(sess),
-		cookieExpiryFormat(now.Add(cookieTTL)),
-	), nil
+	return &http.Cookie{
+		Name:    sessionCookieName,
+		Value:   hex.EncodeToString(sess),
+		Path:    "/",
+		Expires: now.Add(cookieTTL),
+
+		HttpOnly: true,
+		SameSite: http.SameSiteLaxMode,
+	}, nil
 }
 
 // realIP extracts the real IP address of the client from an HTTP request using
@@ -436,8 +433,8 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	if blocker := Context.auth.blocker; blocker != nil {
-		if left := blocker.check(remoteAddr); left > 0 {
+	if rateLimiter := Context.auth.raleLimiter; rateLimiter != nil {
+		if left := rateLimiter.check(remoteAddr); left > 0 {
 			w.Header().Set("Retry-After", strconv.Itoa(int(left.Seconds())))
 			aghhttp.Error(r, w, http.StatusTooManyRequests, "auth: blocked for %s", left)
 
@@ -445,10 +442,9 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
-	var cookie string
-	cookie, err = Context.auth.httpCookie(req, remoteAddr)
+	cookie, err := Context.auth.newCookie(req, remoteAddr)
 	if err != nil {
-		aghhttp.Error(r, w, http.StatusBadRequest, "crypto rand reader: %s", err)
+		aghhttp.Error(r, w, http.StatusForbidden, "%s", err)
 
 		return
 	}
@@ -462,20 +458,11 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
 		log.Error("auth: unknown ip")
 	}
 
-	if len(cookie) == 0 {
-		log.Info("auth: failed to login user %q from ip %v", req.Name, ip)
-
-		time.Sleep(1 * time.Second)
-
-		http.Error(w, "invalid username or password", http.StatusBadRequest)
-
-		return
-	}
-
 	log.Info("auth: user %q successfully logged in from ip %v", req.Name, ip)
 
+	http.SetCookie(w, cookie)
+
 	h := w.Header()
-	h.Set("Set-Cookie", cookie)
 	h.Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
 	h.Set("Pragma", "no-cache")
 	h.Set("Expires", "0")
@@ -484,17 +471,31 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
 }
 
 func handleLogout(w http.ResponseWriter, r *http.Request) {
-	cookie := r.Header.Get("Cookie")
-	sess := parseCookie(cookie)
+	respHdr := w.Header()
+	c, err := r.Cookie(sessionCookieName)
+	if err != nil {
+		// The only error that is returned from r.Cookie is [http.ErrNoCookie].
+		// The user is already logged out.
+		respHdr.Set("Location", "/login.html")
+		w.WriteHeader(http.StatusFound)
 
-	Context.auth.RemoveSession(sess)
+		return
+	}
 
-	w.Header().Set("Location", "/login.html")
+	Context.auth.RemoveSession(c.Value)
 
-	s := fmt.Sprintf("%s=; Path=/; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:00 GMT",
-		sessionCookieName)
-	w.Header().Set("Set-Cookie", s)
+	c = &http.Cookie{
+		Name:    sessionCookieName,
+		Value:   "",
+		Path:    "/",
+		Expires: time.Unix(0, 0),
 
+		HttpOnly: true,
+		SameSite: http.SameSiteLaxMode,
+	}
+
+	respHdr.Set("Location", "/login.html")
+	respHdr.Set("Set-Cookie", c.String())
 	w.WriteHeader(http.StatusFound)
 }
 
@@ -504,101 +505,108 @@ func RegisterAuthHandlers() {
 	httpRegister(http.MethodGet, "/control/logout", handleLogout)
 }
 
-func parseCookie(cookie string) string {
-	pairs := strings.Split(cookie, ";")
-	for _, pair := range pairs {
-		pair = strings.TrimSpace(pair)
-		kv := strings.SplitN(pair, "=", 2)
-		if len(kv) != 2 {
-			continue
-		}
-		if kv[0] == sessionCookieName {
-			return kv[1]
-		}
-	}
-	return ""
-}
-
 // optionalAuthThird return true if user should authenticate first.
-func optionalAuthThird(w http.ResponseWriter, r *http.Request) (authFirst bool) {
-	authFirst = false
+func optionalAuthThird(w http.ResponseWriter, r *http.Request) (mustAuth bool) {
+	if glProcessCookie(r) {
+		log.Debug("auth: authentication is handled by GL-Inet submodule")
+
+		return false
+	}
 
 	// redirect to login page if not authenticated
-	ok := false
+	isAuthenticated := false
 	cookie, err := r.Cookie(sessionCookieName)
-
-	if glProcessCookie(r) {
-		log.Debug("auth: authentication was handled by GL-Inet submodule")
-		ok = true
-	} else if err == nil {
-		r := Context.auth.checkSession(cookie.Value)
-		if r == checkSessionOK {
-			ok = true
-		} else if r < 0 {
-			log.Debug("auth: invalid cookie value: %s", cookie)
-		}
-	} else {
-		// there's no Cookie, check Basic authentication
-		user, pass, ok2 := r.BasicAuth()
-		if ok2 {
-			u := Context.auth.UserFind(user, pass)
-			if len(u.Name) != 0 {
-				ok = true
-			} else {
+	if err != nil {
+		// The only error that is returned from r.Cookie is [http.ErrNoCookie].
+		// Check Basic authentication.
+		user, pass, hasBasic := r.BasicAuth()
+		if hasBasic {
+			_, isAuthenticated = Context.auth.findUser(user, pass)
+			if !isAuthenticated {
 				log.Info("auth: invalid Basic Authorization value")
 			}
 		}
-	}
-	if !ok {
-		if r.URL.Path == "/" || r.URL.Path == "/index.html" {
-			if glProcessRedirect(w, r) {
-				log.Debug("auth: redirected to login page by GL-Inet submodule")
-			} else {
-				w.Header().Set("Location", "/login.html")
-				w.WriteHeader(http.StatusFound)
-			}
-		} else {
-			w.WriteHeader(http.StatusForbidden)
-			_, _ = w.Write([]byte("Forbidden"))
+	} else {
+		res := Context.auth.checkSession(cookie.Value)
+		isAuthenticated = res == checkSessionOK
+		if !isAuthenticated {
+			log.Debug("auth: invalid cookie value: %s", cookie)
 		}
-		authFirst = true
 	}
 
-	return authFirst
+	if isAuthenticated {
+		return false
+	}
+
+	if p := r.URL.Path; p == "/" || p == "/index.html" {
+		if glProcessRedirect(w, r) {
+			log.Debug("auth: redirected to login page by GL-Inet submodule")
+		} else {
+			log.Debug("auth: redirected to login page")
+			w.Header().Set("Location", "/login.html")
+			w.WriteHeader(http.StatusFound)
+		}
+	} else {
+		log.Debug("auth: responded with forbidden to %s %s", r.Method, p)
+		w.WriteHeader(http.StatusForbidden)
+		_, _ = w.Write([]byte("Forbidden"))
+	}
+
+	return true
 }
 
-func optionalAuth(handler func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
+// TODO(a.garipov): Use [http.Handler] consistently everywhere throughout the
+// project.
+func optionalAuth(
+	h func(http.ResponseWriter, *http.Request),
+) (wrapped func(http.ResponseWriter, *http.Request)) {
 	return func(w http.ResponseWriter, r *http.Request) {
-		if r.URL.Path == "/login.html" {
-			// redirect to dashboard if already authenticated
-			authRequired := Context.auth != nil && Context.auth.AuthRequired()
+		p := r.URL.Path
+		authRequired := Context.auth != nil && Context.auth.AuthRequired()
+		if p == "/login.html" {
 			cookie, err := r.Cookie(sessionCookieName)
 			if authRequired && err == nil {
-				r := Context.auth.checkSession(cookie.Value)
-				if r == checkSessionOK {
+				// Redirect to the dashboard if already authenticated.
+				res := Context.auth.checkSession(cookie.Value)
+				if res == checkSessionOK {
 					w.Header().Set("Location", "/")
 					w.WriteHeader(http.StatusFound)
 
 					return
-				} else if r == checkSessionNotFound {
-					log.Debug("auth: invalid cookie value: %s", cookie)
 				}
-			}
 
-		} else if strings.HasPrefix(r.URL.Path, "/assets/") ||
-			strings.HasPrefix(r.URL.Path, "/login.") {
-			// process as usual
-			// no additional auth requirements
-		} else if Context.auth != nil && Context.auth.AuthRequired() {
+				log.Debug("auth: invalid cookie value: %s", cookie)
+			}
+		} else if isPublicResource(p) {
+			// Process as usual, no additional auth requirements.
+		} else if authRequired {
 			if optionalAuthThird(w, r) {
 				return
 			}
 		}
 
-		handler(w, r)
+		h(w, r)
 	}
 }
 
+// isPublicResource returns true if p is a path to a public resource.
+func isPublicResource(p string) (ok bool) {
+	isAsset, err := path.Match("/assets/*", p)
+	if err != nil {
+		// The only error that is returned from path.Match is
+		// [path.ErrBadPattern].  This is a programmer error.
+		panic(fmt.Errorf("bad asset pattern: %w", err))
+	}
+
+	isLogin, err := path.Match("/login.*", p)
+	if err != nil {
+		// Same as above.
+		panic(fmt.Errorf("bad login pattern: %w", err))
+	}
+
+	return isAsset || isLogin
+}
+
 type authHandler struct {
 	handler http.Handler
 }
@@ -612,7 +620,7 @@ func optionalAuthHandler(handler http.Handler) http.Handler {
 }
 
 // UserAdd - add new user
-func (a *Auth) UserAdd(u *User, password string) {
+func (a *Auth) UserAdd(u *webUser, password string) {
 	if len(password) == 0 {
 		return
 	}
@@ -631,31 +639,35 @@ func (a *Auth) UserAdd(u *User, password string) {
 	log.Debug("auth: added user: %s", u.Name)
 }
 
-// UserFind - find a user
-func (a *Auth) UserFind(login, password string) User {
+// findUser returns a user if there is one.
+func (a *Auth) findUser(login, password string) (u webUser, ok bool) {
 	a.lock.Lock()
 	defer a.lock.Unlock()
-	for _, u := range a.users {
+
+	for _, u = range a.users {
 		if u.Name == login &&
 			bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil {
-			return u
+			return u, true
 		}
 	}
-	return User{}
+
+	return webUser{}, false
 }
 
 // getCurrentUser returns the current user.  It returns an empty User if the
 // user is not found.
-func (a *Auth) getCurrentUser(r *http.Request) User {
+func (a *Auth) getCurrentUser(r *http.Request) (u webUser) {
 	cookie, err := r.Cookie(sessionCookieName)
 	if err != nil {
 		// There's no Cookie, check Basic authentication.
 		user, pass, ok := r.BasicAuth()
 		if ok {
-			return Context.auth.UserFind(user, pass)
+			u, _ = Context.auth.findUser(user, pass)
+
+			return u
 		}
 
-		return User{}
+		return webUser{}
 	}
 
 	a.lock.Lock()
@@ -663,20 +675,20 @@ func (a *Auth) getCurrentUser(r *http.Request) User {
 
 	s, ok := a.sessions[cookie.Value]
 	if !ok {
-		return User{}
+		return webUser{}
 	}
 
-	for _, u := range a.users {
+	for _, u = range a.users {
 		if u.Name == s.userName {
 			return u
 		}
 	}
 
-	return User{}
+	return webUser{}
 }
 
 // GetUsers - get users
-func (a *Auth) GetUsers() []User {
+func (a *Auth) GetUsers() []webUser {
 	a.lock.Lock()
 	users := a.users
 	a.lock.Unlock()
diff --git a/internal/home/auth_test.go b/internal/home/auth_test.go
index 6a2ebea7..1bf38753 100644
--- a/internal/home/auth_test.go
+++ b/internal/home/auth_test.go
@@ -43,14 +43,14 @@ func TestAuth(t *testing.T) {
 	dir := t.TempDir()
 	fn := filepath.Join(dir, "sessions.db")
 
-	users := []User{{
+	users := []webUser{{
 		Name:         "name",
 		PasswordHash: "$2y$05$..vyzAECIhJPfaQiOK17IukcQnqEgKJHy0iETyYqxn3YXJl8yZuo2",
 	}}
 	a := InitAuth(fn, nil, 60, nil)
 	s := session{}
 
-	user := User{Name: "name"}
+	user := webUser{Name: "name"}
 	a.UserAdd(&user, "password")
 
 	assert.Equal(t, checkSessionNotFound, a.checkSession("notfound"))
@@ -84,7 +84,8 @@ func TestAuth(t *testing.T) {
 	a.storeSession(sess, &s)
 	a.Close()
 
-	u := a.UserFind("name", "password")
+	u, ok := a.findUser("name", "password")
+	assert.True(t, ok)
 	assert.NotEmpty(t, u.Name)
 
 	time.Sleep(3 * time.Second)
@@ -118,7 +119,7 @@ func TestAuthHTTP(t *testing.T) {
 	dir := t.TempDir()
 	fn := filepath.Join(dir, "sessions.db")
 
-	users := []User{
+	users := []webUser{
 		{Name: "name", PasswordHash: "$2y$05$..vyzAECIhJPfaQiOK17IukcQnqEgKJHy0iETyYqxn3YXJl8yZuo2"},
 	}
 	Context.auth = InitAuth(fn, users, 60, nil)
@@ -150,18 +151,19 @@ func TestAuthHTTP(t *testing.T) {
 	assert.True(t, handlerCalled)
 
 	// perform login
-	cookie, err := Context.auth.httpCookie(loginJSON{Name: "name", Password: "password"}, "")
+	cookie, err := Context.auth.newCookie(loginJSON{Name: "name", Password: "password"}, "")
 	require.NoError(t, err)
-	assert.NotEmpty(t, cookie)
+	require.NotNil(t, cookie)
 
 	// get /
 	handler2 = optionalAuth(handler)
 	w.hdr = make(http.Header)
-	r.Header.Set("Cookie", cookie)
+	r.Header.Set("Cookie", cookie.String())
 	r.URL = &url.URL{Path: "/"}
 	handlerCalled = false
 	handler2(&w, &r)
 	assert.True(t, handlerCalled)
+
 	r.Header.Del("Cookie")
 
 	// get / with basic auth
@@ -177,7 +179,7 @@ func TestAuthHTTP(t *testing.T) {
 	// get login page with a valid cookie - we're redirected to /
 	handler2 = optionalAuth(handler)
 	w.hdr = make(http.Header)
-	r.Header.Set("Cookie", cookie)
+	r.Header.Set("Cookie", cookie.String())
 	r.URL = &url.URL{Path: loginURL}
 	handlerCalled = false
 	handler2(&w, &r)
diff --git a/internal/home/clientshttp.go b/internal/home/clientshttp.go
index 5f10ccbe..59821d0a 100644
--- a/internal/home/clientshttp.go
+++ b/internal/home/clientshttp.go
@@ -93,13 +93,7 @@ func (clients *clientsContainer) handleGetClients(w http.ResponseWriter, r *http
 
 	data.Tags = clientTags
 
-	w.Header().Set("Content-Type", "application/json")
-	e := json.NewEncoder(w).Encode(data)
-	if e != nil {
-		aghhttp.Error(r, w, http.StatusInternalServerError, "failed to encode to json: %v", e)
-
-		return
-	}
+	_ = aghhttp.WriteJSONResponse(w, r, data)
 }
 
 // Convert JSON object to Client object
@@ -249,11 +243,7 @@ func (clients *clientsContainer) handleFindClient(w http.ResponseWriter, r *http
 		})
 	}
 
-	w.Header().Set("Content-Type", "application/json")
-	err := json.NewEncoder(w).Encode(data)
-	if err != nil {
-		aghhttp.Error(r, w, http.StatusInternalServerError, "Couldn't write response: %s", err)
-	}
+	_ = aghhttp.WriteJSONResponse(w, r, data)
 }
 
 // findRuntime looks up the IP in runtime and temporary storages, like
diff --git a/internal/home/config.go b/internal/home/config.go
index ff597761..ed337c86 100644
--- a/internal/home/config.go
+++ b/internal/home/config.go
@@ -85,10 +85,10 @@ type configuration struct {
 	// It's reset after config is parsed
 	fileData []byte
 
-	BindHost     net.IP `yaml:"bind_host"`      // BindHost is the IP address of the HTTP server to bind to
-	BindPort     int    `yaml:"bind_port"`      // BindPort is the port the HTTP server
-	BetaBindPort int    `yaml:"beta_bind_port"` // BetaBindPort is the port for new client
-	Users        []User `yaml:"users"`          // Users that can access HTTP server
+	BindHost     net.IP    `yaml:"bind_host"`      // BindHost is the IP address of the HTTP server to bind to
+	BindPort     int       `yaml:"bind_port"`      // BindPort is the port the HTTP server
+	BetaBindPort int       `yaml:"beta_bind_port"` // BetaBindPort is the port for new client
+	Users        []webUser `yaml:"users"`          // Users that can access HTTP server
 	// AuthAttempts is the maximum number of failed login attempts a user
 	// can do before being blocked.
 	AuthAttempts uint `yaml:"auth_attempts"`
diff --git a/internal/home/control.go b/internal/home/control.go
index 829063e9..881e957a 100644
--- a/internal/home/control.go
+++ b/internal/home/control.go
@@ -146,13 +146,7 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
 		resp.IsDHCPAvailable = Context.dhcpServer != nil
 	}
 
-	w.Header().Set("Content-Type", "application/json")
-	err = json.NewEncoder(w).Encode(resp)
-	if err != nil {
-		aghhttp.Error(r, w, http.StatusInternalServerError, "Unable to write response json: %s", err)
-
-		return
-	}
+	_ = aghhttp.WriteJSONResponse(w, r, resp)
 }
 
 type profileJSON struct {
@@ -162,13 +156,16 @@ type profileJSON struct {
 func handleGetProfile(w http.ResponseWriter, r *http.Request) {
 	pj := profileJSON{}
 	u := Context.auth.getCurrentUser(r)
+
 	pj.Name = u.Name
 
 	data, err := json.Marshal(pj)
 	if err != nil {
 		aghhttp.Error(r, w, http.StatusInternalServerError, "json.Marshal: %s", err)
+
 		return
 	}
+
 	_, _ = w.Write(data)
 }
 
@@ -207,11 +204,24 @@ func ensure(method string, handler func(http.ResponseWriter, *http.Request)) fun
 		log.Debug("%s %v", r.Method, r.URL)
 
 		if r.Method != method {
-			http.Error(w, "This request must be "+method, http.StatusMethodNotAllowed)
+			aghhttp.Error(r, w, http.StatusMethodNotAllowed, "only %s is allowed", method)
+
 			return
 		}
 
 		if method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete {
+			if r.Header.Get(aghhttp.HdrNameContentType) != aghhttp.HdrValApplicationJSON {
+				aghhttp.Error(
+					r,
+					w,
+					http.StatusUnsupportedMediaType,
+					"only %s is allowed",
+					aghhttp.HdrValApplicationJSON,
+				)
+
+				return
+			}
+
 			Context.controlLock.Lock()
 			defer Context.controlLock.Unlock()
 		}
diff --git a/internal/home/controlinstall.go b/internal/home/controlinstall.go
index c46f3459..78de64f7 100644
--- a/internal/home/controlinstall.go
+++ b/internal/home/controlinstall.go
@@ -59,19 +59,7 @@ func (web *Web) handleInstallGetAddresses(w http.ResponseWriter, r *http.Request
 		data.Interfaces[iface.Name] = iface
 	}
 
-	w.Header().Set("Content-Type", "application/json")
-	err = json.NewEncoder(w).Encode(data)
-	if err != nil {
-		aghhttp.Error(
-			r,
-			w,
-			http.StatusInternalServerError,
-			"Unable to marshal default addresses to json: %s",
-			err,
-		)
-
-		return
-	}
+	_ = aghhttp.WriteJSONResponse(w, r, data)
 }
 
 type checkConfReqEnt struct {
@@ -201,13 +189,7 @@ func (web *Web) handleInstallCheckConfig(w http.ResponseWriter, r *http.Request)
 		resp.StaticIP = handleStaticIP(req.DNS.IP, req.SetStaticIP)
 	}
 
-	w.Header().Set("Content-Type", "application/json")
-	err = json.NewEncoder(w).Encode(resp)
-	if err != nil {
-		aghhttp.Error(r, w, http.StatusInternalServerError, "encoding the response: %s", err)
-
-		return
-	}
+	_ = aghhttp.WriteJSONResponse(w, r, resp)
 }
 
 // handleStaticIP - handles static IP request
@@ -424,7 +406,7 @@ func (web *Web) handleInstallConfigure(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	u := &User{
+	u := &webUser{
 		Name: req.Username,
 	}
 	Context.auth.UserAdd(u, req.Password)
@@ -688,19 +670,7 @@ func (web *Web) handleInstallGetAddressesBeta(w http.ResponseWriter, r *http.Req
 
 	data.Interfaces = ifaces
 
-	w.Header().Set("Content-Type", "application/json")
-	err = json.NewEncoder(w).Encode(data)
-	if err != nil {
-		aghhttp.Error(
-			r,
-			w,
-			http.StatusInternalServerError,
-			"Unable to marshal default addresses to json: %s",
-			err,
-		)
-
-		return
-	}
+	_ = aghhttp.WriteJSONResponse(w, r, data)
 }
 
 // registerBetaInstallHandlers registers the install handlers for new client
diff --git a/internal/home/controlupdate.go b/internal/home/controlupdate.go
index 91164696..ef4f0659 100644
--- a/internal/home/controlupdate.go
+++ b/internal/home/controlupdate.go
@@ -28,8 +28,6 @@ type temporaryError interface {
 
 // Get the latest available version from the Internet
 func handleGetVersionJSON(w http.ResponseWriter, r *http.Request) {
-	w.Header().Set("Content-Type", "application/json")
-
 	resp := &versionResponse{}
 	if Context.disableUpdate {
 		resp.Disabled = true
@@ -71,10 +69,7 @@ func handleGetVersionJSON(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	err = json.NewEncoder(w).Encode(resp)
-	if err != nil {
-		aghhttp.Error(r, w, http.StatusInternalServerError, "writing body: %s", err)
-	}
+	_ = aghhttp.WriteJSONResponse(w, r, resp)
 }
 
 // requestVersionInfo sets the VersionInfo field of resp if it can reach the
diff --git a/internal/home/home.go b/internal/home/home.go
index 0f88f57b..f917b6a5 100644
--- a/internal/home/home.go
+++ b/internal/home/home.go
@@ -409,7 +409,7 @@ func run(args options, clientBuildFS fs.FS) {
 	configureLogger(args)
 
 	// Print the first message after logger is configured.
-	log.Println(version.Full())
+	log.Info(version.Full())
 	log.Debug("current working directory is %s", Context.workDir)
 	if args.runningAsService {
 		log.Info("AdGuard Home is running as a service")
@@ -455,9 +455,9 @@ func run(args options, clientBuildFS fs.FS) {
 
 	sessFilename := filepath.Join(Context.getDataDir(), "sessions.db")
 	GLMode = args.glinetMode
-	var arl *authRateLimiter
+	var rateLimiter *authRateLimiter
 	if config.AuthAttempts > 0 && config.AuthBlockMin > 0 {
-		arl = newAuthRateLimiter(
+		rateLimiter = newAuthRateLimiter(
 			time.Duration(config.AuthBlockMin)*time.Minute,
 			config.AuthAttempts,
 		)
@@ -469,7 +469,7 @@ func run(args options, clientBuildFS fs.FS) {
 		sessFilename,
 		config.Users,
 		config.WebSessionTTLHours*60*60,
-		arl,
+		rateLimiter,
 	)
 	if Context.auth == nil {
 		log.Fatalf("Couldn't initialize Auth module")
diff --git a/internal/home/i18n.go b/internal/home/i18n.go
index d58dfcf3..cc8da8fe 100644
--- a/internal/home/i18n.go
+++ b/internal/home/i18n.go
@@ -1,10 +1,8 @@
 package home
 
 import (
-	"fmt"
-	"io"
+	"encoding/json"
 	"net/http"
-	"strings"
 
 	"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
 	"github.com/AdguardTeam/golibs/log"
@@ -51,43 +49,35 @@ var allowedLanguages = stringutil.NewSet(
 	"zh-tw",
 )
 
-func handleI18nCurrentLanguage(w http.ResponseWriter, _ *http.Request) {
-	w.Header().Set("Content-Type", "text/plain")
-	log.Printf("config.Language is %s", config.Language)
-	_, err := fmt.Fprintf(w, "%s\n", config.Language)
-	if err != nil {
-		msg := fmt.Sprintf("Unable to write response json: %s", err)
-		log.Println(msg)
-		http.Error(w, msg, http.StatusInternalServerError)
+// languageJSON is the JSON structure for language requests and responses.
+type languageJSON struct {
+	Language string `json:"language"`
+}
 
-		return
-	}
+func handleI18nCurrentLanguage(w http.ResponseWriter, r *http.Request) {
+	log.Printf("home: language is %s", config.Language)
+
+	_ = aghhttp.WriteJSONResponse(w, r, &languageJSON{
+		Language: config.Language,
+	})
 }
 
 func handleI18nChangeLanguage(w http.ResponseWriter, r *http.Request) {
-	// This use of ReadAll is safe, because request's body is now limited.
-	body, err := io.ReadAll(r.Body)
+	if aghhttp.WriteTextPlainDeprecated(w, r) {
+		return
+	}
+
+	langReq := &languageJSON{}
+	err := json.NewDecoder(r.Body).Decode(langReq)
 	if err != nil {
-		msg := fmt.Sprintf("failed to read request body: %s", err)
-		log.Println(msg)
-		http.Error(w, msg, http.StatusBadRequest)
+		aghhttp.Error(r, w, http.StatusInternalServerError, "reading req: %s", err)
 
 		return
 	}
 
-	language := strings.TrimSpace(string(body))
-	if language == "" {
-		msg := "empty language specified"
-		log.Println(msg)
-		http.Error(w, msg, http.StatusBadRequest)
-
-		return
-	}
-
-	if !allowedLanguages.Has(language) {
-		msg := fmt.Sprintf("unknown language specified: %s", language)
-		log.Println(msg)
-		http.Error(w, msg, http.StatusBadRequest)
+	lang := langReq.Language
+	if !allowedLanguages.Has(lang) {
+		aghhttp.Error(r, w, http.StatusBadRequest, "unknown language: %q", lang)
 
 		return
 	}
@@ -96,7 +86,8 @@ func handleI18nChangeLanguage(w http.ResponseWriter, r *http.Request) {
 		config.Lock()
 		defer config.Unlock()
 
-		config.Language = language
+		config.Language = lang
+		log.Printf("home: language is set to %s", lang)
 	}()
 
 	onConfigModified()
diff --git a/internal/home/service.go b/internal/home/service.go
index c670ebe2..e52f9799 100644
--- a/internal/home/service.go
+++ b/internal/home/service.go
@@ -176,7 +176,8 @@ func handleServiceControlAction(opts options, clientBuildFS fs.FS) {
 	chooseSystem()
 
 	action := opts.serviceControlAction
-	log.Printf("service: control action: %s", action)
+	log.Info(version.Full())
+	log.Info("service: control action: %s", action)
 
 	if action == "reload" {
 		sendSigReload()
diff --git a/internal/home/tls.go b/internal/home/tls.go
index b454e152..359a7a9d 100644
--- a/internal/home/tls.go
+++ b/internal/home/tls.go
@@ -680,8 +680,6 @@ func unmarshalTLS(r *http.Request) (tlsConfigSettingsExt, error) {
 }
 
 func marshalTLS(w http.ResponseWriter, r *http.Request, data tlsConfig) {
-	w.Header().Set("Content-Type", "application/json")
-
 	if data.CertificateChain != "" {
 		encoded := base64.StdEncoding.EncodeToString([]byte(data.CertificateChain))
 		data.CertificateChain = encoded
@@ -692,16 +690,7 @@ func marshalTLS(w http.ResponseWriter, r *http.Request, data tlsConfig) {
 		data.PrivateKey = ""
 	}
 
-	err := json.NewEncoder(w).Encode(data)
-	if err != nil {
-		aghhttp.Error(
-			r,
-			w,
-			http.StatusInternalServerError,
-			"Failed to marshal json with TLS status: %s",
-			err,
-		)
-	}
+	_ = aghhttp.WriteJSONResponse(w, r, data)
 }
 
 // registerWebHandlers registers HTTP handlers for TLS configuration
diff --git a/internal/home/upgrade.go b/internal/home/upgrade.go
index 63e17030..132a82af 100644
--- a/internal/home/upgrade.go
+++ b/internal/home/upgrade.go
@@ -278,11 +278,11 @@ func upgradeSchema4to5(diskConf yobj) error {
 		log.Fatalf("Can't use password \"%s\": bcrypt.GenerateFromPassword: %s", passStr, err)
 		return nil
 	}
-	u := User{
+	u := webUser{
 		Name:         nameStr,
 		PasswordHash: string(hash),
 	}
-	users := []User{u}
+	users := []webUser{u}
 	diskConf["users"] = users
 	return nil
 }
diff --git a/openapi/CHANGELOG.md b/openapi/CHANGELOG.md
index fbbe7169..d9946115 100644
--- a/openapi/CHANGELOG.md
+++ b/openapi/CHANGELOG.md
@@ -4,6 +4,64 @@
 
 ## v0.108.0: API changes
 
+
+
+## v0.107.14: BREAKING API CHANGES
+
+A Cross-Site Request Forgery (CSRF) vulnerability has been discovered.  We have
+implemented several measures to prevent such vulnerabilities in the future, but
+some of these measures break backwards compatibility for the sake of better
+protection.
+
+All new formats for the request and response bodies are documented in
+`openapi.yaml`.
+
+### `POST /control/filtering/set_rules` And Other Plain-Text APIs
+
+The following APIs, which previously accepted or returned `text/plain` data,
+now accept or return data as JSON.
+
+#### `POST /control/filtering/set_rules`
+
+Previously, the API accepted a raw list of filters as a plain-text file.  Now,
+the filters must be presented in a JSON object with the following format:
+
+```json
+{
+  "rules":
+  [
+    "||example.com^",
+    "# comment",
+    "@@||www.example.com^"
+  ]
+}
+```
+
+#### `GET /control/i18n/current_language` And `POST /control/i18n/change_language`
+
+Previously, these APIs accepted and returned the language code in plain text.
+Now, they accept and return them in a JSON object with the following format:
+
+```json
+{
+  "language": "en"
+}
+```
+
+#### `POST /control/dhcp/find_active_dhcp`
+
+Previously, the API accepted the name of the network interface as a plain-text
+string.  Now, it must be contained within a JSON object with the following
+format:
+
+```json
+{
+  "interface": "eth0"
+}
+```
+
+
+
 ## v0.107.12: API changes
 
 ### `GET /control/blocked_services/services`
@@ -11,6 +69,8 @@
 * The new `GET /control/blocked_services/services` HTTP API allows inspecting
   all available services.
 
+
+
 ## v0.107.7: API changes
 
 ### The new optional field `"ecs"` in `QueryLogItem`
@@ -24,6 +84,8 @@
   `POST /install/configure` which means that the specified password does not
   meet the strength requirements.
 
+
+
 ## v0.107.3: API changes
 
 ### The new field `"version"` in `AddressesInfo`
@@ -31,6 +93,8 @@
 * The new field `"version"` in `GET /install/get_addresses` is the version of
   the AdGuard Home instance.
 
+
+
 ## v0.107.0: API changes
 
 ### The new field `"cached"` in `QueryLogItem`
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index ad57d807..c6451fa7 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -413,6 +413,11 @@
       - 'dhcp'
       'operationId': 'checkActiveDhcp'
       'summary': 'Searches for an active DHCP server on the network'
+      'requestBody':
+        'content':
+          'application/json':
+            'schema':
+              '$ref': '#/components/schemas/DhcpFindActiveReq'
       'responses':
         '200':
           'description': 'OK.'
@@ -667,24 +672,6 @@
       - 'parental'
       'operationId': 'parentalEnable'
       'summary': 'Enable parental filtering'
-      'requestBody':
-        'content':
-          'text/plain':
-            'schema':
-              'type': 'string'
-              'enum':
-              - 'EARLY_CHILDHOOD'
-              - 'YOUNG'
-              - 'TEEN'
-              - 'MATURE'
-              'example': 'sensitivity=TEEN'
-        'description': |
-          Age sensitivity for parental filtering,
-          EARLY_CHILDHOOD is 3
-          YOUNG is 10
-          TEEN is 13
-          MATURE is 17
-        'required': true
       'responses':
         '200':
           'description': 'OK.'
@@ -958,10 +945,9 @@
         Change current language.  Argument must be an ISO 639-1 two-letter code.
       'requestBody':
         'content':
-          'text/plain':
+          'application/json':
             'schema':
-              'type': 'string'
-              'example': 'en'
+              '$ref': '#/components/schemas/LanguageSettings'
         'description': >
           New language.  It must be known to the server and must be an ISO 639-1
           two-letter code.
@@ -980,10 +966,9 @@
         '200':
           'description': 'OK.'
           'content':
-            'text/plain':
-              'examples':
-                'response':
-                  'value': 'en'
+            'application/json':
+              'schema':
+                '$ref': '#/components/schemas/LanguageSettings'
   '/install/get_addresses_beta':
     'get':
       'tags':
@@ -1777,6 +1762,16 @@
       'additionalProperties':
         '$ref': '#/components/schemas/NetInterface'
 
+    'DhcpFindActiveReq':
+      'description': >
+        Request for checking for other DHCP servers in the network.
+      'properties':
+        'interface':
+          'description': 'The name of the network interface'
+          'example': 'eth0'
+          'type': 'string'
+      'type': 'object'
+
     'DhcpSearchResult':
       'type': 'object'
       'description': >
@@ -2692,6 +2687,15 @@
           'description': 'The error message, an opaque string.'
           'type': 'string'
       'type': 'object'
+    'LanguageSettings':
+      'description': 'Language settings object.'
+      'properties':
+        'language':
+          'description': 'The current language or the language to set.'
+          'type': 'string'
+      'required':
+      - 'language'
+      'type': 'object'
   'securitySchemes':
     'basicAuth':
       'type': 'http'