]> granicus.if.org Git - pdns/commitdiff
API: Replace HTTP Basic auth with static key in custom header
authorChristian Hofstaedtler <christian@hofstaedtler.name>
Mon, 6 Oct 2014 21:51:01 +0000 (23:51 +0200)
committerChristian Hofstaedtler <christian@hofstaedtler.name>
Tue, 7 Oct 2014 05:30:27 +0000 (07:30 +0200)
Given that the key is sent in a custom header, this should prevent
any possible CSRF attacks.

Fixes #1769.

13 files changed:
pdns/common_startup.cc
pdns/docs/httpapi/README.md
pdns/docs/httpapi/api_spec.md
pdns/docs/pdns.xml
pdns/pdns.conf-dist
pdns/pdns_recursor.cc
pdns/webserver.cc
pdns/webserver.hh
pdns/ws-auth.cc
pdns/ws-recursor.cc
pdns/ws-recursor.hh
regression-tests.api/runtests.py
regression-tests.api/test_helper.py

index ada2c885ad9cc25c382ada1026243f3b772556df..7fdf0474853cf644ae5e7a1aaffe4d452adc06b4 100644 (file)
@@ -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");
index 4546e2b2cc82d1922908a9a3b8918172c1e9f700..13e2f073313bd450d1e77e2de6f11fdb65c4e8a8 100644 (file)
@@ -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
index 75e150ebc7070fa89ee61bc0b956562ee43b35e4..77bce6e5c18983b3b29ab506d21c29d487d8e8a2 100644 (file)
@@ -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
 ------
index 16c44b6d9759b6f6c30f3aef018a90e85f7dcc01..7c07bb655db62e8c83345752d0d693b21a43bb3d 100644 (file)
@@ -13580,6 +13580,14 @@ ALTER TABLE domainmetadata MODIFY kind VARCHAR2(32);
             </para>
           </listitem>
         </varlistentry>
+        <varlistentry>
+          <term>experimental-api-key</term>
+          <listitem>
+            <para>
+              Static API authentication key, must be sent in the X-API-Key header. Required for any API usage.
+            </para>
+          </listitem>
+        </varlistentry>
         <varlistentry>
           <term>experimental-dname-processing</term>
           <listitem>
index ec203c0d168eeabf555f78b954a18b15130e7e56..bc5ae5d59283c28e6b70dedb095237bcf537e6c9 100644 (file)
 #
 # 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
 #
index d5cae24c5b2651232280f266fe0111c33d032532..2a4b8ae64059fd19dc14bab4bc345e4b2f98083b 100644 (file)
@@ -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";
index 6bcda50f40ce7d4b4e7fd88179b0144bed44376c..cf993a1e48b29d29cb9e6a0d76e35a0556301144 100644 (file)
@@ -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<string> 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<HttpRequest*>(req), static_cast<HttpResponse*>(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<<Logger::Debug<<"HTTP API Request \"" << req->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<<Logger::Debug<<"HTTP Request \"" << req->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<<Logger::Debug<<"HTTP Request \"" << req->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<string> 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<<Logger::Debug<<"HTTP Request \"" << req.url.path << "\": Authentication failed" << endl;
-        throw HttpUnauthorizedException();
-      }
-    }
-
     YaHTTP::THandlerFunction handler;
     if (!YaHTTP::Router::Route(&req, handler)) {
       L<<Logger::Debug<<"HTTP: No route found for \"" << req.url.path << "\"" << endl;
@@ -268,11 +306,10 @@ catch(...) {
   L<<Logger::Error<<"HTTP: Unknown exception"<<endl;
 }
 
-WebServer::WebServer(const string &listenaddress, int port, const string &password) : d_server(NULL)
+WebServer::WebServer(const string &listenaddress, int port) : d_server(NULL)
 {
   d_listenaddress=listenaddress;
   d_port=port;
-  d_password=password;
 }
 
 void WebServer::bind()
index 5b3376748d986d6480a930f54e650279eb8319b4..bac4a88540aa2195aa5a587cae94e9ac02982586 100644 (file)
@@ -32,6 +32,8 @@
 #include "namespaces.hh"
 #include "sstuff.hh"
 
+class WebServer;
+
 class HttpRequest : public YaHTTP::Request {
 public:
   HttpRequest() : YaHTTP::Request(), accept_json(false), accept_html(false), complete(false) { };
@@ -40,6 +42,10 @@ public:
   bool accept_html;
   bool complete;
   void json(rapidjson::Document& document);
+
+  // checks password _only_.
+  bool compareAuthorization(const string &expected_password);
+  bool compareHeader(const string &header_name, const string &expected_value);
 };
 
 class HttpResponse: public YaHTTP::Response {
@@ -125,7 +131,7 @@ protected:
 class WebServer : public boost::noncopyable
 {
 public:
-  WebServer(const string &listenaddress, int port, const string &password="");
+  WebServer(const string &listenaddress, int port);
   void bind();
   void go();
 
@@ -133,12 +139,13 @@ public:
   HttpResponse handleRequest(HttpRequest request);
 
   typedef boost::function<void(HttpRequest* req, HttpResponse* resp)> 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);
index 12c7a5c82fb497bdd25c4b6b17e140d2838878b7..69d27a6c7a8baf567119c3eb5f6e99d4b4b69adf 100644 (file)
@@ -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(...) {
index 73c6f57647ae2ab00d2d0a08cfa311768a6b4aab..be8f494d6046568e277b8135fe82ab47c492d1d9 100644 (file)
@@ -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
index ffcad3063628c46232acb960473e523108b64432..3f2fc845c1bbde52d72161297e321b9bd1f38a86 100644 (file)
@@ -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:
index 8f5f82f775deecb9a68e5aaf626df14d454202e4..f44b7a765acf042dc5ac2f81bf50c5f90be9b717 100755 (executable)
@@ -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 ""
index 566853f13a2111f6f3041e5e4bfe548253126e33..287cccf136a02c64120edc1e64c8a23c35476d11 100644 (file)
@@ -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)