mirror of
https://github.com/qdrant/qdrant.git
synced 2026-07-23 11:11:00 -05:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
73
tests/consensus_tests/test_disable_url_snapshot_recovery.py
Normal file
73
tests/consensus_tests/test_disable_url_snapshot_recovery.py
Normal 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)
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user