Skip to content

Commit

Permalink
API: Replace HTTP Basic auth with static key in custom header
Browse files Browse the repository at this point in the history
Given that the key is sent in a custom header, this should prevent
any possible CSRF attacks.

Fixes #1769.
  • Loading branch information
zeha authored and Peter van Dijk committed Oct 21, 2014
1 parent 4a95ab4 commit 5fe6dc0
Show file tree
Hide file tree
Showing 13 changed files with 114 additions and 54 deletions.
1 change: 1 addition & 0 deletions pdns/common_startup.cc
Expand Up @@ -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");
Expand Down
18 changes: 9 additions & 9 deletions pdns/docs/httpapi/README.md
Expand Up @@ -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:

Expand All @@ -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.
Expand All @@ -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
Expand Down
9 changes: 5 additions & 4 deletions pdns/docs/httpapi/api_spec.md
Expand Up @@ -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
------
Expand Down
8 changes: 8 additions & 0 deletions pdns/docs/pdns.xml
Expand Up @@ -13623,6 +13623,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>
Expand Down
5 changes: 5 additions & 0 deletions pdns/pdns.conf-dist
Expand Up @@ -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
#
Expand Down
1 change: 1 addition & 0 deletions pdns/pdns_recursor.cc
Expand Up @@ -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";
Expand Down
93 changes: 65 additions & 28 deletions pdns/webserver.cc
Expand Up @@ -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);
Expand All @@ -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";

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down
11 changes: 9 additions & 2 deletions pdns/webserver.hh
Expand Up @@ -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) { };
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions pdns/ws-auth.cc
Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -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(...) {
Expand Down
2 changes: 1 addition & 1 deletion pdns/ws-recursor.cc
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions pdns/ws-recursor.hh
Expand Up @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions regression-tests.api/runtests.py
Expand Up @@ -12,7 +12,7 @@

SQLITE_DB = 'pdns.sqlite3'
WEBPORT = '5556'
WEBPASSWORD = '12345'
APIKEY = '1234567890abcdefghijklmnopq-key'

NAMED_CONF_TPL = """
# Generated by runtests.py
Expand Down Expand Up @@ -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'
Expand All @@ -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.
Expand Down Expand Up @@ -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 ""
Expand Down
2 changes: 1 addition & 1 deletion regression-tests.api/test_helper.py
Expand Up @@ -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)
Expand Down

0 comments on commit 5fe6dc0

Please sign in to comment.