Skip to main content

vector/sinks/http/
config.rs

1//! Configuration for the `http` sink.
2
3use std::{collections::BTreeMap, path::PathBuf};
4
5#[cfg(feature = "aws-core")]
6use aws_config::meta::region::ProvideRegion;
7#[cfg(feature = "aws-core")]
8use aws_types::region::Region;
9use http::{HeaderName, HeaderValue, Method, Request, StatusCode, header::AUTHORIZATION};
10use hyper::Body;
11use vector_lib::codecs::{
12    CharacterDelimitedEncoder,
13    encoding::{Framer, Serializer},
14};
15#[cfg(feature = "aws-core")]
16use vector_lib::config::proxy::ProxyConfig;
17
18use super::{
19    encoder::HttpEncoder, request_builder::HttpRequestBuilder, service::HttpSinkRequestBuilder,
20    sink::HttpSink,
21};
22#[cfg(feature = "aws-core")]
23use crate::aws::AwsAuthentication;
24#[cfg(feature = "aws-core")]
25use crate::sinks::util::http::SigV4Config;
26use crate::{
27    codecs::{EncodingConfigWithFraming, SinkType},
28    http::{Auth, HttpClient, MaybeAuth},
29    sinks::{
30        prelude::*,
31        util::{
32            RealtimeSizeBasedDefaultBatchSettings, UriSerde,
33            http::{
34                HttpService, OrderedHeaderName, RequestConfig, RetryStrategy,
35                http_response_retry_logic,
36            },
37        },
38    },
39    template::ConfinementConfig,
40};
41
42const CONTENT_TYPE_TEXT: &str = "text/plain";
43const CONTENT_TYPE_NDJSON: &str = "application/x-ndjson";
44const CONTENT_TYPE_JSON: &str = "application/json";
45
46/// Configuration for the `http` sink.
47#[configurable_component(sink("http", "Deliver observability event data to an HTTP server."))]
48#[derive(Clone, Debug)]
49#[serde(deny_unknown_fields)]
50pub struct HttpSinkConfig {
51    /// The full URI to make HTTP requests to.
52    ///
53    /// This should include the protocol and host, but can also include the port, path, and any other valid part of a URI.
54    #[configurable(metadata(docs::examples = "https://10.22.212.22:9000/endpoint"))]
55    pub uri: Template,
56
57    /// The HTTP method to use when making the request.
58    #[serde(default)]
59    pub method: HttpMethod,
60
61    #[configurable(derived)]
62    pub auth: Option<Auth>,
63
64    #[configurable(derived)]
65    #[serde(default)]
66    pub compression: Compression,
67
68    #[serde(flatten)]
69    pub encoding: EncodingConfigWithFraming,
70
71    /// A string to prefix the payload with.
72    ///
73    /// This option is ignored if the encoding is not character delimited JSON.
74    ///
75    /// If specified, the `payload_suffix` must also be specified and together they must produce a valid JSON object.
76    #[configurable(metadata(docs::examples = "{\"data\":"))]
77    #[serde(default)]
78    pub payload_prefix: String,
79
80    /// A string to suffix the payload with.
81    ///
82    /// This option is ignored if the encoding is not character delimited JSON.
83    ///
84    /// If specified, the `payload_prefix` must also be specified and together they must produce a valid JSON object.
85    #[configurable(metadata(docs::examples = "}"))]
86    #[serde(default)]
87    pub payload_suffix: String,
88
89    #[configurable(derived)]
90    #[serde(default)]
91    pub batch: BatchConfig<RealtimeSizeBasedDefaultBatchSettings>,
92
93    #[configurable(derived)]
94    #[serde(default)]
95    pub request: RequestConfig,
96
97    #[configurable(derived)]
98    pub tls: Option<TlsConfig>,
99
100    #[configurable(derived)]
101    #[serde(
102        default,
103        deserialize_with = "crate::serde::bool_or_struct",
104        skip_serializing_if = "crate::serde::is_default"
105    )]
106    pub acknowledgements: AcknowledgementsConfig,
107
108    #[configurable(derived)]
109    #[serde(default)]
110    pub retry_strategy: RetryStrategy,
111
112    #[serde(flatten)]
113    pub confinement: ConfinementConfig,
114}
115
116/// HTTP method.
117///
118/// A subset of the HTTP methods described in [RFC 9110, section 9.1][rfc9110] are supported.
119///
120/// [rfc9110]: https://datatracker.ietf.org/doc/html/rfc9110#section-9.1
121#[configurable_component]
122#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
123#[serde(rename_all = "snake_case")]
124pub enum HttpMethod {
125    /// GET.
126    Get,
127
128    /// HEAD.
129    Head,
130
131    /// POST.
132    #[default]
133    Post,
134
135    /// PUT.
136    Put,
137
138    /// DELETE.
139    Delete,
140
141    /// OPTIONS.
142    Options,
143
144    /// TRACE.
145    Trace,
146
147    /// PATCH.
148    Patch,
149}
150
151impl From<HttpMethod> for Method {
152    fn from(http_method: HttpMethod) -> Self {
153        match http_method {
154            HttpMethod::Head => Self::HEAD,
155            HttpMethod::Get => Self::GET,
156            HttpMethod::Post => Self::POST,
157            HttpMethod::Put => Self::PUT,
158            HttpMethod::Patch => Self::PATCH,
159            HttpMethod::Delete => Self::DELETE,
160            HttpMethod::Options => Self::OPTIONS,
161            HttpMethod::Trace => Self::TRACE,
162        }
163    }
164}
165
166impl HttpSinkConfig {
167    fn build_http_client(&self, cx: &SinkContext) -> crate::Result<HttpClient> {
168        let tls = TlsSettings::from_options(self.tls.as_ref())?;
169        Ok(HttpClient::new(tls, cx.proxy())?)
170    }
171
172    pub(super) fn build_encoder(&self) -> crate::Result<Encoder<Framer>> {
173        let (framer, serializer) = self.encoding.build(SinkType::MessageBased)?;
174        Ok(Encoder::<Framer>::new(framer, serializer))
175    }
176}
177
178impl GenerateConfig for HttpSinkConfig {
179    fn generate_config() -> toml::Value {
180        toml::from_str(
181            r#"uri = "https://10.22.212.22:9000/endpoint"
182            encoding.codec = "json""#,
183        )
184        .unwrap()
185    }
186}
187
188async fn healthcheck(uri: UriSerde, auth: Option<Auth>, client: HttpClient) -> crate::Result<()> {
189    let auth = auth.choose_one(&uri.auth)?;
190    let uri = uri.with_default_parts();
191    let mut request = Request::head(&uri.uri).body(Body::empty()).unwrap();
192
193    if let Some(auth) = auth {
194        auth.apply(&mut request);
195    }
196
197    let response = client.send(request).await?;
198
199    match response.status() {
200        StatusCode::OK => Ok(()),
201        status => Err(HealthcheckError::UnexpectedStatus { status }.into()),
202    }
203}
204
205pub(super) fn validate_headers(
206    headers: &BTreeMap<String, String>,
207    configures_auth: bool,
208) -> crate::Result<BTreeMap<OrderedHeaderName, HeaderValue>> {
209    let headers = crate::sinks::util::http::validate_headers(headers)?;
210
211    for name in headers.keys() {
212        if configures_auth && name.inner() == AUTHORIZATION {
213            return Err("Authorization header can not be used with defined auth options".into());
214        }
215    }
216
217    Ok(headers)
218}
219
220pub(super) fn validate_payload_wrapper(
221    payload_prefix: &str,
222    payload_suffix: &str,
223    encoder: &Encoder<Framer>,
224) -> crate::Result<(String, String)> {
225    let payload = [payload_prefix, "{}", payload_suffix].join("");
226    match (
227        encoder.serializer(),
228        encoder.framer(),
229        serde_json::from_str::<serde_json::Value>(&payload),
230    ) {
231        (
232            Serializer::Json(_),
233            Framer::CharacterDelimited(CharacterDelimitedEncoder { delimiter: b',' }),
234            Err(_),
235        ) => Err("Payload prefix and suffix wrapper must produce a valid JSON object.".into()),
236        _ => Ok((payload_prefix.to_owned(), payload_suffix.to_owned())),
237    }
238}
239
240#[async_trait]
241#[typetag::serde(name = "http")]
242impl SinkConfig for HttpSinkConfig {
243    async fn build(&self, cx: SinkContext) -> crate::Result<(VectorSink, Healthcheck)> {
244        let result = self.build_without_confinement_gauge(cx, Self::NAME).await?;
245        self.confinement.set_confinement_gauge("sink", Self::NAME);
246        Ok(result)
247    }
248
249    fn input(&self) -> Input {
250        Input::new(self.encoding.config().1.input_type())
251    }
252
253    fn files_to_watch(&self) -> Vec<&PathBuf> {
254        let mut files = Vec::new();
255        if let Some(tls) = &self.tls {
256            if let Some(crt_file) = &tls.crt_file {
257                files.push(crt_file)
258            }
259            if let Some(key_file) = &tls.key_file {
260                files.push(key_file)
261            }
262        };
263        files
264    }
265
266    fn acknowledgements(&self) -> &AcknowledgementsConfig {
267        &self.acknowledgements
268    }
269}
270
271impl HttpSinkConfig {
272    /// Confinement + sink construction without emitting the per-sink
273    /// confinement gauge. `component_name` is threaded through so both the
274    /// gauge (emitted by the caller) and per-template security warnings
275    /// carry the outer sink type — `http` when this is the top-level sink,
276    /// `opentelemetry` when [`OpenTelemetryConfig::build`] delegates here.
277    pub(crate) async fn build_without_confinement_gauge(
278        &self,
279        cx: SinkContext,
280        component_name: &'static str,
281    ) -> crate::Result<(VectorSink, Healthcheck)> {
282        let batch_settings = self.batch.validate()?.into_batcher_settings()?;
283
284        let encoder = self.build_encoder()?;
285        let transformer = self.encoding.transformer();
286
287        let request = self.request.clone();
288
289        validate_headers(&request.headers, self.auth.is_some())?;
290        let (static_headers, template_headers) = request.split_headers();
291
292        let (payload_prefix, payload_suffix) =
293            validate_payload_wrapper(&self.payload_prefix, &self.payload_suffix, &encoder)?;
294
295        let client = self.build_http_client(&cx)?;
296
297        let healthcheck = match cx.healthcheck.uri {
298            Some(healthcheck_uri) => {
299                healthcheck(healthcheck_uri, self.auth.clone(), client.clone()).boxed()
300            }
301            None => future::ok(()).boxed(),
302        };
303
304        let content_type = {
305            use Framer::*;
306            use Serializer::*;
307            match (encoder.serializer(), encoder.framer()) {
308                (RawMessage(_) | Text(_), _) => Some(CONTENT_TYPE_TEXT.to_owned()),
309                (Json(_), NewlineDelimited(_)) => Some(CONTENT_TYPE_NDJSON.to_owned()),
310                (Json(_), CharacterDelimited(CharacterDelimitedEncoder { delimiter: b',' })) => {
311                    Some(CONTENT_TYPE_JSON.to_owned())
312                }
313                #[cfg(feature = "codecs-opentelemetry")]
314                (Otlp(_), _) => Some("application/x-protobuf".to_owned()),
315                _ => None,
316            }
317        };
318
319        let request_builder = HttpRequestBuilder {
320            encoder: HttpEncoder::new(encoder, transformer, payload_prefix, payload_suffix),
321            compression: self.compression,
322        };
323
324        let content_encoding = self.compression.is_compressed().then(|| {
325            self.compression
326                .content_encoding()
327                .expect("Encoding should be specified for compression.")
328                .to_string()
329        });
330
331        let converted_static_headers = static_headers
332            .into_iter()
333            .map(|(name, value)| -> crate::Result<_> {
334                let header_name =
335                    HeaderName::from_bytes(name.as_bytes()).map(OrderedHeaderName::from)?;
336                let header_value = HeaderValue::try_from(value)?;
337                Ok((header_name, header_value))
338            })
339            .collect::<Result<BTreeMap<_, _>, _>>()?;
340
341        let http_sink_request_builder = HttpSinkRequestBuilder::new(
342            self.method,
343            self.auth.clone(),
344            converted_static_headers,
345            content_type,
346            content_encoding,
347        );
348
349        let service = match &self.auth {
350            #[cfg(feature = "aws-core")]
351            Some(Auth::Aws { auth, service }) => {
352                let default_region = crate::aws::region_provider(&ProxyConfig::default(), None)?
353                    .region()
354                    .await;
355                let region = (match &auth {
356                    AwsAuthentication::AccessKey { region, .. } => region.clone(),
357                    AwsAuthentication::File { .. } => None,
358                    AwsAuthentication::Role { region, .. } => region.clone(),
359                    AwsAuthentication::Default { region, .. } => region.clone(),
360                })
361                .map_or(default_region, |r| Some(Region::new(r.to_string())))
362                .expect("Region must be specified");
363
364                HttpService::new_with_sig_v4(
365                    client,
366                    http_sink_request_builder,
367                    SigV4Config {
368                        shared_credentials_provider: auth
369                            .credentials_provider(region.clone(), &ProxyConfig::default(), None)
370                            .await?,
371                        region: region.clone(),
372                        service: service.clone(),
373                    },
374                )
375            }
376            _ => HttpService::new(client, http_sink_request_builder),
377        };
378
379        let request_limits = self.request.tower.into_settings();
380
381        let service = ServiceBuilder::new()
382            .settings(
383                request_limits,
384                http_response_retry_logic(self.retry_strategy.clone()),
385            )
386            .service(service);
387
388        let uri = self
389            .uri
390            .clone()
391            .confine(&self.confinement, component_name, "uri")?;
392
393        // Confine every templated header value. Header-based routing
394        // (e.g. `X-Scope-OrgID: "{{ tenant }}"`) is as steerable as URI
395        // routing — an event that controls the header field picks the
396        // destination tenant unless we confine the header template too.
397        let template_headers = template_headers
398            .into_iter()
399            .map(|(name, tpl)| {
400                tpl.confine(&self.confinement, component_name, "request.headers")
401                    .map(|tpl| (name, tpl))
402            })
403            .collect::<crate::Result<BTreeMap<_, _>>>()?;
404
405        let sink = HttpSink::new(
406            service,
407            uri,
408            template_headers,
409            batch_settings,
410            request_builder,
411        );
412
413        Ok((VectorSink::from_event_streamsink(sink), healthcheck))
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use vector_lib::codecs::encoding::format::JsonSerializerOptions;
420
421    use super::*;
422    use crate::components::validation::prelude::*;
423    use crate::template::{ConfinementConfig, Template};
424
425    impl ValidatableComponent for HttpSinkConfig {
426        fn validation_configuration() -> ValidationConfiguration {
427            use std::str::FromStr;
428
429            use vector_lib::{
430                codecs::{JsonSerializerConfig, MetricTagValues},
431                config::LogNamespace,
432            };
433
434            let endpoint = "http://127.0.0.1:9000/endpoint";
435            let uri = UriSerde::from_str(endpoint).expect("should never fail to parse");
436
437            let config = HttpSinkConfig {
438                uri: Template::try_from(endpoint).expect("should never fail to parse"),
439                method: HttpMethod::Post,
440                encoding: EncodingConfigWithFraming::new(
441                    None,
442                    JsonSerializerConfig::new(
443                        MetricTagValues::Full,
444                        JsonSerializerOptions::default(),
445                    )
446                    .into(),
447                    Transformer::default(),
448                ),
449                auth: None,
450                compression: Compression::default(),
451                batch: BatchConfig::default(),
452                request: RequestConfig::default(),
453                tls: None,
454                acknowledgements: AcknowledgementsConfig::default(),
455                payload_prefix: String::new(),
456                payload_suffix: String::new(),
457                retry_strategy: RetryStrategy::default(),
458                confinement: ConfinementConfig::default(),
459            };
460
461            let external_resource = ExternalResource::new(
462                ResourceDirection::Push,
463                HttpResourceConfig::from_parts(uri.uri, Some(config.method.into())),
464                config.encoding.clone(),
465            );
466
467            ValidationConfiguration::from_sink(
468                Self::NAME,
469                LogNamespace::Legacy,
470                vec![ComponentTestCaseConfig::from_sink(
471                    config,
472                    None,
473                    Some(external_resource),
474                )],
475            )
476        }
477    }
478
479    register_validatable_component!(HttpSinkConfig);
480
481    #[test]
482    fn confinement_rejects_unconfined_uri() {
483        let template: Template = "{{ endpoint }}".try_into().unwrap();
484        let err = template
485            .confine(&ConfinementConfig::default(), "http", "uri")
486            .unwrap_err();
487        assert!(
488            err.to_string().contains("no literal string prefix"),
489            "unexpected error: {err}"
490        );
491    }
492
493    #[test]
494    fn confinement_opt_out_allows_unconfined_uri() {
495        let cfg = ConfinementConfig {
496            dangerously_allow_unconfined_template_resolution: true,
497        };
498        let template: Template = "{{ endpoint }}".try_into().unwrap();
499        assert!(template.confine(&cfg, "http", "uri").is_ok());
500    }
501
502    #[test]
503    fn confinement_blocks_host_redirect_at_render() {
504        use crate::event::Event;
505        use vector_lib::event::LogEvent;
506        use vrl::event_path;
507
508        let template: Template = "https://logs.example.com/ingest/{{ tenant }}"
509            .try_into()
510            .unwrap();
511        let template = template
512            .confine(&ConfinementConfig::default(), "http", "uri")
513            .unwrap();
514
515        // Attacker tries to redirect to a different host via the tenant field.
516        let mut event = Event::Log(LogEvent::from("x"));
517        event
518            .as_mut_log()
519            .insert(event_path!("tenant"), "../../evil.com/steal?data=");
520        assert!(template.render_string(&event).is_err());
521    }
522}