Permalink
Browse files

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.
  • Loading branch information...
1 parent 4a95ab4 commit 5fe6dc07c2a28bc829f121b4fbcb7009cd627abb @zeha zeha committed with Habbie Oct 6, 2014
@@ -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");
@@ -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,16 +46,16 @@ 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=
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
@@ -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
------
View
@@ -13624,6 +13624,14 @@ ALTER TABLE domainmetadata MODIFY kind VARCHAR2(32);
</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>
<para>
View
@@ -145,6 +145,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
#
# experimental-api-readonly=no
@@ -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";
View
@@ -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()
View
@@ -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,20 +131,21 @@ 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();
void serveConnection(Socket *client);
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);
View
@@ -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(...) {
View
@@ -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
View
@@ -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:
@@ -12,7 +12,7 @@
SQLITE_DB = 'pdns.sqlite3'
WEBPORT = '5556'
-WEBPASSWORD = '12345'
+APIKEY = '1234567890abcdefghijklmnopq-key'
NAMED_CONF_TPL = """
# Generated by runtests.py
@@ -78,7 +78,7 @@ def ensure_empty_dir(name):
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 @@ def ensure_empty_dir(name):
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 @@ def ensure_empty_dir(name):
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 ""
@@ -15,7 +15,7 @@ def setUp(self):
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)

0 comments on commit 5fe6dc0

Please sign in to comment.