VYPR
Moderate severityNVD Advisory· Published Oct 11, 2022· Updated May 20, 2025

AdGuardHome - CSRF

CVE-2022-32175

Description

In AdGuardHome, versions v0.95 through v0.108.0-b.13 are vulnerable to Cross-Site Request Forgery (CSRF), in the custom filtering rules functionality. An attacker can persuade an authorized user to follow a malicious link, resulting in deleting/modifying the custom filtering rules.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/AdguardTeam/AdGuardHomeGo
>= 0.95, < 0.108.0-b.160.108.0-b.16

Affected products

1

Patches

1
756b14a61de1

Pull request: HOFTIX-csrf

https://github.com/AdguardTeam/AdGuardHomeAinar GaripovSep 29, 2022via ghsa
23 files changed · +490 340
  • CHANGELOG.md+35 0 modified
    @@ -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]).
     
    
  • client/src/actions/filtering.js+3 1 modified
    @@ -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());
    
  • client/src/actions/index.js+7 4 modified
    @@ -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;
    
  • client/src/api/Api.js+8 13 modified
    @@ -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);
         }
    
  • internal/aghhttp/aghhttp.go+39 0 modified
    @@ -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
    +}
    
  • internal/aghhttp/header.go+22 0 added
    @@ -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"
    +)
    
  • internal/dhcpd/http_unix.go+29 28 modified
    @@ -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,31 +463,28 @@ 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"
     		result.V4.OtherServer.Error = err4.Error()
     	} 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) {
    
  • internal/filtering/http.go+13 6 renamed
    @@ -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)
     }
    
  • internal/home/auth.go+154 142 modified
    @@ -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,19 +433,18 @@ 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)
     
     			return
     		}
     	}
     
    -	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)
    +
    +		return
    +	}
     
    -	Context.auth.RemoveSession(sess)
    +	Context.auth.RemoveSession(c.Value)
     
    -	w.Header().Set("Location", "/login.html")
    +	c = &http.Cookie{
    +		Name:    sessionCookieName,
    +		Value:   "",
    +		Path:    "/",
    +		Expires: time.Unix(0, 0),
     
    -	s := fmt.Sprintf("%s=; Path=/; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:00 GMT",
    -		sessionCookieName)
    -	w.Header().Set("Set-Cookie", s)
    +		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")
     			}
     		}
    +	} else {
    +		res := Context.auth.checkSession(cookie.Value)
    +		isAuthenticated = res == checkSessionOK
    +		if !isAuthenticated {
    +			log.Debug("auth: invalid cookie value: %s", cookie)
    +		}
     	}
    -	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)
    -			}
    +
    +	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 {
    -			w.WriteHeader(http.StatusForbidden)
    -			_, _ = w.Write([]byte("Forbidden"))
    +			log.Debug("auth: redirected to login page")
    +			w.Header().Set("Location", "/login.html")
    +			w.WriteHeader(http.StatusFound)
     		}
    -		authFirst = true
    +	} else {
    +		log.Debug("auth: responded with forbidden to %s %s", r.Method, p)
    +		w.WriteHeader(http.StatusForbidden)
    +		_, _ = w.Write([]byte("Forbidden"))
     	}
     
    -	return authFirst
    +	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,52 +639,56 @@ 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()
     	defer a.lock.Unlock()
     
     	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()
    
  • internal/home/auth_test.go+10 8 modified
    @@ -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)
    
  • internal/home/clientshttp.go+2 12 modified
    @@ -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
    
  • internal/home/config.go+4 4 modified
    @@ -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"`
    
  • internal/home/control.go+18 8 modified
    @@ -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()
     		}
    
  • internal/home/controlinstall.go+4 34 modified
    @@ -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
    
  • internal/home/controlupdate.go+1 6 modified
    @@ -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
    
  • internal/home/home.go+4 4 modified
    @@ -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")
    
  • internal/home/i18n.go+21 30 modified
    @@ -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 err != nil {
    -		msg := fmt.Sprintf("failed to read request body: %s", err)
    -		log.Println(msg)
    -		http.Error(w, msg, http.StatusBadRequest)
    -
    +	if aghhttp.WriteTextPlainDeprecated(w, r) {
     		return
     	}
     
    -	language := strings.TrimSpace(string(body))
    -	if language == "" {
    -		msg := "empty language specified"
    -		log.Println(msg)
    -		http.Error(w, msg, http.StatusBadRequest)
    +	langReq := &languageJSON{}
    +	err := json.NewDecoder(r.Body).Decode(langReq)
    +	if err != nil {
    +		aghhttp.Error(r, w, http.StatusInternalServerError, "reading req: %s", err)
     
     		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()
    
  • internal/home/service.go+2 1 modified
    @@ -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()
    
  • internal/home/tls.go+1 12 modified
    @@ -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
    
  • internal/home/upgrade.go+2 2 modified
    @@ -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
     }
    
  • openapi/CHANGELOG.md+64 0 modified
    @@ -4,13 +4,73 @@
     
     ## 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`
     
     * 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,13 +84,17 @@
       `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`
     
     * 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`
    
  • openapi/openapi.yaml+29 25 modified
    @@ -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'
    
  • SECURITY.md+18 0 added
    @@ -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.
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.