Pull request: 4970-error-415

Updates #4970.

Squashed commit of the following:

commit 10365d9c8474e9d9735f581fb32b2892b2153cc4
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Fri Sep 30 14:23:06 2022 +0300

    all: imp docs, names

commit cff1103a0618a6430dc91e7e018febbf313c12ba
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Fri Sep 30 14:02:38 2022 +0300

    home: imp content-type check
This commit is contained in:
Ainar Garipov 2022-09-30 14:41:25 +03:00
parent 7b48863041
commit 4d404b887f
7 changed files with 122 additions and 60 deletions

View file

@ -15,6 +15,19 @@ and this project adheres to
## [v0.108.0] - TBA (APPROX.) ## [v0.108.0] - TBA (APPROX.)
--> -->
### Security
- As an additional CSRF protection measure, AdGuard Home now ensures that
requests that change its state but have no body (such as `POST
/control/stats_reset` requests) do not have a `Content-Type` header set on
them ([#4970]).
### Fixed
- `only application/json is allowed` errors in various APIs ([#4970]).
[#4970]: https://github.com/AdguardTeam/AdGuardHome/issues/4970
<!-- <!--
@ -63,8 +76,8 @@ bodies are documented in `openapi/openapi.yaml` and `openapi/CHANGELOG.md`.
#### Stricter Content-Type Checks (BREAKING API CHANGE) #### Stricter Content-Type Checks (BREAKING API CHANGE)
All JSON APIs now check if the request actually has the `application/json` All JSON APIs that expect a body now check if the request actually has
content-type. `Content-Type` set to `application/json`.
#### Other Security Changes #### Other Security Changes

View file

@ -10,11 +10,17 @@ class Api {
async makeRequest(path, method = 'POST', config) { async makeRequest(path, method = 'POST', config) {
const url = `${this.baseUrl}/${path}`; const url = `${this.baseUrl}/${path}`;
const axiosConfig = config || {};
if (method !== 'GET' && axiosConfig.data) {
axiosConfig.headers = axiosConfig.headers || {};
axiosConfig.headers['Content-Type'] = axiosConfig.headers['Content-Type'] || 'application/json';
}
try { try {
const response = await axios({ const response = await axios({
url, url,
method, method,
...config, ...axiosConfig,
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
@ -55,7 +61,6 @@ class Api {
const { path, method } = this.GLOBAL_TEST_UPSTREAM_DNS; const { path, method } = this.GLOBAL_TEST_UPSTREAM_DNS;
const config = { const config = {
data: servers, data: servers,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, config); return this.makeRequest(path, method, config);
} }
@ -64,7 +69,6 @@ class Api {
const { path, method } = this.GLOBAL_VERSION; const { path, method } = this.GLOBAL_VERSION;
const config = { const config = {
data, data,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, config); return this.makeRequest(path, method, config);
} }
@ -100,7 +104,6 @@ class Api {
const { path, method } = this.FILTERING_REFRESH; const { path, method } = this.FILTERING_REFRESH;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
@ -110,7 +113,6 @@ class Api {
const { path, method } = this.FILTERING_ADD_FILTER; const { path, method } = this.FILTERING_ADD_FILTER;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
@ -120,7 +122,6 @@ class Api {
const { path, method } = this.FILTERING_REMOVE_FILTER; const { path, method } = this.FILTERING_REMOVE_FILTER;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
@ -130,7 +131,6 @@ class Api {
const { path, method } = this.FILTERING_SET_RULES; const { path, method } = this.FILTERING_SET_RULES;
const parameters = { const parameters = {
data: rules, data: rules,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@ -139,7 +139,6 @@ class Api {
const { path, method } = this.FILTERING_CONFIG; const { path, method } = this.FILTERING_CONFIG;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@ -148,7 +147,6 @@ class Api {
const { path, method } = this.FILTERING_SET_URL; const { path, method } = this.FILTERING_SET_URL;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@ -239,7 +237,6 @@ class Api {
const { path, method } = this.CHANGE_LANGUAGE; const { path, method } = this.CHANGE_LANGUAGE;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@ -275,7 +272,6 @@ class Api {
const { path, method } = this.DHCP_SET_CONFIG; const { path, method } = this.DHCP_SET_CONFIG;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@ -284,7 +280,6 @@ class Api {
const { path, method } = this.DHCP_FIND_ACTIVE; const { path, method } = this.DHCP_FIND_ACTIVE;
const parameters = { const parameters = {
data: req, data: req,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@ -293,7 +288,6 @@ class Api {
const { path, method } = this.DHCP_ADD_STATIC_LEASE; const { path, method } = this.DHCP_ADD_STATIC_LEASE;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@ -302,7 +296,6 @@ class Api {
const { path, method } = this.DHCP_REMOVE_STATIC_LEASE; const { path, method } = this.DHCP_REMOVE_STATIC_LEASE;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@ -333,7 +326,6 @@ class Api {
const { path, method } = this.INSTALL_CONFIGURE; const { path, method } = this.INSTALL_CONFIGURE;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@ -342,7 +334,6 @@ class Api {
const { path, method } = this.INSTALL_CHECK_CONFIG; const { path, method } = this.INSTALL_CHECK_CONFIG;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@ -363,7 +354,6 @@ class Api {
const { path, method } = this.TLS_CONFIG; const { path, method } = this.TLS_CONFIG;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@ -372,7 +362,6 @@ class Api {
const { path, method } = this.TLS_VALIDATE; const { path, method } = this.TLS_VALIDATE;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@ -397,7 +386,6 @@ class Api {
const { path, method } = this.ADD_CLIENT; const { path, method } = this.ADD_CLIENT;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@ -406,7 +394,6 @@ class Api {
const { path, method } = this.DELETE_CLIENT; const { path, method } = this.DELETE_CLIENT;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@ -415,7 +402,6 @@ class Api {
const { path, method } = this.UPDATE_CLIENT; const { path, method } = this.UPDATE_CLIENT;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@ -440,7 +426,6 @@ class Api {
const { path, method } = this.ACCESS_SET; const { path, method } = this.ACCESS_SET;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@ -461,7 +446,6 @@ class Api {
const { path, method } = this.REWRITE_ADD; const { path, method } = this.REWRITE_ADD;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@ -470,7 +454,6 @@ class Api {
const { path, method } = this.REWRITE_DELETE; const { path, method } = this.REWRITE_DELETE;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@ -496,7 +479,6 @@ class Api {
const { path, method } = this.BLOCKED_SERVICES_SET; const { path, method } = this.BLOCKED_SERVICES_SET;
const parameters = { const parameters = {
data: config, data: config,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
@ -524,7 +506,6 @@ class Api {
const { path, method } = this.STATS_CONFIG; const { path, method } = this.STATS_CONFIG;
const config = { const config = {
data, data,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, config); return this.makeRequest(path, method, config);
} }
@ -560,7 +541,6 @@ class Api {
const { path, method } = this.QUERY_LOG_CONFIG; const { path, method } = this.QUERY_LOG_CONFIG;
const config = { const config = {
data, data,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, config); return this.makeRequest(path, method, config);
} }
@ -577,7 +557,6 @@ class Api {
const { path, method } = this.LOGIN; const { path, method } = this.LOGIN;
const config = { const config = {
data, data,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, config); return this.makeRequest(path, method, config);
} }
@ -604,7 +583,6 @@ class Api {
const { path, method } = this.SET_DNS_CONFIG; const { path, method } = this.SET_DNS_CONFIG;
const config = { const config = {
data, data,
headers: { 'Content-Type': 'application/json' },
}; };
return this.makeRequest(path, method, config); return this.makeRequest(path, method, config);
} }

View file

@ -33,7 +33,7 @@ func OK(w http.ResponseWriter) {
// Error writes formatted message to w and also logs it. // Error writes formatted message to w and also logs it.
func Error(r *http.Request, w http.ResponseWriter, code int, format string, args ...any) { func Error(r *http.Request, w http.ResponseWriter, code int, format string, args ...any) {
text := fmt.Sprintf(format, args...) text := fmt.Sprintf(format, args...)
log.Error("%s %s: %s", r.Method, r.URL, text) log.Error("%s %s %s: %s", r.Method, r.Host, r.URL, text)
http.Error(w, text, code) http.Error(w, text, code)
} }

View file

@ -8,8 +8,8 @@ package aghhttp
const ( const (
HdrNameAcceptEncoding = "Accept-Encoding" HdrNameAcceptEncoding = "Accept-Encoding"
HdrNameAccessControlAllowOrigin = "Access-Control-Allow-Origin" HdrNameAccessControlAllowOrigin = "Access-Control-Allow-Origin"
HdrNameContentType = "Content-Type"
HdrNameContentEncoding = "Content-Encoding" HdrNameContentEncoding = "Content-Encoding"
HdrNameContentType = "Content-Type"
HdrNameServer = "Server" HdrNameServer = "Server"
HdrNameTrailer = "Trailer" HdrNameTrailer = "Trailer"
HdrNameUserAgent = "User-Agent" HdrNameUserAgent = "User-Agent"

View file

@ -8,6 +8,7 @@ import (
"net/url" "net/url"
"runtime" "runtime"
"strings" "strings"
"time"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp" "github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/AdGuardHome/internal/aghnet" "github.com/AdguardTeam/AdGuardHome/internal/aghnet"
@ -97,6 +98,8 @@ func collectDNSAddresses() (addrs []string, err error) {
// statusResponse is a response for /control/status endpoint. // statusResponse is a response for /control/status endpoint.
type statusResponse struct { type statusResponse struct {
Version string `json:"version"`
Language string `json:"language"`
DNSAddrs []string `json:"dns_addresses"` DNSAddrs []string `json:"dns_addresses"`
DNSPort int `json:"dns_port"` DNSPort int `json:"dns_port"`
HTTPPort int `json:"http_port"` HTTPPort int `json:"http_port"`
@ -105,8 +108,6 @@ type statusResponse struct {
// openapi.yaml declares. // openapi.yaml declares.
IsDHCPAvailable bool `json:"dhcp_available"` IsDHCPAvailable bool `json:"dhcp_available"`
IsRunning bool `json:"running"` IsRunning bool `json:"running"`
Version string `json:"version"`
Language string `json:"language"`
} }
func handleStatus(w http.ResponseWriter, r *http.Request) { func handleStatus(w http.ResponseWriter, r *http.Request) {
@ -125,12 +126,12 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
defer config.RUnlock() defer config.RUnlock()
resp = statusResponse{ resp = statusResponse{
Version: version.Version(),
DNSAddrs: dnsAddrs, DNSAddrs: dnsAddrs,
DNSPort: config.DNS.Port, DNSPort: config.DNS.Port,
HTTPPort: config.BindPort, HTTPPort: config.BindPort,
IsRunning: isRunning(),
Version: version.Version(),
Language: config.Language, Language: config.Language,
IsRunning: isRunning(),
} }
}() }()
@ -196,29 +197,26 @@ func httpRegister(method, url string, handler http.HandlerFunc) {
Context.mux.Handle(url, postInstallHandler(optionalAuthHandler(gziphandler.GzipHandler(ensureHandler(method, handler))))) Context.mux.Handle(url, postInstallHandler(optionalAuthHandler(gziphandler.GzipHandler(ensureHandler(method, handler)))))
} }
// ---------------------------------- // ensure returns a wrapped handler that makes sure that the request has the
// helper functions for HTTP handlers // correct method as well as additional method and header checks.
// ---------------------------------- func ensure(
func ensure(method string, handler func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { method string,
handler func(http.ResponseWriter, *http.Request),
) (wrapped func(http.ResponseWriter, *http.Request)) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
log.Debug("%s %v", r.Method, r.URL) start := time.Now()
m, u := r.Method, r.URL
log.Debug("started %s %s %s", m, r.Host, u)
defer func() { log.Debug("finished %s %s %s in %s", m, r.Host, u, time.Since(start)) }()
if r.Method != method { if m != method {
aghhttp.Error(r, w, http.StatusMethodNotAllowed, "only %s is allowed", method) aghhttp.Error(r, w, http.StatusMethodNotAllowed, "only method %s is allowed", method)
return return
} }
if method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete { if modifiesData(m) {
if r.Header.Get(aghhttp.HdrNameContentType) != aghhttp.HdrValApplicationJSON { if !ensureContentType(w, r) {
aghhttp.Error(
r,
w,
http.StatusUnsupportedMediaType,
"only %s is allowed",
aghhttp.HdrValApplicationJSON,
)
return return
} }
@ -230,6 +228,42 @@ func ensure(method string, handler func(http.ResponseWriter, *http.Request)) fun
} }
} }
// modifiesData returns true if m is an HTTP method that can modify data.
func modifiesData(m string) (ok bool) {
return m == http.MethodPost || m == http.MethodPut || m == http.MethodDelete
}
// ensureContentType makes sure that the content type of a data-modifying
// request is set correctly. If it is not, ensureContentType writes a response
// to w, and ok is false.
func ensureContentType(w http.ResponseWriter, r *http.Request) (ok bool) {
const statusUnsup = http.StatusUnsupportedMediaType
cType := r.Header.Get(aghhttp.HdrNameContentType)
if r.ContentLength == 0 {
if cType == "" {
return true
}
// Assume that browsers always send a content type when submitting HTML
// forms and require no content type for requests with no body to make
// sure that the request comes from JavaScript.
aghhttp.Error(r, w, statusUnsup, "empty body with content-type %q not allowed", cType)
return false
}
const wantCType = aghhttp.HdrValApplicationJSON
if cType == wantCType {
return true
}
aghhttp.Error(r, w, statusUnsup, "only content-type %s is allowed", wantCType)
return false
}
func ensurePOST(handler func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { func ensurePOST(handler func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
return ensure(http.MethodPost, handler) return ensure(http.MethodPost, handler)
} }

View file

@ -6,6 +6,28 @@
## v0.107.15: `POST` Requests Without Bodies
As an additional CSRF protection measure, AdGuard Home now ensures that requests
that change its state but have no body do not have a `Content-Type` header set
on them.
This concerns the following APIs:
* `POST /control/dhcp/reset_leases`;
* `POST /control/dhcp/reset`;
* `POST /control/parental/disable`;
* `POST /control/parental/enable`;
* `POST /control/querylog_clear`;
* `POST /control/safebrowsing/disable`;
* `POST /control/safebrowsing/enable`;
* `POST /control/safesearch/disable`;
* `POST /control/safesearch/enable`;
* `POST /control/stats_reset`;
* `POST /control/update`.
## v0.107.14: BREAKING API CHANGES ## v0.107.14: BREAKING API CHANGES
A Cross-Site Request Forgery (CSRF) vulnerability has been discovered. We have A Cross-Site Request Forgery (CSRF) vulnerability has been discovered. We have
@ -13,6 +35,9 @@ implemented several measures to prevent such vulnerabilities in the future, but
some of these measures break backwards compatibility for the sake of better some of these measures break backwards compatibility for the sake of better
protection. protection.
All JSON APIs that expect a body now check if the request actually has
`Content-Type` set to `application/json`.
All new formats for the request and response bodies are documented in All new formats for the request and response bodies are documented in
`openapi.yaml`. `openapi.yaml`.

View file

@ -601,11 +601,10 @@
'summary': 'Set user-defined filter rules' 'summary': 'Set user-defined filter rules'
'requestBody': 'requestBody':
'content': 'content':
'text/plain': 'application/json':
'schema': 'schema':
'type': 'string' '$ref': '#/components/schemas/SetRulesRequest'
'example': '@@||yandex.ru^|' 'description': 'Custom filtering rules.'
'description': 'All filtering rules, one line per rule'
'responses': 'responses':
'200': '200':
'description': 'OK.' 'description': 'OK.'
@ -1538,6 +1537,19 @@
'properties': 'properties':
'updated': 'updated':
'type': 'integer' 'type': 'integer'
'SetRulesRequest':
'description': 'Custom filtering rules setting request.'
'example':
'rules':
- '||example.com^'
- '# comment'
- '@@||www.example.com^'
'properties':
'rules':
'items':
'type': 'string'
'type': 'array'
'type': 'object'
'GetVersionRequest': 'GetVersionRequest':
'type': 'object' 'type': 'object'
'description': '/version.json request data' 'description': '/version.json request data'