Add config option to disable snapshot restore from URL (#8628)

* [ai] Add config boolean to disable URL based snapshot restore

* Merge if-statement

* Comment-out config option by default

* [ai] Also block partial snapshots from remote URLs

* [ai] Only run clock consistency test when staging feature is present

* [ai] Add integration test
This commit is contained in:
Tim Visée
2026-04-24 16:53:57 +02:00
committed by timvisee
parent 58083c694a
commit e79941e845
5 changed files with 127 additions and 1 deletions

View File

@@ -330,6 +330,13 @@ service:
# Prefix for the names of metrics in the /metrics API. # Prefix for the names of metrics in the /metrics API.
# metrics_prefix: qdrant_ # metrics_prefix: qdrant_
# Allow snapshot recovery from remote HTTP/HTTPS URLs.
# If disabled, snapshot recovery will only work with local files and uploads.
# Disabling this can mitigate SSRF risks in environments where the Qdrant node
# has access to internal resources that should not be reachable by users.
# Default: true
# enable_snapshot_url_recovery: true
cluster: cluster:
# Use `enabled: true` to run Qdrant in distributed deployment mode # Use `enabled: true` to run Qdrant in distributed deployment mode
enabled: false enabled: false

View File

@@ -10,7 +10,7 @@ use collection::common::file_utils::move_file;
use collection::common::sha_256; use collection::common::sha_256;
use collection::common::snapshot_stream::SnapshotStream; use collection::common::snapshot_stream::SnapshotStream;
use collection::operations::snapshot_ops::{ use collection::operations::snapshot_ops::{
ShardSnapshotRecover, SnapshotPriority, SnapshotRecover, ShardSnapshotLocation, ShardSnapshotRecover, SnapshotPriority, SnapshotRecover,
}; };
use collection::operations::types::CollectionError; use collection::operations::types::CollectionError;
use collection::operations::verification::new_unchecked_verification_pass; use collection::operations::verification::new_unchecked_verification_pass;
@@ -48,6 +48,7 @@ use crate::common::auth::Auth;
use crate::common::collections::*; use crate::common::collections::*;
use crate::common::http_client::HttpClient; use crate::common::http_client::HttpClient;
use crate::common::snapshots::try_take_partial_snapshot_recovery_lock; use crate::common::snapshots::try_take_partial_snapshot_recovery_lock;
use crate::settings::ServiceConfig;
#[derive(Deserialize, Serialize, JsonSchema, Validate)] #[derive(Deserialize, Serialize, JsonSchema, Validate)]
pub struct SnapshotUploadingParam { pub struct SnapshotUploadingParam {
@@ -249,6 +250,7 @@ async fn upload_snapshot(
async fn recover_from_snapshot( async fn recover_from_snapshot(
dispatcher: web::Data<Dispatcher>, dispatcher: web::Data<Dispatcher>,
http_client: web::Data<HttpClient>, http_client: web::Data<HttpClient>,
service_config: web::Data<ServiceConfig>,
collection: valid::Path<CollectionPath>, collection: valid::Path<CollectionPath>,
request: valid::Json<SnapshotRecover>, request: valid::Json<SnapshotRecover>,
params: valid::Query<SnapshottingParam>, params: valid::Query<SnapshottingParam>,
@@ -256,6 +258,15 @@ async fn recover_from_snapshot(
) -> impl Responder { ) -> impl Responder {
let future = async move { let future = async move {
let snapshot_recover = request.into_inner(); let snapshot_recover = request.into_inner();
if !service_config.enable_snapshot_url_recovery
&& matches!(snapshot_recover.location.scheme(), "http" | "https")
{
return Err(StorageError::forbidden(
"Snapshot recovery from remote URLs is disabled in the configuration",
));
}
let http_client = http_client.client(snapshot_recover.api_key.as_deref())?; let http_client = http_client.client(snapshot_recover.api_key.as_deref())?;
do_recover_from_snapshot( do_recover_from_snapshot(
@@ -442,6 +453,7 @@ async fn stream_shard_snapshot(
async fn recover_shard_snapshot( async fn recover_shard_snapshot(
dispatcher: web::Data<Dispatcher>, dispatcher: web::Data<Dispatcher>,
http_client: web::Data<HttpClient>, http_client: web::Data<HttpClient>,
service_config: web::Data<ServiceConfig>,
path: valid::Path<CollectionShardPath>, path: valid::Path<CollectionShardPath>,
query: web::Query<SnapshottingParam>, query: web::Query<SnapshottingParam>,
valid::Json(request): valid::Json<ShardSnapshotRecover>, valid::Json(request): valid::Json<ShardSnapshotRecover>,
@@ -451,6 +463,15 @@ async fn recover_shard_snapshot(
let pass = new_unchecked_verification_pass(); let pass = new_unchecked_verification_pass();
let future = async move { let future = async move {
if !service_config.enable_snapshot_url_recovery
&& let ShardSnapshotLocation::Url(url) = &request.location
&& matches!(url.scheme(), "http" | "https")
{
return Err(StorageError::forbidden(
"Snapshot recovery from remote URLs is disabled in the configuration",
));
}
let CollectionShardPath { let CollectionShardPath {
collection_name: collection, collection_name: collection,
shard, shard,
@@ -740,6 +761,7 @@ pub struct PartialSnapshotRecoverFrom {
async fn recover_partial_snapshot_from( async fn recover_partial_snapshot_from(
dispatcher: web::Data<Dispatcher>, dispatcher: web::Data<Dispatcher>,
http_client: web::Data<HttpClient>, http_client: web::Data<HttpClient>,
service_config: web::Data<ServiceConfig>,
path: valid::Path<CollectionShardPath>, path: valid::Path<CollectionShardPath>,
query: web::Query<SnapshottingParam>, query: web::Query<SnapshottingParam>,
web::Json(request): web::Json<PartialSnapshotRecoverFrom>, web::Json(request): web::Json<PartialSnapshotRecoverFrom>,
@@ -752,6 +774,17 @@ async fn recover_partial_snapshot_from(
let PartialSnapshotRecoverFrom { peer_url, api_key } = request; let PartialSnapshotRecoverFrom { peer_url, api_key } = request;
let SnapshottingParam { wait } = query.into_inner(); let SnapshottingParam { wait } = query.into_inner();
if !service_config.enable_snapshot_url_recovery && matches!(peer_url.scheme(), "http" | "https")
{
return helpers::process_response_error(
StorageError::forbidden(
"Snapshot recovery from remote URLs is disabled in the configuration",
),
tokio::time::Instant::now(),
None,
);
}
// nothing to verify // nothing to verify
let pass = new_unchecked_verification_pass(); let pass = new_unchecked_verification_pass();

View File

@@ -97,6 +97,13 @@ pub struct ServiceConfig {
#[serde(default)] #[serde(default)]
#[validate(custom(function = validate_metrics_prefix))] #[validate(custom(function = validate_metrics_prefix))]
pub metrics_prefix: Option<String>, pub metrics_prefix: Option<String>,
/// Whether to allow snapshot recovery from remote URLs (http/https).
/// If disabled, snapshot recovery will only work with local files and uploads.
/// Disabling this can mitigate SSRF risks in environments where the Qdrant node
/// has access to internal resources that should not be reachable by users.
#[serde(default = "default_snapshot_url_recovery")]
pub enable_snapshot_url_recovery: bool,
} }
impl ServiceConfig { impl ServiceConfig {
@@ -425,6 +432,10 @@ const fn default_cors() -> bool {
true true
} }
const fn default_snapshot_url_recovery() -> bool {
true
}
const fn default_http_keep_alive_timeout_sec() -> u64 { const fn default_http_keep_alive_timeout_sec() -> u64 {
5 5
} }

View File

@@ -0,0 +1,73 @@
import pathlib
import requests
from .fixtures import create_collection
from .utils import *
COLLECTION_NAME = "test_collection"
# Substring of the error the service returns when URL-based snapshot recovery
# is disabled through the `service.enable_snapshot_url_recovery=false` config.
BLOCKED_ERROR_MESSAGE = "Snapshot recovery from remote URLs is disabled"
def assert_url_recovery_blocked(response: requests.Response):
assert response.status_code == 403, (
f"Expected 403 Forbidden, got {response.status_code}: {response.text}"
)
error = response.json()["status"]["error"]
assert BLOCKED_ERROR_MESSAGE in error, (
f"Expected error message to contain {BLOCKED_ERROR_MESSAGE!r}, got: {error!r}"
)
# Assert that disabling URL-based snapshot recovery through the
# `QDRANT__SERVICE__ENABLE_SNAPSHOT_URL_RECOVERY=false` environment variable is
# actually respected by every snapshot recovery endpoint that accepts a remote
# URL: the service must reject `http`/`https` locations with a `403 Forbidden`
# response before attempting any download.
def test_disable_url_snapshot_recovery(tmp_path: pathlib.Path):
assert_project_root()
peer_urls, _, _ = start_cluster(
tmp_path,
1,
extra_env={"QDRANT__SERVICE__ENABLE_SNAPSHOT_URL_RECOVERY": "false"},
)
peer_url = peer_urls[0]
create_collection(peer_url, collection=COLLECTION_NAME)
# Arbitrary remote URLs; the service must reject them up front rather than
# attempting any network request (the `invalid` TLD never resolves).
remote_urls = [
"http://example.invalid/snapshot.tar",
"https://example.invalid/snapshot.tar",
]
shard_id = 0
# Full collection snapshot recovery
for location in remote_urls:
resp = requests.put(
f"{peer_url}/collections/{COLLECTION_NAME}/snapshots/recover",
json={"location": location},
)
assert_url_recovery_blocked(resp)
# Shard snapshot recovery
for location in remote_urls:
resp = requests.put(
f"{peer_url}/collections/{COLLECTION_NAME}/shards/{shard_id}/snapshots/recover",
json={"location": location},
)
assert_url_recovery_blocked(resp)
# Partial shard snapshot recovery from a peer URL
for peer_recover_url in remote_urls:
resp = requests.post(
f"{peer_url}/collections/{COLLECTION_NAME}/shards/{shard_id}/snapshot/partial/recover_from",
json={"peer_url": peer_recover_url},
)
assert_url_recovery_blocked(resp)

View File

@@ -16,6 +16,8 @@ def test_shard_snapshot_clocks_consistency(tmp_path: pathlib.Path):
peer_urls, _, _ = start_cluster(tmp_path, 1, extra_env={ "QDRANT__STAGING__SNAPSHOT_SHARD_CLOCKS_DELAY": "5" }) peer_urls, _, _ = start_cluster(tmp_path, 1, extra_env={ "QDRANT__STAGING__SNAPSHOT_SHARD_CLOCKS_DELAY": "5" })
peer_url = peer_urls[0] peer_url = peer_urls[0]
skip_if_no_feature(peer_url, "staging")
# Bootstrap collection # Bootstrap collection
create_collection(peer_url) create_collection(peer_url)
upsert_random_points(peer_url, 10_000, batch_size=100, with_sparse_vector=False) upsert_random_points(peer_url, 10_000, batch_size=100, with_sparse_vector=False)