Skip to content

Commit 8832452

Browse files
authored
fix(sinks): confinement follow-ups from #25820 codex review (#25830)
1 parent cc72a8a commit 8832452

66 files changed

Lines changed: 1368 additions & 428 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,33 @@
1-
Sinks that use `{{ field }}` references in routing templates now require a
2-
literal prefix in the template so Vector can enforce a routing boundary. For
3-
example, `key_prefix: "{{ host }}/"` is no longer accepted at startup because
4-
there is no fixed leading segment.
5-
6-
The `file` sink additionally gains a `base_dir` config field to set the
7-
confinement root explicitly when the `path` template has no usable literal
8-
prefix.
1+
Sinks that accept `{{ field }}` references in routing templates now enforce a
2+
confinement boundary: the rendered value must stay within the literal prefix
3+
declared in the template. Templates with no literal prefix (e.g.
4+
`key_prefix: "{{ host }}/"`) are rejected at startup.
95

106
Affected sinks: `aws_s3`, `azure_blob`, `gcp_cloud_storage`, `webhdfs`,
117
`file`, `elasticsearch`, `kafka`, `http`, `splunk_hec_logs`,
12-
`splunk_hec_metrics`, `loki`, `clickhouse`, `redis`, `amqp`, `pulsar`,
13-
`mqtt`, `nats`, `greptimedb_logs`, `aws_cloudwatch_logs`,
14-
`gcp_stackdriver_logs`, `prometheus_remote_write`.
8+
`splunk_hec_metrics`, `humio_logs`, `humio_metrics`, `loki`, `clickhouse`,
9+
`redis`, `amqp`, `pulsar`, `mqtt`, `nats`, `greptimedb_logs`,
10+
`aws_cloudwatch_logs`, `gcp_stackdriver_logs`, `prometheus_remote_write`.
11+
12+
The `file` sink gains a `base_dir` config field to set the confinement root
13+
explicitly when the `path` template has no usable literal prefix.
14+
15+
**URI templates:** HTTP/HTTPS URI templates that use `{{ field }}` references
16+
must not contain `?`. A field-rendered value could smuggle additional query
17+
parameters into the request. Fully static URI templates (no `{{ }}`) with a
18+
query string are still accepted. Dynamic query segments (e.g.
19+
`https://api.internal/ingest?tenant={{ tenant }}`) are rejected at startup.
20+
21+
**Opt-out:** set `dangerously_allow_unconfined_template_resolution: true` on
22+
the affected sink to disable all confinement checks for that sink — both at
23+
startup and at runtime. Vector logs a warning per template on startup and sets
24+
`vector_security_confinement_disabled{component_type=...}` to `1`.
1525

16-
**To migrate:** please add a fixed prefix to the template, e.g. `key_prefix: "logs-{{ host }}/"`.
26+
**Observability:**
1727

18-
**To restore previous behavior:** set `dangerously_allow_unconfined_template_resolution: true` on the affected sink.
28+
- `component_errors_total{error_type="confinement_failed"}` — increments on
29+
each violation; events that trigger it are dropped.
30+
- `vector_security_confinement_disabled` — set to `1` while a sink is running
31+
with confinement disabled.
1932

2033
authors: pront

‎changelog.d/template_confinement.security.md‎

Lines changed: 0 additions & 14 deletions
This file was deleted.

‎lib/vector-common/src/internal_event/prelude.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ pub mod error_type {
1515
// When a condition for the event to be valid failed.
1616
// This is used for example when a field is missing or should be a string.
1717
pub const CONDITION_FAILED: &str = "condition_failed";
18+
// When a templated routing value fell outside its configured confinement boundary.
19+
pub const CONFINEMENT_FAILED: &str = "confinement_failed";
1820
// When the component received a request with missing or invalid authentication credentials.
1921
pub const AUTHENTICATION_FAILED: &str = "authentication_failed";
2022
// When the component or the service on which it depends is not configured properly.

‎src/internal_events/common.rs‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,28 @@ use vector_lib::{counter, histogram};
1010

1111
#[derive(Debug, NamedInternalEvent)]
1212
pub struct KeyOutsideBasePrefixError<'a> {
13-
pub key: &'a str,
13+
/// Bounded preview of the rejected key — never the full rendered value,
14+
/// so attacker-controlled input can't amplify into logs and secrets in
15+
/// templated header/query fields don't leak.
16+
pub key_preview: &'a str,
17+
/// Full byte length of the rejected rendered value.
18+
pub key_len: usize,
1419
pub message: &'a str,
1520
}
1621

1722
impl InternalEvent for KeyOutsideBasePrefixError<'_> {
1823
fn emit(self) {
1924
error!(
2025
message = "Rendered key is outside the configured base prefix; dropping event.",
21-
key = self.key,
26+
key_preview = self.key_preview,
27+
key_len = self.key_len,
2228
error = self.message,
23-
error_type = error_type::CONDITION_FAILED,
29+
error_type = error_type::CONFINEMENT_FAILED,
2430
stage = error_stage::PROCESSING,
2531
);
2632
counter!(
2733
CounterName::ComponentErrorsTotal,
28-
"error_type" => error_type::CONDITION_FAILED,
34+
"error_type" => error_type::CONFINEMENT_FAILED,
2935
"stage" => error_stage::PROCESSING,
3036
)
3137
.increment(1);

‎src/internal_events/file.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,12 @@ impl InternalEvent for FilePathOutsideBaseDirError<'_> {
122122
path = ?self.path,
123123
base_dir = ?self.base_dir,
124124
error = %self.error,
125-
error_type = error_type::CONDITION_FAILED,
125+
error_type = error_type::CONFINEMENT_FAILED,
126126
stage = error_stage::PROCESSING,
127127
);
128128
counter!(
129129
CounterName::ComponentErrorsTotal,
130-
"error_type" => error_type::CONDITION_FAILED,
130+
"error_type" => error_type::CONFINEMENT_FAILED,
131131
"stage" => error_stage::PROCESSING,
132132
)
133133
.increment(1);

‎src/internal_events/template.rs‎

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,35 +15,86 @@ pub struct TemplateRenderingError<'a> {
1515

1616
impl InternalEvent for TemplateRenderingError<'_> {
1717
fn emit(self) {
18-
let mut msg = "Failed to render template".to_owned();
18+
let confined = matches!(
19+
self.error,
20+
crate::template::TemplateRenderingError::Confined { .. }
21+
);
22+
23+
// Message wording tracks BOTH the error class (confinement vs render
24+
// failure) AND whether the caller is dropping the event. A caller
25+
// like the KeyPartitioner falling back to a dead-letter key emits
26+
// `Confined` + `drop_event: false` — we still want to log the
27+
// confinement violation, but claiming "dropping event" would be a
28+
// lie because the event still routes to the dead-letter.
29+
let mut msg = match (confined, self.drop_event) {
30+
(true, true) => {
31+
"Templated routing value was outside the configured confinement; dropping event"
32+
.to_owned()
33+
}
34+
(true, false) => {
35+
"Templated routing value was outside the configured confinement".to_owned()
36+
}
37+
(false, _) => "Failed to render template".to_owned(),
38+
};
1939
if let Some(field) = self.field {
2040
use std::fmt::Write;
2141
_ = write!(msg, " for \"{field}\"");
2242
}
2343
msg.push('.');
2444

25-
let confined = matches!(
26-
self.error,
27-
crate::template::TemplateRenderingError::Confined { .. }
28-
);
29-
30-
if self.drop_event {
45+
// A `Confined` error is always alert-worthy: an attacker attempted
46+
// to steer routing via event data. Some callers legitimately don't
47+
// drop the event (e.g. a partitioner falling back to a dead-letter
48+
// key), but the confinement fire itself must still surface in logs
49+
// and `component_errors_total` regardless of the caller's
50+
// drop_event decision.
51+
//
52+
// `check-events` requires `error_type` counter tag values to be
53+
// constants, so the two error-class branches are split
54+
// explicitly. Confined renders always surface at `error!` +
55+
// `component_errors_total` (they're security-relevant even when
56+
// the caller doesn't drop the event); non-confined renders only
57+
// surface on the drop path.
58+
if confined {
59+
error!(
60+
message = %msg,
61+
error = %self.error,
62+
error_type = error_type::CONFINEMENT_FAILED,
63+
stage = error_stage::PROCESSING,
64+
);
65+
counter!(
66+
CounterName::ComponentErrorsTotal,
67+
"error_type" => error_type::CONFINEMENT_FAILED,
68+
"stage" => error_stage::PROCESSING,
69+
)
70+
.increment(1);
71+
} else if self.drop_event {
3172
error!(
3273
message = %msg,
3374
error = %self.error,
3475
error_type = error_type::TEMPLATE_FAILED,
3576
stage = error_stage::PROCESSING,
3677
);
37-
3878
counter!(
3979
CounterName::ComponentErrorsTotal,
4080
"error_type" => error_type::TEMPLATE_FAILED,
4181
"stage" => error_stage::PROCESSING,
4282
)
4383
.increment(1);
84+
} else {
85+
warn!(
86+
message = %msg,
87+
error = %self.error,
88+
error_type = error_type::TEMPLATE_FAILED,
89+
stage = error_stage::PROCESSING,
90+
);
91+
}
4492

45-
// Confinement violations are intentional security discards.
46-
// All other template render failures are unintentional drops.
93+
// Only emit `ComponentEventsDropped` when the caller actually
94+
// dropped the event. `Confined` + `drop_event: false` (e.g. the
95+
// dead-letter fallback) doesn't count as a drop — the event still
96+
// lands somewhere, just at the operator-authored fallback key.
97+
if self.drop_event {
4798
if confined {
4899
emit!(ComponentEventsDropped::<INTENTIONAL> {
49100
count: 1,
@@ -55,13 +106,6 @@ impl InternalEvent for TemplateRenderingError<'_> {
55106
reason: "Failed to render template.",
56107
});
57108
}
58-
} else {
59-
warn!(
60-
message = %msg,
61-
error = %self.error,
62-
error_type = error_type::TEMPLATE_FAILED,
63-
stage = error_stage::PROCESSING,
64-
);
65109
}
66110
}
67111
}

‎src/sinks/amqp/config.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ impl SinkConfig for AmqpSinkConfig {
145145
.transpose()?;
146146
let sink = AmqpSink::new(config).await?;
147147
let hc = healthcheck(sink.channels.clone()).boxed();
148+
self.confinement.set_confinement_gauge("sink", Self::NAME);
148149
Ok((VectorSink::from_event_streamsink(sink), hc))
149150
}
150151

‎src/sinks/aws_cloudwatch_logs/config.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ impl SinkConfig for CloudwatchLogsSinkConfig {
241241
service: svc,
242242
};
243243

244+
self.confinement.set_confinement_gauge("sink", Self::NAME);
244245
Ok((VectorSink::from_event_streamsink(sink), healthcheck))
245246
}
246247

‎src/sinks/aws_s3/config.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ impl SinkConfig for S3SinkConfig {
232232
let service = self.create_service(&cx.proxy).await?;
233233
let healthcheck = self.build_healthcheck(service.client())?;
234234
let sink = self.build_processor(service, cx)?;
235+
self.confinement.set_confinement_gauge("sink", Self::NAME);
235236
Ok((sink, healthcheck))
236237
}
237238

‎src/sinks/axiom/config.rs‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use crate::{
1919
http::{RequestConfig, RetryStrategy},
2020
},
2121
},
22+
template::ConfinementConfig,
2223
tls::TlsConfig,
2324
};
2425

@@ -129,6 +130,9 @@ pub struct AxiomConfig {
129130
#[configurable(derived)]
130131
#[serde(default)]
131132
pub retry_strategy: RetryStrategy,
133+
134+
#[serde(flatten)]
135+
pub confinement: ConfinementConfig,
132136
}
133137

134138
impl GenerateConfig for AxiomConfig {
@@ -186,10 +190,17 @@ impl SinkConfig for AxiomConfig {
186190
payload_prefix: "".into(), // Always newline delimited JSON
187191
payload_suffix: "".into(), // Always newline delimited JSON
188192
retry_strategy: self.retry_strategy.clone(),
189-
confinement: Default::default(),
193+
confinement: self.confinement.clone(),
190194
};
191195

192-
http_sink_config.build(cx).await
196+
// Route through the HTTP builder that doesn't emit the gauge, so
197+
// per-template warnings and the sink-level gauge both carry
198+
// `component_type=axiom` — not `http`.
199+
let result = http_sink_config
200+
.build_without_confinement_gauge(cx, Self::NAME)
201+
.await?;
202+
self.confinement.set_confinement_gauge("sink", Self::NAME);
203+
Ok(result)
193204
}
194205

195206
fn input(&self) -> Input {

0 commit comments

Comments
 (0)