3 Commits

Author SHA1 Message Date
e11686f4a0 feat(http): implement origin and referer validation for HTTP requests
Some checks failed
Build linux_amd64 extension and upload to Packages / build-linux-amd64 (push) Failing after 25m14s
Added helper functions to validate request origins and referers, allowing for configurable local URLs and support for environments where the UI is exposed externally. Introduced environment variable `ui_allow_any_origin` for bypassing origin checks. Updated request handling methods to utilize these validations, enhancing security for HTTP interactions.
2025-09-13 19:02:04 +00:00
5821ee7fc8 feat(ci): add DuckDB binary upload to GitHub Actions workflow
Some checks failed
Build linux_amd64 extension and upload to Packages / build-linux-amd64 (push) Has been cancelled
2025-09-13 18:43:59 +00:00
c52ad95c35 chore(ci): squash history into single commit for GitHub Action setup
All checks were successful
Build linux_amd64 extension and upload to Packages / build-linux-amd64 (push) Successful in 25m21s
2025-09-13 18:01:59 +00:00
2 changed files with 251 additions and 11 deletions

View File

@@ -6,6 +6,8 @@ on:
description: Package version (defaults to tag name or short SHA) description: Package version (defaults to tag name or short SHA)
required: false required: false
push: push:
branches:
- main
tags: tags:
- 'v*' - 'v*'
@@ -16,6 +18,33 @@ jobs:
EXTENSION_NAME: ui EXTENSION_NAME: ui
DUCKDB_PLATFORM: linux_amd64 DUCKDB_PLATFORM: linux_amd64
steps: steps:
- name: Checkout repository (manual)
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
set -euo pipefail
if [ -d .git ]; then
echo "Repository already present"
else
server="${GITHUB_SERVER_URL:-${{ github.server_url }}}"
repo_full="${GITHUB_REPOSITORY:-${{ github.repository }}}"
sha="${GITHUB_SHA:-${{ github.sha }}}"
host="$(echo "$server" | sed -E 's#^https?://([^/]+).*#\1#')"
if [ -n "${TOKEN:-}" ]; then
umask 077
printf "machine %s\n login token\n password %s\n" "$host" "$TOKEN" > "$HOME/.netrc"
fi
git init .
git config --global --add safe.directory "$(pwd)"
git remote add origin "$server/$repo_full.git"
git -c http.https://$host/.extraheader="" fetch --depth=1 origin "$sha"
git checkout -q FETCH_HEAD
fi
- name: Show workspace status
run: |
set -e
git --no-pager status | cat
- name: Install build dependencies - name: Install build dependencies
run: | run: |
set -e set -e
@@ -24,9 +53,154 @@ jobs:
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
build-essential cmake ninja-build python3 python3-venv pkg-config \ build-essential cmake ninja-build python3 python3-venv pkg-config \
libssl-dev curl git ca-certificates libssl-dev curl git ca-certificates
# ... submodule init, build release, find artifact ... - name: Preflight Gitea upload (fast-fail)
env:
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
ACTOR: ${{ github.actor }}
run: |
set -euo pipefail
: "${GITEA_TOKEN:?GITEA_TOKEN secret is required}"
server="${GITHUB_SERVER_URL:-${{ github.server_url }}}"
owner="${GITHUB_REPOSITORY_OWNER:-${{ github.repository_owner }}}"
repo_full="${GITHUB_REPOSITORY:-${{ github.repository }}}"
pkg="${repo_full##*/}-preflight"
version="preflight-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-0}-$(date +%s)"
name="check.bin"
tmpfile="$(mktemp)"
printf "auth check %s\n" "$(date -u +%FT%TZ)" > "$tmpfile"
# Normalize server to effective scheme+host (handles http->https redirects)
base_no_trail="$(echo "$server" | sed 's#/*$##')"
# Use GET (not HEAD) to avoid servers that don't support HEAD on this endpoint
effective_version_url=$(curl -sS -L -o /dev/null -w '%{url_effective}' "$base_no_trail/api/v1/version" || echo "")
normalized_server=$(echo "$effective_version_url" | sed -E 's#^(https?://[^/]+).*$#\1#')
if [ -n "$normalized_server" ]; then
server="$normalized_server"
fi
url="$server/api/packages/$owner/generic/$pkg/$version/$name?replace=1"
auth_user="${ACTOR:-$owner}"
echo "Preflight: server=$server owner=$owner package=$pkg version=$version"
# Perform preflight upload using Basic auth directly
if curl -fS -L -X PUT \
-u "$auth_user:${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--upload-file "$tmpfile" "$url" >/dev/null; then
echo "Preflight upload succeeded, cleaning up"
else
echo "Preflight upload failed" >&2
exit 1
fi
# Cleanup the uploaded dummy package version (best effort)
curl -sS -L -o /dev/null -w " delete -> HTTP %{http_code}\n" \
-u "$auth_user:${GITEA_TOKEN}" -X DELETE \
"$server/api/packages/$owner/generic/$pkg/$version" || true
- name: Initialize submodules
run: |
set -e
git submodule update --init --recursive
- name: Build release (linux_amd64)
env:
GEN: ""
run: |
set -e
make -j"$(nproc)" release
- name: Find extension artifact
id: artifact
run: |
set -euo pipefail
path="$(ls -1 build/release/extension/${EXTENSION_NAME}/${EXTENSION_NAME}.duckdb_extension 2>/dev/null || true)"
if [ -z "$path" ]; then
path="$(find build/release -type f -name '*.duckdb_extension' | head -n 1 || true)"
fi
if [ -z "$path" ]; then
echo "Extension artifact not found" >&2
exit 1
fi
echo "file=$path" >> "$GITHUB_OUTPUT"
echo "Found: $path"
- name: Compute package version
id: ver
run: |
set -euo pipefail
version='${{ inputs.version }}'
if [ -z "$version" ]; then
if [ "${{ github.ref_type }}" = "tag" ]; then
version='${{ github.ref_name }}'
else
version="dev-${GITHUB_SHA::8}"
fi
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Using version: $version"
- name: Upload to Gitea Packages (generic) - name: Upload to Gitea Packages (generic)
env: env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
ACTOR: ${{ github.actor }}
run: | run: |
curl -fsSL -X PUT -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/octet-stream" --upload-file "$file" "$server/api/packages/$owner/generic/$pkg/$version/$name?replace=1" set -euo pipefail
: "${GITEA_TOKEN:?GITEA_TOKEN secret is required}"
# Derive server/owner/pkg from env if not provided
server="${GITHUB_SERVER_URL:-${{ github.server_url }}}"
owner="${GITHUB_REPOSITORY_OWNER:-${{ github.repository_owner }}}"
repo_full="${GITHUB_REPOSITORY:-${{ github.repository }}}"
pkg="${repo_full##*/}"
# Use previously computed version & artifact if available
version='${{ steps.ver.outputs.version }}'
file='${{ steps.artifact.outputs.file }}'
# Fallbacks if steps were skipped
if [ -z "${version}" ]; then
if [ -n "${GITHUB_REF_TYPE:-}" ] && [ "${GITHUB_REF_TYPE}" = "tag" ]; then
version="${GITHUB_REF_NAME:-dev-${GITHUB_SHA::8}}"
else
version="dev-${GITHUB_SHA::8}"
fi
fi
if [ -z "${file}" ]; then
file="$(ls -1 build/release/extension/${EXTENSION_NAME}/${EXTENSION_NAME}.duckdb_extension 2>/dev/null || true)"
if [ -z "$file" ]; then
file="$(find build/release -type f -name '*.duckdb_extension' | head -n 1 || true)"
fi
fi
[ -n "$server" ] || { echo "server not set" >&2; exit 1; }
[ -n "$owner" ] || { echo "owner not set" >&2; exit 1; }
[ -n "$pkg" ] || { echo "pkg not set" >&2; exit 1; }
[ -n "$version" ] || { echo "version not set" >&2; exit 1; }
[ -n "$file" ] || { echo "file not set" >&2; exit 1; }
# Normalize server using effective URL of /api/v1/version (handles http->https)
base_no_trail="$(echo "$server" | sed 's#/*$##')"
effective_version_url=$(curl -sS -L -o /dev/null -w '%{url_effective}' "$base_no_trail/api/v1/version" || echo "")
normalized_server=$(echo "$effective_version_url" | sed -E 's#^(https?://[^/]+).*$#\1#')
if [ -n "$normalized_server" ]; then
server="$normalized_server"
fi
# Use the GitHub actor as basic auth username by default
auth_user="${ACTOR:-$owner}"
name="$(basename "$file")"
url="$server/api/packages/$owner/generic/$pkg/$version/$name?replace=1"
echo "Uploading $file to $url"
echo " auth user=$auth_user"
# Use Basic auth directly (works with package registry)
curl -fS -L -X PUT \
-u "$auth_user:${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--retry 2 --retry-delay 2 --max-time 300 \
--upload-file "$file" "$url"
echo "Upload complete."
# Also upload the DuckDB shell binary
bin_path="./build/release/duckdb"
if [ ! -f "$bin_path" ]; then
echo "duckdb binary not found at $bin_path" >&2
exit 1
fi
bin_name="$(basename "$bin_path")"
bin_url="$server/api/packages/$owner/generic/$pkg/$version/$bin_name?replace=1"
echo "Uploading $bin_path to $bin_url"
curl -fS -L -X PUT \
-u "$auth_user:${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--retry 2 --retry-delay 2 --max-time 300 \
--upload-file "$bin_path" "$bin_url"
echo "DuckDB binary upload complete."

View File

@@ -22,6 +22,76 @@ namespace ui {
unique_ptr<HttpServer> HttpServer::server_instance; unique_ptr<HttpServer> HttpServer::server_instance;
// Helpers for validating request origin/referer in deployments where the UI is
// exposed on a non-localhost host (e.g., Docker, k8s, reverse proxies). These
// checks allow either the configured local_url, or the runtime host derived
// from the request headers. They also allow an escape hatch via the
// environment variable `ui_allow_any_origin=1|true`.
namespace {
// Returns true if the given referer begins with any of the expected base URLs.
static bool RefererStartsWithAny(const std::string &referer,
const std::vector<std::string> &bases) {
for (const auto &base : bases) {
if (!base.empty() && referer.compare(0, base.size(), base) == 0) {
return true;
}
}
return false;
}
static std::vector<std::string>
ExpectedBaseUrls(const httplib::Request &req, const std::string &local_url) {
// Prefer forwarded host if present, otherwise fall back to Host.
auto forwarded_host = req.get_header_value("X-Forwarded-Host");
auto host = forwarded_host.empty() ? req.get_header_value("Host")
: forwarded_host;
std::vector<std::string> bases;
bases.push_back(local_url);
if (!host.empty()) {
bases.push_back(StringUtil::Format("http://%s", host));
bases.push_back(StringUtil::Format("https://%s", host));
}
return bases;
}
static bool IsOriginAllowed(const httplib::Request &req,
const std::string &local_url) {
if (IsEnvEnabled("ui_allow_any_origin")) {
return true;
}
auto origin = req.get_header_value("Origin");
if (origin.empty()) {
return false;
}
auto bases = ExpectedBaseUrls(req, local_url);
for (const auto &base : bases) {
if (origin == base) {
return true;
}
}
return false;
}
static bool IsRefererAllowed(const httplib::Request &req,
const std::string &local_url) {
if (IsEnvEnabled("ui_allow_any_origin")) {
return true;
}
auto referer = req.get_header_value("Referer");
if (referer.empty()) {
return false;
}
return RefererStartsWithAny(referer, ExpectedBaseUrls(req, local_url));
}
} // namespace
HttpServer *HttpServer::GetInstance(ClientContext &context) { HttpServer *HttpServer::GetInstance(ClientContext &context) {
if (server_instance) { if (server_instance) {
// We already have an instance, make sure we're running on the right DB // We already have an instance, make sure we're running on the right DB
@@ -228,8 +298,7 @@ void HttpServer::HandleGetLocalToken(const httplib::Request &req,
httplib::Response &res) { httplib::Response &res) {
// GET requests don't include Origin, so use Referer instead. // GET requests don't include Origin, so use Referer instead.
// Referer includes the path, so only compare the start. // Referer includes the path, so only compare the start.
auto referer = req.get_header_value("Referer"); if (!IsRefererAllowed(req, local_url)) {
if (referer.compare(0, local_url.size(), local_url) != 0) {
res.status = 401; res.status = 401;
return; return;
} }
@@ -321,8 +390,7 @@ void HttpServer::HandleGet(const httplib::Request &req,
void HttpServer::HandleInterrupt(const httplib::Request &req, void HttpServer::HandleInterrupt(const httplib::Request &req,
httplib::Response &res) { httplib::Response &res) {
auto origin = req.get_header_value("Origin"); if (!IsOriginAllowed(req, local_url)) {
if (origin != local_url) {
res.status = 401; res.status = 401;
return; return;
} }
@@ -361,8 +429,7 @@ void HttpServer::HandleRun(const httplib::Request &req, httplib::Response &res,
void HttpServer::DoHandleRun(const httplib::Request &req, void HttpServer::DoHandleRun(const httplib::Request &req,
httplib::Response &res, httplib::Response &res,
const httplib::ContentReader &content_reader) { const httplib::ContentReader &content_reader) {
auto origin = req.get_header_value("Origin"); if (!IsOriginAllowed(req, local_url)) {
if (origin != local_url) {
res.status = 401; res.status = 401;
return; return;
} }
@@ -625,8 +692,7 @@ void HttpServer::DoHandleRun(const httplib::Request &req,
void HttpServer::HandleTokenize(const httplib::Request &req, void HttpServer::HandleTokenize(const httplib::Request &req,
httplib::Response &res, httplib::Response &res,
const httplib::ContentReader &content_reader) { const httplib::ContentReader &content_reader) {
auto origin = req.get_header_value("Origin"); if (!IsOriginAllowed(req, local_url)) {
if (origin != local_url) {
res.status = 401; res.status = 401;
return; return;
} }