This is an automated email from the ASF dual-hosted git repository.

spetz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new ab734f17f fix(connectors): validate connector key before it becomes a 
path (#4083)
ab734f17f is described below

commit ab734f17f849497d828667b5fba51820c3c3b531
Author: Ryan Huang <[email protected]>
AuthorDate: Mon Sep 7 22:57:18 2026 +0800

    fix(connectors): validate connector key before it becomes a path (#4083)
    
    Closes #4058
---
 core/connectors/runtime/README.md                  |  15 ++
 core/connectors/runtime/src/api/error.rs           |  15 +-
 core/connectors/runtime/src/api/key.rs             |  42 +++++
 core/connectors/runtime/src/api/mod.rs             |   1 +
 core/connectors/runtime/src/api/sink.rs            |  44 +++--
 core/connectors/runtime/src/api/source.rs          |  44 +++--
 core/connectors/runtime/src/configs/connectors.rs  | 182 +++++++++++++++++++--
 .../src/configs/connectors/http_provider.rs        |  12 +-
 .../src/configs/connectors/local_provider.rs       |  50 ++++--
 core/connectors/runtime/src/error.rs               |   6 +
 core/connectors/runtime/src/main.rs                |  30 +++-
 core/integration/tests/connectors/api/endpoints.rs | 104 ++++++++++++
 .../tests/connectors/api/key_validation.toml       |  31 ++++
 13 files changed, 505 insertions(+), 71 deletions(-)

diff --git a/core/connectors/runtime/README.md 
b/core/connectors/runtime/README.md
index 23803704d..ec824abda 100644
--- a/core/connectors/runtime/README.md
+++ b/core/connectors/runtime/README.md
@@ -326,6 +326,21 @@ Currently, it does expose the following endpoints:
 - `POST /sources/{key}/restart`: stop the source and start it again from its 
highest stored configuration version, which on the local provider is not 
necessarily the active one 
([#3848](https://github.com/apache/iggy/issues/3848)).
 - `GET /sources/{key}/transforms`: source transforms to be applied to the 
fields.
 
+`{key}` is the connector key: at most 128 bytes of ASCII letters, digits, `-`,
+`_` and `.`, starting with a letter or digit. A decoded segment outside that
+rule, such as `..%2F..%2Fpwned`, is answered with `400 Bad Request` and the
+error code `invalid_connector_key` before the request reaches the configuration
+provider, because on the local provider the key becomes part of a filename 
under
+`config_dir`, and on the HTTP provider part of a URL. An unencoded `/` splits
+the path and matches no route, so it is a `404`.
+
+Keys loaded from configuration files or the HTTP provider are not rejected, so
+existing deployments keep starting, but a key outside the rule is logged at
+startup and cannot be addressed through the API. Keys are case-sensitive to the
+runtime while the local provider maps them to filenames, so on a
+case-insensitive filesystem two keys that differ only by case share one file;
+prefer lowercase.
+
 ## Telemetry
 
 The connector runtime supports OpenTelemetry for logs and traces. To enable 
telemetry, add the following configuration:
diff --git a/core/connectors/runtime/src/api/error.rs 
b/core/connectors/runtime/src/api/error.rs
index e22070c17..77125bad8 100644
--- a/core/connectors/runtime/src/api/error.rs
+++ b/core/connectors/runtime/src/api/error.rs
@@ -16,7 +16,7 @@
 // under the License.
 
 use crate::error::RuntimeError;
-use axum::{Json, http::StatusCode, response::IntoResponse};
+use axum::{Json, extract::rejection::PathRejection, http::StatusCode, 
response::IntoResponse};
 use serde::Serialize;
 use thiserror::Error;
 use tracing::error;
@@ -27,6 +27,8 @@ pub enum ApiError {
     Error(#[from] RuntimeError),
     #[error(transparent)]
     JsonError(#[from] serde_json::Error),
+    #[error(transparent)]
+    PathRejection(#[from] PathRejection),
 }
 
 #[derive(Debug, Serialize)]
@@ -43,6 +45,7 @@ impl IntoResponse for ApiError {
                 let status_code = match error {
                     RuntimeError::MissingIggyCredentials => 
StatusCode::BAD_REQUEST,
                     RuntimeError::InvalidConfiguration(_) => 
StatusCode::BAD_REQUEST,
+                    RuntimeError::InvalidConnectorKey(_) => 
StatusCode::BAD_REQUEST,
                     RuntimeError::CannotConvertConfiguration => 
StatusCode::BAD_REQUEST,
                     RuntimeError::SinkNotFound(_) => StatusCode::NOT_FOUND,
                     RuntimeError::SourceNotFound(_) => StatusCode::NOT_FOUND,
@@ -66,6 +69,16 @@ impl IntoResponse for ApiError {
                     }),
                 )
             }
+            ApiError::PathRejection(rejection) => {
+                error!("There was a path error: {rejection}");
+                (
+                    rejection.status(),
+                    Json(ErrorResponse {
+                        code: "invalid_path".to_owned(),
+                        reason: rejection.body_text(),
+                    }),
+                )
+            }
         }
         .into_response()
     }
diff --git a/core/connectors/runtime/src/api/key.rs 
b/core/connectors/runtime/src/api/key.rs
new file mode 100644
index 000000000..a6438c2a7
--- /dev/null
+++ b/core/connectors/runtime/src/api/key.rs
@@ -0,0 +1,42 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::error::ApiError;
+use crate::configs::connectors::ConnectorKey;
+use axum::extract::{FromRequestParts, Path};
+use axum::http::request::Parts;
+use serde::Deserialize;
+
+/// The `{key}` route segment parsed as a `ConnectorKey`. Deserializing
+/// `Path<ConnectorKey>` directly would answer a bad key with axum's plain-text
+/// rejection; going through `ApiError` keeps the `{code, reason}` envelope the
+/// rest of the API returns.
+pub struct KeyPath(pub ConnectorKey);
+
+#[derive(Deserialize)]
+struct KeyParam {
+    key: String,
+}
+
+impl<S: Send + Sync> FromRequestParts<S> for KeyPath {
+    type Rejection = ApiError;
+
+    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, 
Self::Rejection> {
+        let Path(KeyParam { key }) = Path::from_request_parts(parts, 
state).await?;
+        Ok(Self(ConnectorKey::try_from(key)?))
+    }
+}
diff --git a/core/connectors/runtime/src/api/mod.rs 
b/core/connectors/runtime/src/api/mod.rs
index 62950fa6e..5669ede29 100644
--- a/core/connectors/runtime/src/api/mod.rs
+++ b/core/connectors/runtime/src/api/mod.rs
@@ -34,6 +34,7 @@ use tracing::{error, info, warn};
 mod auth;
 pub mod config;
 mod error;
+mod key;
 mod models;
 mod sink;
 mod source;
diff --git a/core/connectors/runtime/src/api/sink.rs 
b/core/connectors/runtime/src/api/sink.rs
index c70f85a57..d99e89cf1 100644
--- a/core/connectors/runtime/src/api/sink.rs
+++ b/core/connectors/runtime/src/api/sink.rs
@@ -18,6 +18,7 @@
 use super::{
     config::map_connector_config,
     error::ApiError,
+    key::KeyPath,
     models::{SinkDetailsResponse, SinkInfoResponse, TransformResponse},
 };
 use crate::api::models::SinkConfigResponse;
@@ -69,10 +70,10 @@ async fn get_sinks(
 
 async fn get_sink(
     State(context): State<Arc<RuntimeContext>>,
-    Path(key): Path<String>,
+    KeyPath(key): KeyPath,
 ) -> Result<Json<SinkDetailsResponse>, ApiError> {
     let Some(sink) = context.sinks.get(&key).await else {
-        return Err(ApiError::Error(RuntimeError::SinkNotFound(key)));
+        return Err(ApiError::Error(RuntimeError::SinkNotFound(key.into())));
     };
     let sink = sink.lock().await;
     Ok(Json(SinkDetailsResponse {
@@ -83,11 +84,11 @@ async fn get_sink(
 
 async fn get_sink_plugin_config(
     State(context): State<Arc<RuntimeContext>>,
-    Path(key): Path<String>,
+    KeyPath(key): KeyPath,
     Query(query): Query<GetSinkConfig>,
 ) -> Result<impl IntoResponse, ApiError> {
     let Some(sink) = context.sinks.get(&key).await else {
-        return Err(ApiError::Error(RuntimeError::SinkNotFound(key)));
+        return Err(ApiError::Error(RuntimeError::SinkNotFound(key.into())));
     };
     let sink = sink.lock().await;
     let Some(config) = sink.config.plugin_config.as_ref() else {
@@ -110,10 +111,10 @@ struct GetSinkConfig {
 
 async fn get_sink_transforms(
     State(context): State<Arc<RuntimeContext>>,
-    Path(key): Path<String>,
+    KeyPath(key): KeyPath,
 ) -> Result<Json<Vec<TransformResponse>>, ApiError> {
     let Some(sink) = context.sinks.get(&key).await else {
-        return Err(ApiError::Error(RuntimeError::SinkNotFound(key)));
+        return Err(ApiError::Error(RuntimeError::SinkNotFound(key.into())));
     };
     let sink = sink.lock().await;
     let Some(transforms) = sink.config.transforms.as_ref() else {
@@ -134,13 +135,13 @@ async fn get_sink_transforms(
 
 async fn get_sink_configs(
     State(context): State<Arc<RuntimeContext>>,
-    Path((key,)): Path<(String,)>,
+    KeyPath(key): KeyPath,
 ) -> Result<Json<Vec<SinkConfigResponse>>, ApiError> {
     let active_config = context
         .sinks
         .get_config(&key)
         .await
-        .ok_or(ApiError::Error(RuntimeError::SinkNotFound(key.clone())))?;
+        .ok_or_else(|| 
ApiError::Error(RuntimeError::SinkNotFound(key.to_string())))?;
     let configs = context.config_provider.get_sink_configs(&key).await?;
     let configs = configs
         .into_iter()
@@ -154,12 +155,12 @@ async fn get_sink_configs(
 
 async fn create_sink_config(
     State(context): State<Arc<RuntimeContext>>,
-    Path((key,)): Path<(String,)>,
+    KeyPath(key): KeyPath,
     Json(config): Json<CreateSinkConfig>,
 ) -> Result<Json<SinkConfigResponse>, ApiError> {
     let created_config = context
         .config_provider
-        .create_sink_config(&key, config.clone())
+        .create_sink_config(&key, config)
         .await?;
 
     Ok(Json(SinkConfigResponse {
@@ -168,15 +169,21 @@ async fn create_sink_config(
     }))
 }
 
+#[derive(Debug, Deserialize)]
+struct ConfigVersion {
+    version: u64,
+}
+
 async fn get_sink_config(
     State(context): State<Arc<RuntimeContext>>,
-    Path((key, version)): Path<(String, u64)>,
+    KeyPath(key): KeyPath,
+    Path(ConfigVersion { version }): Path<ConfigVersion>,
 ) -> Result<Json<SinkConfigResponse>, ApiError> {
     let active_config = context
         .sinks
         .get_config(&key)
         .await
-        .ok_or(ApiError::Error(RuntimeError::SinkNotFound(key.clone())))?;
+        .ok_or_else(|| 
ApiError::Error(RuntimeError::SinkNotFound(key.to_string())))?;
 
     let config = context
         .config_provider
@@ -192,20 +199,21 @@ async fn get_sink_config(
             }))
         }
         None => Err(ApiError::Error(RuntimeError::SinkConfigNotFound(
-            key, version,
+            key.into(),
+            version,
         ))),
     }
 }
 
 async fn get_sink_active_config(
     State(context): State<Arc<RuntimeContext>>,
-    Path((key,)): Path<(String,)>,
+    KeyPath(key): KeyPath,
 ) -> Result<Json<SinkConfigResponse>, ApiError> {
     let config = context
         .sinks
         .get_config(&key)
         .await
-        .ok_or(ApiError::Error(RuntimeError::SinkNotFound(key)))?;
+        .ok_or(ApiError::Error(RuntimeError::SinkNotFound(key.into())))?;
     Ok(Json(SinkConfigResponse {
         config,
         active: true,
@@ -219,7 +227,7 @@ struct UpdateSinkActiveConfig {
 
 async fn update_sink_active_config(
     State(context): State<Arc<RuntimeContext>>,
-    Path((key,)): Path<(String,)>,
+    KeyPath(key): KeyPath,
     Json(update): Json<UpdateSinkActiveConfig>,
 ) -> Result<StatusCode, ApiError> {
     context
@@ -236,7 +244,7 @@ struct DeleteSinkConfig {
 
 async fn delete_sink_config(
     State(context): State<Arc<RuntimeContext>>,
-    Path((key,)): Path<(String,)>,
+    KeyPath(key): KeyPath,
     Query(query): Query<DeleteSinkConfig>,
 ) -> Result<StatusCode, ApiError> {
     context
@@ -248,7 +256,7 @@ async fn delete_sink_config(
 
 async fn restart_sink(
     State(context): State<Arc<RuntimeContext>>,
-    Path(key): Path<String>,
+    KeyPath(key): KeyPath,
 ) -> Result<StatusCode, ApiError> {
     context
         .sinks
diff --git a/core/connectors/runtime/src/api/source.rs 
b/core/connectors/runtime/src/api/source.rs
index a1c700edf..480c7dffa 100644
--- a/core/connectors/runtime/src/api/source.rs
+++ b/core/connectors/runtime/src/api/source.rs
@@ -18,6 +18,7 @@
 use super::{
     config::map_connector_config,
     error::ApiError,
+    key::KeyPath,
     models::{SourceDetailsResponse, SourceInfoResponse, TransformResponse},
 };
 use crate::api::models::SourceConfigResponse;
@@ -72,10 +73,10 @@ async fn get_sources(
 
 async fn get_source(
     State(context): State<Arc<RuntimeContext>>,
-    Path(key): Path<String>,
+    KeyPath(key): KeyPath,
 ) -> Result<Json<SourceDetailsResponse>, ApiError> {
     let Some(source) = context.sources.get(&key).await else {
-        return Err(ApiError::Error(RuntimeError::SourceNotFound(key)));
+        return Err(ApiError::Error(RuntimeError::SourceNotFound(key.into())));
     };
     let source = source.lock().await;
     Ok(Json(SourceDetailsResponse {
@@ -86,11 +87,11 @@ async fn get_source(
 
 async fn get_source_plugin_config(
     State(context): State<Arc<RuntimeContext>>,
-    Path(key): Path<String>,
+    KeyPath(key): KeyPath,
     Query(query): Query<GetSourceConfig>,
 ) -> Result<impl IntoResponse, ApiError> {
     let Some(source) = context.sources.get(&key).await else {
-        return Err(ApiError::Error(RuntimeError::SourceNotFound(key)));
+        return Err(ApiError::Error(RuntimeError::SourceNotFound(key.into())));
     };
     let source = source.lock().await;
     let Some(config) = source.config.plugin_config.as_ref() else {
@@ -113,10 +114,10 @@ struct GetSourceConfig {
 
 async fn get_source_transforms(
     State(context): State<Arc<RuntimeContext>>,
-    Path(key): Path<String>,
+    KeyPath(key): KeyPath,
 ) -> Result<Json<Vec<TransformResponse>>, ApiError> {
     let Some(source) = context.sources.get(&key).await else {
-        return Err(ApiError::Error(RuntimeError::SourceNotFound(key)));
+        return Err(ApiError::Error(RuntimeError::SourceNotFound(key.into())));
     };
     let source = source.lock().await;
     let Some(transforms) = source.config.transforms.as_ref() else {
@@ -137,13 +138,13 @@ async fn get_source_transforms(
 
 async fn get_source_configs(
     State(context): State<Arc<RuntimeContext>>,
-    Path((key,)): Path<(String,)>,
+    KeyPath(key): KeyPath,
 ) -> Result<Json<Vec<SourceConfigResponse>>, ApiError> {
     let active_config = context
         .sources
         .get_config(&key)
         .await
-        .ok_or(ApiError::Error(RuntimeError::SourceNotFound(key.clone())))?;
+        .ok_or_else(|| 
ApiError::Error(RuntimeError::SourceNotFound(key.to_string())))?;
     let configs = context.config_provider.get_source_configs(&key).await?;
     let configs = configs
         .into_iter()
@@ -157,12 +158,12 @@ async fn get_source_configs(
 
 async fn create_source_config(
     State(context): State<Arc<RuntimeContext>>,
-    Path((key,)): Path<(String,)>,
+    KeyPath(key): KeyPath,
     Json(config): Json<CreateSourceConfig>,
 ) -> Result<Json<SourceConfigResponse>, ApiError> {
     let created_config = context
         .config_provider
-        .create_source_config(&key, config.clone())
+        .create_source_config(&key, config)
         .await?;
 
     Ok(Json(SourceConfigResponse {
@@ -171,15 +172,21 @@ async fn create_source_config(
     }))
 }
 
+#[derive(Debug, Deserialize)]
+struct ConfigVersion {
+    version: u64,
+}
+
 async fn get_source_config(
     State(context): State<Arc<RuntimeContext>>,
-    Path((key, version)): Path<(String, u64)>,
+    KeyPath(key): KeyPath,
+    Path(ConfigVersion { version }): Path<ConfigVersion>,
 ) -> Result<Json<SourceConfigResponse>, ApiError> {
     let active_config = context
         .sources
         .get_config(&key)
         .await
-        .ok_or(ApiError::Error(RuntimeError::SourceNotFound(key.clone())))?;
+        .ok_or_else(|| 
ApiError::Error(RuntimeError::SourceNotFound(key.to_string())))?;
 
     let config = context
         .config_provider
@@ -195,20 +202,21 @@ async fn get_source_config(
             }))
         }
         None => Err(ApiError::Error(RuntimeError::SourceConfigNotFound(
-            key, version,
+            key.into(),
+            version,
         ))),
     }
 }
 
 async fn get_source_active_config(
     State(context): State<Arc<RuntimeContext>>,
-    Path((key,)): Path<(String,)>,
+    KeyPath(key): KeyPath,
 ) -> Result<Json<SourceConfigResponse>, ApiError> {
     let config = context
         .sources
         .get_config(&key)
         .await
-        .ok_or(ApiError::Error(RuntimeError::SourceNotFound(key)))?;
+        .ok_or(ApiError::Error(RuntimeError::SourceNotFound(key.into())))?;
     Ok(Json(SourceConfigResponse {
         config,
         active: true,
@@ -222,7 +230,7 @@ struct UpdateSourceActiveConfig {
 
 async fn update_source_active_config(
     State(context): State<Arc<RuntimeContext>>,
-    Path((key,)): Path<(String,)>,
+    KeyPath(key): KeyPath,
     Json(update): Json<UpdateSourceActiveConfig>,
 ) -> Result<StatusCode, ApiError> {
     context
@@ -239,7 +247,7 @@ struct DeleteSourceConfig {
 
 async fn delete_source_config(
     State(context): State<Arc<RuntimeContext>>,
-    Path((key,)): Path<(String,)>,
+    KeyPath(key): KeyPath,
     Query(query): Query<DeleteSourceConfig>,
 ) -> Result<StatusCode, ApiError> {
     context
@@ -251,7 +259,7 @@ async fn delete_source_config(
 
 async fn restart_source(
     State(context): State<Arc<RuntimeContext>>,
-    Path(key): Path<String>,
+    KeyPath(key): KeyPath,
 ) -> Result<StatusCode, ApiError> {
     context
         .sources
diff --git a/core/connectors/runtime/src/configs/connectors.rs 
b/core/connectors/runtime/src/configs/connectors.rs
index 24647e870..ad6a6c435 100644
--- a/core/connectors/runtime/src/configs/connectors.rs
+++ b/core/connectors/runtime/src/configs/connectors.rs
@@ -30,7 +30,9 @@ use iggy_connector_sdk::transforms::TransformType;
 use serde::{Deserialize, Serialize};
 use std::collections::HashMap;
 use std::fmt::Formatter;
+use std::ops::Deref;
 use std::path::PathBuf;
+use std::str::FromStr;
 use strum::Display;
 
 #[derive(
@@ -49,6 +51,74 @@ pub enum ConfigFormat {
     Text,
 }
 
+/// A connector key becomes part of a filename under the local provider's
+/// `config_dir` and of a URL under the HTTP provider, so it must stay a single
+/// path component. Requiring a leading letter or digit is what rules out `.`,
+/// `..` and hidden-file names outright, instead of relying on the `sink_` /
+/// `source_` filename prefix to neutralize them.
+#[derive(Debug)]
+pub struct ConnectorKey(String);
+
+impl ConnectorKey {
+    /// Leaves room for the `source_` prefix, the version suffix and the
+    /// `.toml` extension inside a 255-byte filename limit.
+    pub const MAX_LENGTH: usize = 128;
+
+    pub fn as_str(&self) -> &str {
+        &self.0
+    }
+
+    fn is_valid(key: &str) -> bool {
+        key.len() <= Self::MAX_LENGTH
+            && key.as_bytes().split_first().is_some_and(|(first, rest)| {
+                first.is_ascii_alphanumeric()
+                    && rest.iter().all(|byte| {
+                        byte.is_ascii_alphanumeric() || matches!(*byte, b'-' | 
b'_' | b'.')
+                    })
+            })
+    }
+}
+
+impl TryFrom<String> for ConnectorKey {
+    type Error = RuntimeError;
+
+    fn try_from(key: String) -> Result<Self, Self::Error> {
+        if Self::is_valid(&key) {
+            Ok(Self(key))
+        } else {
+            Err(RuntimeError::InvalidConnectorKey(key))
+        }
+    }
+}
+
+impl FromStr for ConnectorKey {
+    type Err = RuntimeError;
+
+    fn from_str(key: &str) -> Result<Self, Self::Err> {
+        Self::try_from(key.to_owned())
+    }
+}
+
+impl Deref for ConnectorKey {
+    type Target = str;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl From<ConnectorKey> for String {
+    fn from(key: ConnectorKey) -> Self {
+        key.0
+    }
+}
+
+impl std::fmt::Display for ConnectorKey {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.write_str(&self.0)
+    }
+}
+
 #[derive(Debug, Clone, Deserialize, Serialize)]
 #[serde(tag = "type", rename_all = "lowercase")]
 pub enum ConnectorConfig {
@@ -87,17 +157,17 @@ pub struct CreateSinkConfig {
 }
 
 impl CreateSinkConfig {
-    fn to_sink_config(&self, key: &str, version: u64) -> SinkConfig {
+    fn into_sink_config(self, key: &ConnectorKey, version: u64) -> SinkConfig {
         SinkConfig {
-            key: key.to_owned(),
+            key: key.to_string(),
             enabled: self.enabled,
             version,
-            name: self.name.clone(),
-            path: self.path.clone(),
-            transforms: self.transforms.clone(),
-            streams: self.streams.clone(),
+            name: self.name,
+            path: self.path,
+            transforms: self.transforms,
+            streams: self.streams,
             plugin_config_format: self.plugin_config_format,
-            plugin_config: self.plugin_config.clone(),
+            plugin_config: self.plugin_config,
             verbose: self.verbose,
             benchmark: self.benchmark,
         }
@@ -140,17 +210,17 @@ pub struct CreateSourceConfig {
 }
 
 impl CreateSourceConfig {
-    fn to_source_config(&self, key: &str, version: u64) -> SourceConfig {
+    fn into_source_config(self, key: &ConnectorKey, version: u64) -> 
SourceConfig {
         SourceConfig {
-            key: key.to_owned(),
+            key: key.to_string(),
             enabled: self.enabled,
             version,
-            name: self.name.clone(),
-            path: self.path.clone(),
-            transforms: self.transforms.clone(),
-            streams: self.streams.clone(),
+            name: self.name,
+            path: self.path,
+            transforms: self.transforms,
+            streams: self.streams,
             plugin_config_format: self.plugin_config_format,
-            plugin_config: self.plugin_config.clone(),
+            plugin_config: self.plugin_config,
             verbose: self.verbose,
             benchmark: self.benchmark,
         }
@@ -222,16 +292,19 @@ pub struct ConnectorConfigVersions {
     pub sources: HashMap<String, ConnectorConfigVersionInfo>,
 }
 
+/// Only the two `create_*` methods take a parsed key: they are where the local
+/// provider turns the key into a filename, so the type carries the proof that
+/// the API boundary already validated it. The other methods only compare keys.
 #[async_trait]
 pub trait ConnectorsConfigProvider: Send + Sync {
     async fn create_sink_config(
         &self,
-        key: &str,
+        key: &ConnectorKey,
         config: CreateSinkConfig,
     ) -> Result<SinkConfig, RuntimeError>;
     async fn create_source_config(
         &self,
-        key: &str,
+        key: &ConnectorKey,
         config: CreateSourceConfig,
     ) -> Result<SourceConfig, RuntimeError>;
     async fn get_active_configs(&self) -> Result<ConnectorsConfig, 
RuntimeError>;
@@ -411,3 +484,80 @@ impl ConnectorsConfig {
         &self.sources
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn given_single_component_key_when_parsed_should_succeed() {
+        for key in ["postgres", "es-sink.v2_1", "A1", "9lives", "a.b-c_d"] {
+            let parsed: ConnectorKey = key
+                .parse()
+                .unwrap_or_else(|error| panic!("key {key:?} should be 
accepted, got: {error}"));
+            assert_eq!(parsed.as_str(), key);
+            assert_eq!(parsed.to_string(), key);
+        }
+    }
+
+    #[test]
+    fn given_key_at_the_length_limit_when_parsed_should_succeed() {
+        let key = "k".repeat(ConnectorKey::MAX_LENGTH);
+        assert_eq!(key.parse::<ConnectorKey>().unwrap().as_str(), key);
+    }
+
+    #[test]
+    fn given_key_over_the_length_limit_when_parsed_should_fail() {
+        assert_rejected(&"k".repeat(ConnectorKey::MAX_LENGTH + 1));
+    }
+
+    #[test]
+    fn given_key_with_path_separator_when_parsed_should_fail() {
+        for key in ["../../pwned", "x/../../../tmp/pwn", "a/b", "a\\b", 
"/abs"] {
+            assert_rejected(key);
+        }
+    }
+
+    #[test]
+    fn 
given_key_that_is_a_dot_segment_or_hidden_name_when_parsed_should_fail() {
+        for key in [".", "..", "..evil", ".hidden"] {
+            assert_rejected(key);
+        }
+    }
+
+    #[test]
+    fn given_key_with_characters_outside_the_charset_when_parsed_should_fail() 
{
+        for key in [
+            "",
+            "-leading-dash",
+            "_leading_underscore",
+            "with space",
+            "k\0ey",
+            "k\ney",
+            "ключ",
+            "a#b",
+        ] {
+            assert_rejected(key);
+        }
+    }
+
+    #[test]
+    fn given_owned_key_when_converted_should_apply_the_same_rule() {
+        let accepted = ConnectorKey::try_from("random".to_owned()).unwrap();
+        assert_eq!(accepted.as_str(), "random");
+
+        let rejected = 
ConnectorKey::try_from("../pwned".to_owned()).unwrap_err();
+        assert!(
+            matches!(&rejected, RuntimeError::InvalidConnectorKey(key) if key 
== "../pwned"),
+            "unexpected error: {rejected}"
+        );
+    }
+
+    fn assert_rejected(key: &str) {
+        let result = key.parse::<ConnectorKey>();
+        assert!(
+            matches!(&result, Err(RuntimeError::InvalidConnectorKey(rejected)) 
if rejected == key),
+            "key {key:?} should be rejected, got: {result:?}"
+        );
+    }
+}
diff --git a/core/connectors/runtime/src/configs/connectors/http_provider.rs 
b/core/connectors/runtime/src/configs/connectors/http_provider.rs
index ea49e5e0d..8133719e9 100644
--- a/core/connectors/runtime/src/configs/connectors/http_provider.rs
+++ b/core/connectors/runtime/src/configs/connectors/http_provider.rs
@@ -21,8 +21,8 @@ mod url_builder;
 use 
crate::configs::connectors::http_provider::response_extractor::ResponseExtractor;
 use crate::configs::connectors::http_provider::url_builder::{TemplateKeys, 
UrlBuilder};
 use crate::configs::connectors::{
-    ConnectorConfigVersions, ConnectorsConfig, ConnectorsConfigProvider, 
CreateSinkConfig,
-    CreateSourceConfig, SinkConfig, SourceConfig,
+    ConnectorConfigVersions, ConnectorKey, ConnectorsConfig, 
ConnectorsConfigProvider,
+    CreateSinkConfig, CreateSourceConfig, SinkConfig, SourceConfig,
 };
 use crate::configs::runtime::{ResponseConfig, RetryConfig};
 use crate::error::RuntimeError;
@@ -131,11 +131,11 @@ impl HttpConnectorsConfigProvider {
 impl ConnectorsConfigProvider for HttpConnectorsConfigProvider {
     async fn create_sink_config(
         &self,
-        key: &str,
+        key: &ConnectorKey,
         config: CreateSinkConfig,
     ) -> Result<SinkConfig, RuntimeError> {
         let mut vars = HashMap::new();
-        vars.insert("key", key);
+        vars.insert("key", key.as_str());
         let url = self.url_builder.build(TemplateKeys::CREATE_SINK, &vars);
 
         let response = self
@@ -157,11 +157,11 @@ impl ConnectorsConfigProvider for 
HttpConnectorsConfigProvider {
 
     async fn create_source_config(
         &self,
-        key: &str,
+        key: &ConnectorKey,
         config: CreateSourceConfig,
     ) -> Result<SourceConfig, RuntimeError> {
         let mut vars = HashMap::new();
-        vars.insert("key", key);
+        vars.insert("key", key.as_str());
         let url = self.url_builder.build(TemplateKeys::CREATE_SOURCE, &vars);
 
         let response = self
diff --git a/core/connectors/runtime/src/configs/connectors/local_provider.rs 
b/core/connectors/runtime/src/configs/connectors/local_provider.rs
index 566ca48b5..5df18e238 100644
--- a/core/connectors/runtime/src/configs/connectors/local_provider.rs
+++ b/core/connectors/runtime/src/configs/connectors/local_provider.rs
@@ -16,8 +16,9 @@
 // under the License.
 
 use crate::configs::connectors::{
-    ConnectorConfig, ConnectorConfigVersionInfo, ConnectorConfigVersions, 
ConnectorsConfig,
-    ConnectorsConfigProvider, CreateSinkConfig, CreateSourceConfig, 
SinkConfig, SourceConfig,
+    ConnectorConfig, ConnectorConfigVersionInfo, ConnectorConfigVersions, 
ConnectorKey,
+    ConnectorsConfig, ConnectorsConfigProvider, CreateSinkConfig, 
CreateSourceConfig, SinkConfig,
+    SourceConfig,
 };
 use crate::error::RuntimeError;
 use ::configs::{ConfigProvider, FileConfigProvider, TypedEnvProvider};
@@ -392,18 +393,18 @@ impl BaseConnectorConfig {
 impl ConnectorsConfigProvider for LocalConnectorsConfigProvider<Initialized> {
     async fn create_sink_config(
         &self,
-        key: &str,
+        key: &ConnectorKey,
         cmd: CreateSinkConfig,
     ) -> Result<SinkConfig, RuntimeError> {
         let sinks = self.state.connectors_config.sinks();
         let next_version = sinks
             .iter()
-            .filter(|entry| entry.key().key == key)
+            .filter(|entry| entry.key().key == key.as_str())
             .max_by_key(|entry| entry.config.version)
             .map(|entry| entry.config.version + 1)
             .unwrap_or(0);
 
-        let config = cmd.to_sink_config(key, next_version);
+        let config = cmd.into_sink_config(key, next_version);
         let connector_config = ConnectorConfig::Sink(config.clone());
         let connector_id: ConnectorId = (&connector_config).into();
 
@@ -418,7 +419,7 @@ impl ConnectorsConfigProvider for 
LocalConnectorsConfigProvider<Initialized> {
             SinkConfigFile {
                 config: config.clone(),
                 created_at: Utc::now(),
-                path: path.clone(),
+                path,
             },
         );
 
@@ -427,18 +428,18 @@ impl ConnectorsConfigProvider for 
LocalConnectorsConfigProvider<Initialized> {
 
     async fn create_source_config(
         &self,
-        key: &str,
+        key: &ConnectorKey,
         cmd: CreateSourceConfig,
     ) -> Result<SourceConfig, RuntimeError> {
         let sources = &self.state.connectors_config.sources;
         let next_version = sources
             .iter()
-            .filter(|entry| entry.key().key == key)
+            .filter(|entry| entry.key().key == key.as_str())
             .max_by_key(|entry| entry.config.version)
             .map(|entry| entry.config.version + 1)
             .unwrap_or(0);
 
-        let config = cmd.to_source_config(key, next_version);
+        let config = cmd.into_source_config(key, next_version);
         let connector_config = ConnectorConfig::Source(config.clone());
         let connector_id: ConnectorId = (&connector_config).into();
 
@@ -453,7 +454,7 @@ impl ConnectorsConfigProvider for 
LocalConnectorsConfigProvider<Initialized> {
             SourceConfigFile {
                 config: config.clone(),
                 created_at: Utc::now(),
-                path: path.clone(),
+                path,
             },
         );
 
@@ -851,3 +852,32 @@ impl Provider for ConnectorEnvProvider {
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use tempfile::TempDir;
+
+    #[tokio::test]
+    async fn 
given_valid_key_when_creating_source_config_should_write_prefixed_file() {
+        let dir = TempDir::new().unwrap();
+        let provider = 
LocalConnectorsConfigProvider::new(dir.path().to_str().unwrap())
+            .init()
+            .await
+            .unwrap();
+        let key: ConnectorKey = "random".parse().unwrap();
+
+        let config = provider
+            .create_source_config(&key, CreateSourceConfig::default())
+            .await
+            .unwrap();
+
+        assert_eq!(config.key, "random");
+        assert_eq!(config.version, 0);
+        let entries: Vec<String> = std::fs::read_dir(dir.path())
+            .unwrap()
+            .map(|entry| 
entry.unwrap().file_name().to_string_lossy().into_owned())
+            .collect();
+        assert_eq!(entries, vec!["source_random_0.toml"]);
+    }
+}
diff --git a/core/connectors/runtime/src/error.rs 
b/core/connectors/runtime/src/error.rs
index 35be3bb17..f0f5d11b0 100644
--- a/core/connectors/runtime/src/error.rs
+++ b/core/connectors/runtime/src/error.rs
@@ -21,6 +21,11 @@ use thiserror::Error;
 pub enum RuntimeError {
     #[error("Invalid configuration: {0}")]
     InvalidConfiguration(String),
+    #[error(
+        "Invalid connector key {0:?}: expected at most {max_length} bytes of 
ASCII letters, digits, '-', '_' or '.', starting with a letter or digit",
+        max_length = crate::configs::connectors::ConnectorKey::MAX_LENGTH
+    )]
+    InvalidConnectorKey(String),
     #[error("Failed to serialize topic metadata")]
     FailedToSerializeTopicMetadata,
     #[error("Failed to serialize messages metadata")]
@@ -79,6 +84,7 @@ impl RuntimeError {
             RuntimeError::SourceConfigNotFound(_, _) => 
"source_config_not_found",
             RuntimeError::MissingIggyCredentials => "invalid_configuration",
             RuntimeError::InvalidConfiguration(_) => "invalid_configuration",
+            RuntimeError::InvalidConnectorKey(_) => "invalid_connector_key",
             RuntimeError::HttpRequestFailed(_) => "http_request_failed",
             RuntimeError::StateLoadFailed { .. } => "state_load_failed",
             RuntimeError::TokenFileNotFound(_) => "invalid_configuration",
diff --git a/core/connectors/runtime/src/main.rs 
b/core/connectors/runtime/src/main.rs
index 08311d7d1..fcf8cafa9 100644
--- a/core/connectors/runtime/src/main.rs
+++ b/core/connectors/runtime/src/main.rs
@@ -15,7 +15,9 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crate::configs::connectors::{ConnectorsConfigProvider, 
create_connectors_config_provider};
+use crate::configs::connectors::{
+    ConnectorKey, ConnectorsConfig, ConnectorsConfigProvider, 
create_connectors_config_provider,
+};
 use ::configs::ConfigProvider;
 use clap::Parser;
 use configs::connectors::ConfigFormat;
@@ -40,7 +42,7 @@ use std::{
     sync::{Arc, atomic::AtomicU32},
 };
 use system_stats::capture_allowed_cpus;
-use tracing::{error, info};
+use tracing::{error, info, warn};
 
 mod api;
 mod benchmark;
@@ -155,6 +157,7 @@ async fn main() -> Result<(), RuntimeError> {
         connectors_config.sources().len(),
         connectors_config.sinks().len()
     );
+    warn_on_unaddressable_keys(&connectors_config);
     let sources_config = connectors_config.sources();
     let (sources, failed_sources) = source::init(
         sources_config.clone(),
@@ -306,6 +309,29 @@ async fn main() -> Result<(), RuntimeError> {
     Ok(())
 }
 
+/// Keys loaded from a provider are not held to `ConnectorKey`, so existing
+/// deployments keep starting, but the control API only routes keys that pass
+/// it. Say so at startup instead of letting the operator discover a 400.
+fn warn_on_unaddressable_keys(connectors_config: &ConnectorsConfig) {
+    let keys = connectors_config
+        .sinks()
+        .keys()
+        .map(|key| ("sink", key))
+        .chain(
+            connectors_config
+                .sources()
+                .keys()
+                .map(|key| ("source", key)),
+        );
+    for (connector_type, key) in keys {
+        if let Err(error) = key.parse::<ConnectorKey>() {
+            warn!(
+                "Loaded {connector_type} connector with key {key:?} that the 
control API cannot address: {error}"
+            );
+        }
+    }
+}
+
 /// Resolves a plugin shared library path from the connector config `path` 
field.
 ///
 /// Accepts both `plugin.so` and `plugin` (OS-specific extension appended if 
missing).
diff --git a/core/integration/tests/connectors/api/endpoints.rs 
b/core/integration/tests/connectors/api/endpoints.rs
index 97ebd72e4..d1d8c75ab 100644
--- a/core/integration/tests/connectors/api/endpoints.rs
+++ b/core/integration/tests/connectors/api/endpoints.rs
@@ -21,8 +21,15 @@ use iggy_connector_sdk::api::{
 use integration::harness::seeds;
 use integration::iggy_harness;
 use reqwest::Client;
+use serde_json::{Value, json};
+use std::fs;
 
 const API_KEY: &str = "test-api-key";
+/// `config_dir` of `key_validation.toml`, relative to the crate root, which is
+/// the working directory of both the test process and the spawned runtime. It
+/// lives under the gitignored `test_logs/` so a regression that writes a 
config
+/// file cannot land in the source tree or be loaded by another test.
+const CONNECTORS_CONFIG_DIR: &str = 
"../../test_logs/connectors_api_key_validation";
 
 #[iggy_harness(
     server(connectors_runtime(config_path = 
"tests/connectors/api/config.toml")),
@@ -243,3 +250,100 @@ async fn 
api_key_authentication_rejected_with_invalid_key(harness: &TestHarness)
 
     assert_eq!(response.status(), 401);
 }
+
+#[iggy_harness(
+    server(connectors_runtime(config_path = 
"tests/connectors/api/key_validation.toml")),
+    seed = seeds::connector_stream
+)]
+async fn key_endpoints_reject_key_that_is_not_a_single_path_component(harness: 
&TestHarness) {
+    let api_address = harness
+        .connectors_runtime()
+        .expect("connector runtime should be available")
+        .http_url();
+    let client = Client::new();
+    let config = json!({
+        "enabled": false,
+        "name": "x",
+        "path": "/tmp/evil.so",
+        "streams": []
+    });
+    let config_dir_before = config_dir_entries();
+
+    // Each entry is a percent-encoded path segment: axum decodes it before
+    // handing it to the handler, so `..%2F..%2Fpwned` arrives as 
`../../pwned`.
+    // The charset itself is covered by unit tests; these are the two probes
+    // from the issue report plus a hidden-file name.
+    let keys = ["..%2F..%2Fpwned", "x%2F..%2F..%2F..%2Ftmp%2Fpwn", ".hidden"];
+    for key in keys {
+        for kind in ["sources", "sinks"] {
+            let response = client
+                .post(format!("{api_address}/{kind}/{key}/configs"))
+                .header("api-key", API_KEY)
+                .json(&config)
+                .send()
+                .await
+                .unwrap();
+            assert_eq!(response.status(), 400, "POST /{kind}/{key}/configs");
+            let body: Value = response.json().await.unwrap();
+            assert_eq!(
+                body["code"], "invalid_connector_key",
+                "POST /{kind}/{key}/configs body: {body}"
+            );
+
+            let response = client
+                .get(format!("{api_address}/{kind}/{key}"))
+                .header("api-key", API_KEY)
+                .send()
+                .await
+                .unwrap();
+            assert_eq!(response.status(), 400, "GET /{kind}/{key}");
+            let body: Value = response.json().await.unwrap();
+            assert_eq!(
+                body["code"], "invalid_connector_key",
+                "GET /{kind}/{key} body: {body}"
+            );
+        }
+    }
+
+    assert_eq!(
+        config_dir_entries(),
+        config_dir_before,
+        "no config file should have been written"
+    );
+}
+
+#[iggy_harness(
+    server(connectors_runtime(config_path = 
"tests/connectors/api/config.toml")),
+    seed = seeds::connector_stream
+)]
+async fn key_endpoints_accept_single_component_key(harness: &TestHarness) {
+    let api_address = harness
+        .connectors_runtime()
+        .expect("connector runtime should be available")
+        .http_url();
+    let client = Client::new();
+
+    for (kind, code) in [("sources", "source_not_found"), ("sinks", 
"sink_not_found")] {
+        let response = client
+            .get(format!("{api_address}/{kind}/postgres-cdc.v2_1"))
+            .header("api-key", API_KEY)
+            .send()
+            .await
+            .unwrap();
+        assert_eq!(response.status(), 404, "GET /{kind}/postgres-cdc.v2_1");
+        let body: Value = response.json().await.unwrap();
+        assert_eq!(
+            body["code"], code,
+            "GET /{kind}/postgres-cdc.v2_1 body: {body}"
+        );
+    }
+}
+
+fn config_dir_entries() -> Vec<String> {
+    let mut entries: Vec<String> = fs::read_dir(CONNECTORS_CONFIG_DIR)
+        .unwrap()
+        .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
+        .collect();
+    entries.sort();
+    entries
+}
diff --git a/core/integration/tests/connectors/api/key_validation.toml 
b/core/integration/tests/connectors/api/key_validation.toml
new file mode 100644
index 000000000..72c2fa69a
--- /dev/null
+++ b/core/integration/tests/connectors/api/key_validation.toml
@@ -0,0 +1,31 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[http]
+enabled = true
+address = "0.0.0.0:0"
+api_key = "test-api-key"
+
+[http.metrics]
+enabled = true
+endpoint = "/metrics"
+
+[connectors]
+config_type = "local"
+# Gitignored: the key-validation test snapshots this directory and must
+# never leave a config file in the source tree.
+config_dir = "../../test_logs/connectors_api_key_validation"

Reply via email to