Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ testext
test/python/__pycache__/
.Rhistory
.vscode/
node_modules/
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ set(EXTENSION_SOURCES
src/utils/helpers.cpp
src/utils/md_helpers.cpp
src/utils/serialization.cpp
src/utils/token.cpp
src/watcher.cpp)

add_definitions(-DDUCKDB_MAJOR_VERSION=${DUCKDB_MAJOR_VERSION})
Expand Down
112 changes: 100 additions & 12 deletions src/http_server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include "settings.hpp"
#include "state.hpp"
#include "utils/encoding.hpp"
#include "utils/token.hpp"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: alphabetize

#include "utils/env.hpp"
#include "utils/helpers.hpp"
#include "utils/md_helpers.hpp"
Expand Down Expand Up @@ -101,27 +102,34 @@ const HttpServer &HttpServer::Start(ClientContext &context, bool *was_started) {
*was_started = false;
}

const auto host = GetLocalHost(context);
const auto remote_url = GetRemoteUrl(context);
const auto port = GetLocalPort(context);
auto &http_util = HTTPUtil::Get(*context.db);
// FIXME - https://github.com/duckdb/duckdb/pull/17655 will remove `unused`
auto http_params = http_util.InitializeParameters(context, "unused");
auto server = GetInstance(context);
server->DoStart(port, remote_url, std::move(http_params));
const auto token_auth = GetEnableTokenAuth(context);
server->DoStart(host, port, remote_url, std::move(http_params), token_auth);
return *server;
}

void HttpServer::DoStart(const uint16_t _local_port,
void HttpServer::DoStart(const std::string &_local_host,
const uint16_t _local_port,
const std::string &_remote_url,
unique_ptr<HTTPParams> _http_params) {
unique_ptr<HTTPParams> _http_params,
bool _token_auth_enabled) {
if (Started()) {
throw std::runtime_error("HttpServer already started");
}

local_host = _local_host;
local_port = _local_port;
local_url = StringUtil::Format("http://localhost:%d", local_port);
local_url = StringUtil::Format("http://%s:%d", local_host, local_port);
remote_url = _remote_url;
http_params = std::move(_http_params);
token_auth_enabled = _token_auth_enabled;
auth_token = GenerateToken();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't generate a token if token auth is disabled, both to avoid the unnecessary work, and also to avoid it showing up in the URL.

user_agent =
StringUtil::Format("duckdb-ui/%s-%s(%s)", DuckDB::LibraryVersion(),
UI_EXTENSION_VERSION, DuckDB::Platform());
Expand Down Expand Up @@ -163,11 +171,72 @@ void HttpServer::DoStop() {
http_params = nullptr;
event_dispatcher = nullptr;
remote_url = "";
local_host = "";
local_port = 0;
}

std::string HttpServer::LocalUrl() const {
return StringUtil::Format("http://localhost:%d/", local_port);
return StringUtil::Format("http://%s:%d/", local_host, local_port);
}

std::string HttpServer::GetAuthToken() const {
return auth_token;
}

std::string HttpServer::ExtractTokenFromCookie(const std::string &cookie_header) {
const std::string prefix = "duckdb_ui_token=";
size_t pos = cookie_header.find(prefix);
if (pos == std::string::npos) {
return "";
}
pos += prefix.size();
size_t end = cookie_header.find(';', pos);
if (end == std::string::npos) {
return cookie_header.substr(pos);
}
return cookie_header.substr(pos, end - pos);
}

void HttpServer::SetTokenCookie(httplib::Response &res) {
res.set_header("Set-Cookie",
"duckdb_ui_token=" + auth_token +
"; Path=/; HttpOnly; SameSite=Strict");
}

bool HttpServer::ValidateToken(const httplib::Request &req,
httplib::Response &res) {
if (!token_auth_enabled) {
return true;
}

// Check Authorization header: "token <TOKEN>"
auto auth_header = req.get_header_value("Authorization");
if (auth_header.size() > 6 && auth_header.compare(0, 6, "token ") == 0) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should make this auth scheme more specific, such as "duckdb-ui-token" or "duckdb_ui_token", to match the cookie.

if (auth_header.substr(6) == auth_token) {
SetTokenCookie(res);
return true;
}
}

// Check URL parameter: ?token=<TOKEN>
if (req.has_param("token")) {
if (req.get_param_value("token") == auth_token) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should name this URL param a bit more uniquely, to prevent it from interacting with other potential URL params. How about "duckdb-ui-token" or "duckdb_ui_token", to match the cookie?

SetTokenCookie(res);
return true;
}
}

// Check cookie: duckdb_ui_token=<TOKEN>
auto cookie_header = req.get_header_value("Cookie");
if (!cookie_header.empty()) {
auto cookie_token = ExtractTokenFromCookie(cookie_header);
if (cookie_token == auth_token) {
return true;
}
}

res.status = 401;
return false;
}

shared_ptr<DatabaseInstance> HttpServer::LockDatabaseInstance() {
Expand Down Expand Up @@ -203,7 +272,7 @@ void HttpServer::Run() {
const httplib::ContentReader &content_reader) {
HandleTokenize(req, res, content_reader);
});
server.listen("localhost", local_port);
server.listen(local_host, local_port);
}

void HttpServer::HandleGetInfo(const httplib::Request &req,
Expand All @@ -217,6 +286,10 @@ void HttpServer::HandleGetInfo(const httplib::Request &req,

void HttpServer::HandleGetLocalEvents(const httplib::Request &req,
httplib::Response &res) {
if (!ValidateToken(req, res)) {
return;
}

res.set_chunked_content_provider(
"text/event-stream", [&](size_t /*offset*/, httplib::DataSink &sink) {
if (event_dispatcher && event_dispatcher->WaitEvent(&sink)) {
Expand All @@ -230,11 +303,7 @@ void HttpServer::HandleGetLocalEvents(const httplib::Request &req,

void HttpServer::HandleGetLocalToken(const httplib::Request &req,
httplib::Response &res) {
// GET requests don't include Origin, so use Referer instead.
// Referer includes the path, so only compare the start.
auto referer = req.get_header_value("Referer");
if (referer.compare(0, local_url.size(), local_url) != 0) {
res.status = 401;
Copy link
Copy Markdown
Collaborator

@jraymakers jraymakers Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should keep this check unless the local_host setting is something other than "localhost". (This is a variant of the other origin checks I mentioned.)

if (!ValidateToken(req, res)) {
return;
}

Expand Down Expand Up @@ -282,6 +351,10 @@ void HttpServer::InitClientFromParams(httplib::Client &client) {

void HttpServer::HandleGet(const httplib::Request &req,
httplib::Response &res) {
if (!ValidateToken(req, res)) {
return;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need (and shouldn't) require the token for these forwarded GET requests. The purpose of the token auth is to protect access to the DuckDB instance, but this function just proxies requests to the Internet, so there's nothing local to protect.


// Create HTTP client to remote URL
// TODO: Can this be created once and shared?
httplib::Client client(remote_url);
Expand All @@ -298,7 +371,10 @@ void HttpServer::HandleGet(const httplib::Request &req,
}

// forward GET to remote URL
auto result = client.Get(req.path, req.params, headers);
// Strip the token parameter before forwarding to remote
auto params = req.params;
params.erase("token");
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where the general name "token" is most likely to cause problems, hence my other comment to make it more unique (e.g. "duckdb-ui-token").

auto result = client.Get(req.path, params, headers);
if (!result) {
res.status = 500;
res.set_content("Could not fetch: '" + req.path + "' from '" + remote_url +
Expand All @@ -325,6 +401,10 @@ void HttpServer::HandleGet(const httplib::Request &req,

void HttpServer::HandleInterrupt(const httplib::Request &req,
httplib::Response &res) {
if (!ValidateToken(req, res)) {
return;
}

auto origin = req.get_header_value("Origin");
if (origin != local_url) {
res.status = 401;
Expand Down Expand Up @@ -365,6 +445,10 @@ void HttpServer::HandleRun(const httplib::Request &req, httplib::Response &res,
void HttpServer::DoHandleRun(const httplib::Request &req,
httplib::Response &res,
const httplib::ContentReader &content_reader) {
if (!ValidateToken(req, res)) {
return;
}

auto origin = req.get_header_value("Origin");
if (origin != local_url) {
res.status = 401;
Expand Down Expand Up @@ -665,6 +749,10 @@ void HttpServer::DoHandleRun(const httplib::Request &req,
void HttpServer::HandleTokenize(const httplib::Request &req,
httplib::Response &res,
const httplib::ContentReader &content_reader) {
if (!ValidateToken(req, res)) {
return;
}

auto origin = req.get_header_value("Origin");
if (origin != local_url) {
res.status = 401;
Expand Down
14 changes: 12 additions & 2 deletions src/include/http_server.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@ class HttpServer {
static bool Stop();

std::string LocalUrl() const;
std::string GetAuthToken() const;

private:
friend class Watcher;

// Lifecycle
void DoStart(const uint16_t local_port, const std::string &remote_url,
unique_ptr<HTTPParams>);
void DoStart(const std::string &local_host, const uint16_t local_port,
const std::string &remote_url, unique_ptr<HTTPParams>,
bool token_auth_enabled);
void DoStop();
void Run();
void UpdateDatabaseInstance(shared_ptr<DatabaseInstance> context_db);
Expand All @@ -63,6 +65,11 @@ class HttpServer {
const httplib::ContentReader &content_reader);
std::string ReadContent(const httplib::ContentReader &content_reader);

// Token authentication
bool ValidateToken(const httplib::Request &req, httplib::Response &res);
void SetTokenCookie(httplib::Response &res);
static std::string ExtractTokenFromCookie(const std::string &cookie_header);

// Http responses
void SetResponseContent(httplib::Response &res, const MemoryStream &content);
void SetResponseEmptyResult(httplib::Response &res);
Expand All @@ -75,6 +82,7 @@ class HttpServer {
static void CopyAndSlice(duckdb::DataChunk &source, duckdb::DataChunk &target,
idx_t row_count);

std::string local_host;
uint16_t local_port;
std::string local_url;
std::string remote_url;
Expand All @@ -85,6 +93,8 @@ class HttpServer {
unique_ptr<EventDispatcher> event_dispatcher;
unique_ptr<Watcher> watcher;
unique_ptr<HTTPParams> http_params;
std::string auth_token;
bool token_auth_enabled;

static unique_ptr<HttpServer> server_instance;
};
Expand Down
6 changes: 6 additions & 0 deletions src/include/settings.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
#include <duckdb/common/exception.hpp>
#include <duckdb/main/client_context.hpp>

#define UI_LOCAL_HOST_SETTING_NAME "ui_local_host"
#define UI_LOCAL_HOST_SETTING_DEFAULT "localhost"
#define UI_LOCAL_PORT_SETTING_NAME "ui_local_port"
#define UI_LOCAL_PORT_SETTING_DEFAULT 4213
#define UI_REMOTE_URL_SETTING_NAME "ui_remote_url"
#define UI_REMOTE_URL_SETTING_DEFAULT "https://ui.duckdb.org"
#define UI_POLLING_INTERVAL_SETTING_NAME "ui_polling_interval"
#define UI_POLLING_INTERVAL_SETTING_DEFAULT 284
#define UI_ENABLE_TOKEN_AUTH_SETTING_NAME "ui_enable_token_auth"
#define UI_ENABLE_TOKEN_AUTH_SETTING_DEFAULT true
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should:

  • default to token auth disabled if the local host is "localhost"
  • default to token auth enabled otherwise
  • respect the value of "ui_enable_token_auth" if it is set explicitly.


namespace duckdb {

Expand All @@ -25,8 +29,10 @@ T GetSetting(const ClientContext &context, const char *setting_name) {
}
} // namespace internal

std::string GetLocalHost(const ClientContext &);
std::string GetRemoteUrl(const ClientContext &);
uint16_t GetLocalPort(const ClientContext &);
uint32_t GetPollingInterval(const ClientContext &);
bool GetEnableTokenAuth(const ClientContext &);

} // namespace duckdb
9 changes: 9 additions & 0 deletions src/include/utils/token.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#pragma once

#include <string>

namespace duckdb {

std::string GenerateToken();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General naming comment: There's an existing use of "token" in the extension - the "localToken" endpoint and the "GetMDToken" helper - for (optional) MotherDuck support. This change adds a second kind of token. To avoid confusion, we should qualify this new token wherever we refer to it, in function names, variables, etc. I recommend "DuckDBUIToken".


} // namespace duckdb
11 changes: 10 additions & 1 deletion src/settings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,21 @@ std::string GetRemoteUrl(const ClientContext &context) {
return internal::GetSetting<std::string>(context, UI_REMOTE_URL_SETTING_NAME);
}

std::string GetLocalHost(const ClientContext &context) {
return internal::GetSetting<std::string>(context, UI_LOCAL_HOST_SETTING_NAME);
}

uint16_t GetLocalPort(const ClientContext &context) {
return internal::GetSetting<uint16_t>(context, UI_LOCAL_PORT_SETTING_NAME);
}

uint32_t GetPollingInterval(const ClientContext &context) {
return internal::GetSetting<uint32_t>(context,
UI_POLLING_INTERVAL_SETTING_NAME);
UI_POLLING_INTERVAL_SETTING_NAME);
}

bool GetEnableTokenAuth(const ClientContext &context) {
return internal::GetSetting<bool>(context,
UI_ENABLE_TOKEN_AUTH_SETTING_NAME);
}
} // namespace duckdb
Loading