From: Christian Hofstaedtler Date: Mon, 6 Oct 2014 21:51:01 +0000 (+0200) Subject: API: Replace HTTP Basic auth with static key in custom header X-Git-Tag: rec-3.7.0-rc1~189^2~19^2 X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=bbef8f04823bcd8b5f7bba9e319016d7df4359d0;p=thirdparty%2Fpdns.git API: Replace HTTP Basic auth with static key in custom header Given that the key is sent in a custom header, this should prevent any possible CSRF attacks. Fixes #1769. --- diff --git a/pdns/common_startup.cc b/pdns/common_startup.cc index ada2c885ad..7fdf047485 100644 --- a/pdns/common_startup.cc +++ b/pdns/common_startup.cc @@ -61,6 +61,7 @@ void declareArguments() ::arg().set("retrieval-threads", "Number of AXFR-retrieval threads for slave operation")="2"; ::arg().setSwitch("experimental-json-interface", "If the webserver should serve JSON data")="no"; ::arg().setSwitch("experimental-api-readonly", "If the JSON API should disallow data modification")="no"; + ::arg().set("experimental-api-key", "REST API Static authentication key (required for API use)")=""; ::arg().setSwitch("experimental-dname-processing", "If we should support DNAME records")="no"; ::arg().setCmd("help","Provide a helpful message"); diff --git a/pdns/docs/httpapi/README.md b/pdns/docs/httpapi/README.md index 4546e2b2cc..13e2f07331 100644 --- a/pdns/docs/httpapi/README.md +++ b/pdns/docs/httpapi/README.md @@ -4,8 +4,8 @@ PowerDNS API PowerDNS features a built-in API. For the Authoritative Server, starting with version 3.4, for the Recursor starting with version 3.6. -At the time of writing this, these versions were not released, but preliminary -support is available in git. +The released versions use the standard webserver password for authentication, +while newer versions use a static API key mechanism (shown below). You can get suitable packages for testing (RPM or DEB) from these links: @@ -23,18 +23,18 @@ PostgreSQL or SQLite3). Then configure as follows: experimental-json-interface=yes + experimental-api-key=changeme webserver=yes - webserver-password=changeme After restarting `pdns_server`, the following examples should start working: # List zones - curl -v http://a:changeme@127.0.0.1:8081/servers/localhost/zones | jq . + curl -v -H 'X-API-Key: changeme' http://127.0.0.1:8081/servers/localhost/zones | jq . # Create new zone "example.org" with nameservers ns1.example.org, ns2.example.org - curl -X POST --data '{"name":"example.org", "kind": "Native", "masters": [], "nameservers": ["ns1.example.org", "ns2.example.org"]}' -v http://a:changeme@127.0.0.1:8081/servers/localhost/zones | jq . + curl -X POST --data '{"name":"example.org", "kind": "Native", "masters": [], "nameservers": ["ns1.example.org", "ns2.example.org"]}' -v -H 'X-API-Key: changeme' http://127.0.0.1:8081/servers/localhost/zones | jq . # Show the new zone - curl -v http://a:changeme@127.0.0.1:8081/servers/localhost/zones/example.org | jq . + curl -v -H 'X-API-Key: changeme' http://127.0.0.1:8081/servers/localhost/zones/example.org | jq . `jq` is a highly recommended tool for pretty-printing JSON. If you don't have `jq`, try `json_pp` or `python -mjson.tool` instead. @@ -46,7 +46,7 @@ Try it (Recursor edition) Install PowerDNS Recursor, configured as follows: experimental-webserver=yes - experimental-webserver-password=changeme + experimental-api-key=changeme auth-zones= forward-zones= forward-zones-recurse= @@ -54,8 +54,8 @@ Install PowerDNS Recursor, configured as follows: After restarting `pdns_recursor`, the following examples should start working: - curl -v http://a:changeme@127.0.0.1:8082/servers/localhost | jq . - curl -v http://a:changeme@127.0.0.1:8082/servers/localhost/zones | jq . + curl -v -H 'X-API-Key: changeme' http://127.0.0.1:8082/servers/localhost | jq . + curl -v -H 'X-API-Key: changeme' http://127.0.0.1:8082/servers/localhost/zones | jq . API Specification diff --git a/pdns/docs/httpapi/api_spec.md b/pdns/docs/httpapi/api_spec.md index 75e150ebc7..77bce6e5c1 100644 --- a/pdns/docs/httpapi/api_spec.md +++ b/pdns/docs/httpapi/api_spec.md @@ -53,11 +53,12 @@ For interactions that do not directly map onto CRUD, we use these: Authentication -------------- -Clients SHOULD support: +The PowerDNS daemons accept a static API Key, which has to be sent in the +`X-API-Key` header. + +Note: Authoritative Server 3.4.0 and Recursor 3.6.0 and 3.6.1 use HTTP +Basic Authentication instead. -* HTTP Basic Auth (used by pdns, pdnsmgrd) -* OAuth (used by pdnscontrol) - * **TODO**: Not implemented yet. Errors ------ diff --git a/pdns/docs/pdns.xml b/pdns/docs/pdns.xml index 16c44b6d97..7c07bb655d 100644 --- a/pdns/docs/pdns.xml +++ b/pdns/docs/pdns.xml @@ -13580,6 +13580,14 @@ ALTER TABLE domainmetadata MODIFY kind VARCHAR2(32); + + experimental-api-key + + + Static API authentication key, must be sent in the X-API-Key header. Required for any API usage. + + + experimental-dname-processing diff --git a/pdns/pdns.conf-dist b/pdns/pdns.conf-dist index ec203c0d16..bc5ae5d592 100644 --- a/pdns/pdns.conf-dist +++ b/pdns/pdns.conf-dist @@ -144,6 +144,11 @@ # # entropy-source=/dev/urandom +################################# +# experimental-api-key REST API Static authentication key (required for API use) +# +# experimental-api-key= + ################################# # experimental-api-readonly If the JSON API should disallow data modification # diff --git a/pdns/pdns_recursor.cc b/pdns/pdns_recursor.cc index d5cae24c5b..2a4b8ae640 100644 --- a/pdns/pdns_recursor.cc +++ b/pdns/pdns_recursor.cc @@ -2101,6 +2101,7 @@ int main(int argc, char **argv) ::arg().set("experimental-webserver-password", "Password required for accessing the webserver") = ""; ::arg().set("webserver-allow-from","Webserver access is only allowed from these subnets")="0.0.0.0/0,::/0"; ::arg().set("experimental-api-config-dir", "Directory where REST API stores config and zones") = ""; + ::arg().set("experimental-api-key", "REST API Static authentication key (required for API use)") = ""; ::arg().set("carbon-ourname", "If set, overrides our reported hostname for carbon stats")=""; ::arg().set("carbon-server", "If set, send metrics in carbon (graphite) format to this server")=""; ::arg().set("carbon-interval", "Number of seconds between carbon (graphite) updates")="30"; diff --git a/pdns/webserver.cc b/pdns/webserver.cc index 6bcda50f40..cf993a1e48 100644 --- a/pdns/webserver.cc +++ b/pdns/webserver.cc @@ -48,6 +48,37 @@ void HttpRequest::json(rapidjson::Document& document) } } +bool HttpRequest::compareAuthorization(const string &expected_password) +{ + // validate password + YaHTTP::strstr_map_t::iterator header = headers.find("authorization"); + bool auth_ok = false; + if (header != headers.end() && toLower(header->second).find("basic ") == 0) { + string cookie = header->second.substr(6); + + string plain; + B64Decode(cookie, plain); + + vector cparts; + stringtok(cparts, plain, ":"); + + // this gets rid of terminating zeros + auth_ok = (cparts.size()==2 && (0==strcmp(cparts[1].c_str(), expected_password.c_str()))); + } + return auth_ok; +} + +bool HttpRequest::compareHeader(const string &header_name, const string &expected_value) +{ + YaHTTP::strstr_map_t::iterator header = headers.find(header_name); + if (header == headers.end()) + return false; + + // this gets rid of terminating zeros + return (0==strcmp(header->second.c_str(), expected_value.c_str())); +} + + void HttpResponse::setBody(rapidjson::Document& document) { this->body = makeStringFromDocument(document); @@ -58,19 +89,30 @@ int WebServer::B64Decode(const std::string& strInput, std::string& strOutput) return ::B64Decode(strInput, strOutput); } -static void handlerWrapper(WebServer::HandlerFunction handler, YaHTTP::Request* req, YaHTTP::Response* resp) +static void bareHandlerWrapper(WebServer::HandlerFunction handler, YaHTTP::Request* req, YaHTTP::Response* resp) { // wrapper to convert from YaHTTP::* to our subclasses handler(static_cast(req), static_cast(resp)); } -void WebServer::registerHandler(const string& url, HandlerFunction handler) +void WebServer::registerBareHandler(const string& url, HandlerFunction handler) { - YaHTTP::THandlerFunction f = boost::bind(&handlerWrapper, handler, _1, _2); + YaHTTP::THandlerFunction f = boost::bind(&bareHandlerWrapper, handler, _1, _2); YaHTTP::Router::Any(url, f); } static void apiWrapper(WebServer::HandlerFunction handler, HttpRequest* req, HttpResponse* resp) { + const string& api_key = arg()["experimental-api-key"]; + if (api_key.empty()) { + L<url.path << "\": Authentication failed, API Key missing in config" << endl; + throw HttpUnauthorizedException(); + } + bool auth_ok = req->compareHeader("x-api-key", api_key); + if (!auth_ok) { + L<url.path << "\": Authentication by API Key failed" << endl; + throw HttpUnauthorizedException(); + } + resp->headers["Access-Control-Allow-Origin"] = "*"; resp->headers["Content-Type"] = "application/json"; @@ -108,7 +150,25 @@ static void apiWrapper(WebServer::HandlerFunction handler, HttpRequest* req, Htt void WebServer::registerApiHandler(const string& url, HandlerFunction handler) { HandlerFunction f = boost::bind(&apiWrapper, handler, _1, _2); - registerHandler(url, f); + registerBareHandler(url, f); +} + +static void webWrapper(WebServer::HandlerFunction handler, HttpRequest* req, HttpResponse* resp) { + const string& web_password = arg()["webserver-password"]; + if (!web_password.empty()) { + bool auth_ok = req->compareAuthorization(web_password); + if (!auth_ok) { + L<url.path << "\": Web Authentication failed" << endl; + throw HttpUnauthorizedException(); + } + } + + handler(req, resp); +} + +void WebServer::registerWebHandler(const string& url, HandlerFunction handler) { + HandlerFunction f = boost::bind(&webWrapper, handler, _1, _2); + registerBareHandler(url, f); } static void *WebServerConnectionThreadStart(void *p) { @@ -148,28 +208,6 @@ HttpResponse WebServer::handleRequest(HttpRequest req) } } - if (!d_password.empty()) { - // validate password - header = req.headers.find("authorization"); - bool auth_ok = false; - if (header != req.headers.end() && toLower(header->second).find("basic ") == 0) { - string cookie = header->second.substr(6); - - string plain; - B64Decode(cookie, plain); - - vector cparts; - stringtok(cparts, plain, ":"); - - // this gets rid of terminating zeros - auth_ok = (cparts.size()==2 && (0==strcmp(cparts[1].c_str(), d_password.c_str()))); - } - if (!auth_ok) { - L< HandlerFunction; - void registerHandler(const string& url, HandlerFunction handler); void registerApiHandler(const string& url, HandlerFunction handler); + void registerWebHandler(const string& url, HandlerFunction handler); protected: static char B64Decode1(char cInChar); static int B64Decode(const std::string& strInput, std::string& strOutput); + void registerBareHandler(const string& url, HandlerFunction handler); virtual Server* createServer() { return new Server(d_listenaddress, d_port); diff --git a/pdns/ws-auth.cc b/pdns/ws-auth.cc index 12c7a5c82f..69d27a6c7a 100644 --- a/pdns/ws-auth.cc +++ b/pdns/ws-auth.cc @@ -61,7 +61,7 @@ AuthWebServer::AuthWebServer() d_ws = 0; d_tid = 0; if(arg().mustDo("webserver")) { - d_ws = new WebServer(arg()["webserver-address"], arg().asNum("webserver-port"),arg()["webserver-password"]); + d_ws = new WebServer(arg()["webserver-address"], arg().asNum("webserver-port")); d_ws->bind(); } } @@ -1255,8 +1255,8 @@ void AuthWebServer::webThread() // legacy dispatch d_ws->registerApiHandler("/jsonstat", boost::bind(&AuthWebServer::jsonstat, this, _1, _2)); } - d_ws->registerHandler("/style.css", boost::bind(&AuthWebServer::cssfunction, this, _1, _2)); - d_ws->registerHandler("/", boost::bind(&AuthWebServer::indexfunction, this, _1, _2)); + d_ws->registerWebHandler("/style.css", boost::bind(&AuthWebServer::cssfunction, this, _1, _2)); + d_ws->registerWebHandler("/", boost::bind(&AuthWebServer::indexfunction, this, _1, _2)); d_ws->go(); } catch(...) { diff --git a/pdns/ws-recursor.cc b/pdns/ws-recursor.cc index 73c6f57647..be8f494d60 100644 --- a/pdns/ws-recursor.cc +++ b/pdns/ws-recursor.cc @@ -421,7 +421,7 @@ RecursorWebServer::RecursorWebServer(FDMultiplexer* fdm) { RecursorControlParser rcp; // inits - d_ws = new AsyncWebServer(fdm, arg()["experimental-webserver-address"], arg().asNum("experimental-webserver-port"), arg()["experimental-webserver-password"]); + d_ws = new AsyncWebServer(fdm, arg()["experimental-webserver-address"], arg().asNum("experimental-webserver-port")); d_ws->bind(); // legacy dispatch diff --git a/pdns/ws-recursor.hh b/pdns/ws-recursor.hh index ffcad30636..3f2fc845c1 100644 --- a/pdns/ws-recursor.hh +++ b/pdns/ws-recursor.hh @@ -45,8 +45,8 @@ private: class AsyncWebServer : public WebServer { public: - AsyncWebServer(FDMultiplexer* fdm, const string &listenaddress, int port, const string &password="") : - WebServer(listenaddress, port, password), d_fdm(fdm) { }; + AsyncWebServer(FDMultiplexer* fdm, const string &listenaddress, int port) : + WebServer(listenaddress, port), d_fdm(fdm) { }; void go(); private: diff --git a/regression-tests.api/runtests.py b/regression-tests.api/runtests.py index 8f5f82f775..f44b7a765a 100755 --- a/regression-tests.api/runtests.py +++ b/regression-tests.api/runtests.py @@ -12,7 +12,7 @@ import time SQLITE_DB = 'pdns.sqlite3' WEBPORT = '5556' -WEBPASSWORD = '12345' +APIKEY = '1234567890abcdefghijklmnopq-key' NAMED_CONF_TPL = """ # Generated by runtests.py @@ -78,7 +78,7 @@ if daemon == 'authoritative': tf.seek(0, os.SEEK_SET) # rewind subprocess.check_call(["sqlite3", SQLITE_DB], stdin=tf) - pdnscmd = ("../pdns/pdns_server --daemon=no --local-port=5300 --socket-dir=./ --module-dir=../regression-tests/modules --no-shuffle --launch=gsqlite3 --gsqlite3-dnssec --send-root-referral --experimental-dnsupdate=yes --cache-ttl=0 --no-config --gsqlite3-dnssec=on --gsqlite3-database="+SQLITE_DB+" --experimental-json-interface=yes --webserver=yes --webserver-port="+WEBPORT+" --webserver-address=127.0.0.1 --webserver-password="+WEBPASSWORD).split() + pdnscmd = ("../pdns/pdns_server --daemon=no --local-port=5300 --socket-dir=./ --module-dir=../regression-tests/modules --no-shuffle --launch=gsqlite3 --gsqlite3-dnssec --send-root-referral --experimental-dnsupdate=yes --cache-ttl=0 --no-config --gsqlite3-dnssec=on --gsqlite3-database="+SQLITE_DB+" --experimental-json-interface=yes --webserver=yes --webserver-port="+WEBPORT+" --webserver-address=127.0.0.1 --webserver-password=something --experimental-api-key="+APIKEY).split() else: conf_dir = 'rec-conf.d' @@ -90,7 +90,7 @@ else: with open(conf_dir+'/example.com..conf', 'w') as conf_file: conf_file.write(REC_EXAMPLE_COM_CONF_TPL) - pdnscmd = ("../pdns/pdns_recursor --daemon=no --socket-dir=. --config-dir=. --allow-from-file=acl.list --local-port=5555 --experimental-webserver=yes --experimental-webserver-port="+WEBPORT+" --experimental-webserver-address=127.0.0.1 --experimental-webserver-password="+WEBPASSWORD).split() + pdnscmd = ("../pdns/pdns_recursor --daemon=no --socket-dir=. --config-dir=. --allow-from-file=acl.list --local-port=5555 --experimental-webserver=yes --experimental-webserver-port="+WEBPORT+" --experimental-webserver-address=127.0.0.1 --experimental-webserver-password=something --experimental-api-key="+APIKEY).split() # Now run pdns and the tests. @@ -118,7 +118,7 @@ print "Running tests..." rc = 0 test_env = {} test_env.update(os.environ) -test_env.update({'WEBPORT': WEBPORT, 'WEBPASSWORD': WEBPASSWORD, 'DAEMON': daemon}) +test_env.update({'WEBPORT': WEBPORT, 'APIKEY': APIKEY, 'DAEMON': daemon}) try: print "" diff --git a/regression-tests.api/test_helper.py b/regression-tests.api/test_helper.py index 566853f13a..287cccf136 100644 --- a/regression-tests.api/test_helper.py +++ b/regression-tests.api/test_helper.py @@ -15,7 +15,7 @@ class ApiTestCase(unittest.TestCase): self.server_port = int(os.environ.get('WEBPORT', '5580')) self.server_url = 'http://%s:%s/' % (self.server_address, self.server_port) self.session = requests.Session() - self.session.auth = ('admin', os.environ.get('WEBPASSWORD', 'changeme')) + self.session.headers = {'x-api-key': os.environ.get('APIKEY', 'changeme-key')} def url(self, relative_url): return urlparse.urljoin(self.server_url, relative_url)