From 9ac93cbbc3a0b8ad5e136c1b3bbe2eda1df24a16 Mon Sep 17 00:00:00 2001 From: shuiyisong <113876041+shuiyisong@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:26:29 +0800 Subject: [PATCH] fix!: backport #9617 to `release/v1.3` (#9619) fix(pipeline)!: disable VRL host access by default (#9610) fix(pipeline): disable VRL host access by default Signed-off-by: shuiyisong --- Cargo.lock | 2 + config/config.md | 2 + config/frontend.example.toml | 4 + config/standalone.example.toml | 4 + src/frontend/src/frontend.rs | 12 + src/frontend/src/instance/log_handler.rs | 5 +- src/pipeline/Cargo.toml | 2 + src/pipeline/src/etl.rs | 11 +- src/pipeline/src/etl/README.md | 21 ++ src/pipeline/src/etl/processor.rs | 17 +- .../src/etl/processor/vrl_processor.rs | 266 +++++++++++++++++- src/pipeline/src/lib.rs | 2 +- src/pipeline/src/manager/pipeline_operator.rs | 12 +- src/pipeline/src/manager/table.rs | 18 +- src/pipeline/src/options.rs | 5 + src/standalone/src/options.rs | 13 + tests-integration/Cargo.toml | 2 +- tests-integration/src/standalone.rs | 10 + tests-integration/tests/http.rs | 68 +++++ 19 files changed, 434 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cbd87363918..5bb44bf45b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10933,6 +10933,8 @@ dependencies = [ "snafu 0.8.6", "sql", "table", + "temp-env", + "tempfile", "tokio", "urlencoding", "vrl", diff --git a/config/config.md b/config/config.md index dbd72b5fb1c..4e8b844da53 100644 --- a/config/config.md +++ b/config/config.md @@ -266,6 +266,7 @@ | `tracing.tokio_console_addr` | String | Unset | The tokio console address. | | `pipeline` | -- | -- | The pipeline options. | | `pipeline.cache_ttl` | String | `10s` | Time to live of the local pipeline cache. Default is `10s`. | +| `pipeline.allow_vrl_host_access` | Bool | `false` | Allow VRL to access the server environment, filesystem, hostname, local timezone,
network, and logs. Default is false. Enable only for trusted pipeline authors.
Applies to pipeline creation, execution, and dry-runs. Requires a restart. | | `event_recorder` | -- | -- | Configuration options for the event recorder. | | `event_recorder.ttl` | String | `90d` | TTL for the events table that will be used to store the events. Default is `90d`. | | `event_recorder.event_types` | Array | All | Event types to record. Current available event types: `create_database`,
`alter_database`, `drop_database`, `create_flow`, `drop_flow`,
`create_table`, `create_logical_tables`, `alter_table`, `alter_logical_tables`,
`drop_table`, `undrop_table`, `purge_dropped_table`, `truncate_table`,
`create_view`, `drop_view`, `admin_function`, `reconcile_catalog`,
`reconcile_database`, `reconcile_table`, `reconcile_logical_tables`.
When omitted, all current and future event types are recorded.
Set to an empty array to disable event recording. | @@ -425,6 +426,7 @@ | `memory.enable_heap_profiling` | Bool | `true` | Whether to enable heap profiling activation during startup.
When enabled, heap profiling will be activated if the `MALLOC_CONF` environment variable
is set to "prof:true,prof_active:false". The official image adds this env variable.
Default is true. | | `pipeline` | -- | -- | The pipeline options. | | `pipeline.cache_ttl` | String | `10s` | Time to live of the frontend-local pipeline cache. A pipeline created or deleted on
another frontend takes effect on this one after at most this duration. | +| `pipeline.allow_vrl_host_access` | Bool | `false` | Allow VRL to access the server environment, filesystem, hostname, local timezone,
network, and logs. Default is false. Enable only for trusted pipeline authors.
Applies to pipeline creation, execution, and dry-runs. Requires a restart. | | `event_recorder` | -- | -- | Configuration options for the event recorder. | | `event_recorder.ttl` | String | `90d` | TTL for the events table that will be used to store the events. Default is `90d`. | | `event_recorder.event_types` | Array | All | Event types to record. Current available event type: `admin_function`.
When omitted, all current and future event types are recorded.
Set to an empty array to disable event recording. | diff --git a/config/frontend.example.toml b/config/frontend.example.toml index 0e7c0482fa3..68d25396b35 100644 --- a/config/frontend.example.toml +++ b/config/frontend.example.toml @@ -504,6 +504,10 @@ enable_heap_profiling = true ## Time to live of the frontend-local pipeline cache. A pipeline created or deleted on ## another frontend takes effect on this one after at most this duration. cache_ttl = "10s" +## Allow VRL to access the server environment, filesystem, hostname, local timezone, +## network, and logs. Default is false. Enable only for trusted pipeline authors. +## Applies to pipeline creation, execution, and dry-runs. Requires a restart. +allow_vrl_host_access = false ## Configuration options for the event recorder. [event_recorder] diff --git a/config/standalone.example.toml b/config/standalone.example.toml index cebbf8ac265..99c9e28e15d 100644 --- a/config/standalone.example.toml +++ b/config/standalone.example.toml @@ -1087,6 +1087,10 @@ ttl = "90d" [pipeline] ## Time to live of the local pipeline cache. Default is `10s`. cache_ttl = "10s" +## Allow VRL to access the server environment, filesystem, hostname, local timezone, +## network, and logs. Default is false. Enable only for trusted pipeline authors. +## Applies to pipeline creation, execution, and dry-runs. Requires a restart. +allow_vrl_host_access = false ## Configuration options for the event recorder. [event_recorder] diff --git a/src/frontend/src/frontend.rs b/src/frontend/src/frontend.rs index 4608f70c093..8ee10903710 100644 --- a/src/frontend/src/frontend.rs +++ b/src/frontend/src/frontend.rs @@ -297,6 +297,18 @@ backoff_multiplier = 3.0 ); } + #[test] + fn test_pipeline_host_access_config() { + let defaults: FrontendOptions = toml::from_str("").unwrap(); + assert!(!defaults.pipeline.allow_vrl_host_access); + let options: FrontendOptions = + toml::from_str("[pipeline]\nallow_vrl_host_access = true").unwrap(); + assert!(options.pipeline.allow_vrl_host_access); + let serialized = toml::to_string(&options).unwrap(); + let restored: FrontendOptions = toml::from_str(&serialized).unwrap(); + assert_eq!(options.pipeline, restored.pipeline); + } + #[test] fn test_logical_batcher_protocols_from_env() { temp_env::with_vars( diff --git a/src/frontend/src/instance/log_handler.rs b/src/frontend/src/instance/log_handler.rs index 44738b9dcc1..7b6a8fcb8ed 100644 --- a/src/frontend/src/instance/log_handler.rs +++ b/src/frontend/src/instance/log_handler.rs @@ -23,7 +23,6 @@ use auth::{ use client::Output; use common_error::ext::BoxedError; use datatypes::timestamp::TimestampNanosecond; -use pipeline::pipeline_operator::PipelineOperator; use pipeline::{Pipeline, PipelineInfo, PipelineVersion}; use servers::error::{ AuthSnafu, Error as ServerError, ExecuteGrpcRequestSnafu, PipelineSnafu, Result as ServerResult, @@ -148,7 +147,9 @@ impl PipelineHandler for Instance { } fn build_pipeline(&self, pipeline: &str) -> ServerResult { - PipelineOperator::build_pipeline(pipeline).context(PipelineSnafu) + self.pipeline_operator + .build_pipeline(pipeline) + .context(PipelineSnafu) } async fn get_pipeline_str( diff --git a/src/pipeline/Cargo.toml b/src/pipeline/Cargo.toml index 5a9cb3e9aa6..38a5a79996d 100644 --- a/src/pipeline/Cargo.toml +++ b/src/pipeline/Cargo.toml @@ -60,6 +60,8 @@ yaml-rust = "0.4" [dev-dependencies] catalog = { workspace = true, features = ["testing"] } criterion = { workspace = true, features = ["html_reports"] } +temp-env.workspace = true +tempfile.workspace = true [[bench]] name = "processor" diff --git a/src/pipeline/src/etl.rs b/src/pipeline/src/etl.rs index f78a515fb59..ba68fde79ee 100644 --- a/src/pipeline/src/etl.rs +++ b/src/pipeline/src/etl.rs @@ -42,8 +42,8 @@ use crate::etl::transform::transformer::greptime::{ }; use crate::tablesuffix::TableSuffixTemplate; use crate::{ - ContextOpt, GreptimeTransformer, IdentityTimeIndex, PipelineContext, SchemaInfo, - unwrap_or_continue_if_err, + ContextOpt, GreptimeTransformer, IdentityTimeIndex, PipelineContext, PipelineOptions, + SchemaInfo, unwrap_or_continue_if_err, }; const DESCRIPTION: &str = "description"; @@ -60,6 +60,11 @@ pub enum Content<'a> { } pub fn parse(input: &Content) -> Result { + parse_with_options(input, &PipelineOptions::default()) +} + +/// Parses a pipeline using the server's compilation policy. +pub fn parse_with_options(input: &Content, options: &PipelineOptions) -> Result { match input { Content::Yaml(str) => { let docs = YamlLoader::load_from_str(str).context(YamlLoadSnafu)?; @@ -73,7 +78,7 @@ pub fn parse(input: &Content) -> Result { let doc_version = (&doc[DOC_VERSION]).try_into()?; let processors = if let Some(v) = doc[PROCESSORS].as_vec() { - v.try_into()? + Processors::from_yaml(v, options.allow_vrl_host_access)? } else { Processors::default() }; diff --git a/src/pipeline/src/etl/README.md b/src/pipeline/src/etl/README.md index e512ea31f7b..020bcdc304d 100644 --- a/src/pipeline/src/etl/README.md +++ b/src/pipeline/src/etl/README.md @@ -2,6 +2,27 @@ ETL capability +## VRL host access + +VRL host access is disabled by default. The server configuration option +`[pipeline].allow_vrl_host_access = true` restores the full VRL standard library +for trusted pipeline authors. It applies to both stored pipelines and dry-runs, +requires a server restart, and cannot be overridden in a pipeline definition. + +With host access disabled, environment, hostname, system timezone, filesystem, +network, and logging functions are unavailable. This includes `get_env_var`, +`get_hostname`, `get_timezone_name`, `http_request`, `dns_lookup`, `reverse_dns`, +`parse_proto`, `encode_proto`, `parse_etld`, `validate_json_schema`, and `log`. +Grok parsing supports inline +aliases but rejects nonempty `alias_sources`. Timestamp parsing and formatting +use UTC by default; an explicit timezone must be a constant named timezone such +as `Asia/Shanghai`, rather than `local`, an empty string, or an event-derived +value. Ordinary time and random-value functions remain available. + +Existing pipelines using restricted functions or arguments fail compilation +under the default policy, including when loaded from storage. This option +restricts VRL capabilities; it does not impose CPU or memory limits. + ## processors refer [elastic ingest processor][elastic-ingest-processor] for detail diff --git a/src/pipeline/src/etl/processor.rs b/src/pipeline/src/etl/processor.rs index 761bcd6cba4..8c999cd13ee 100644 --- a/src/pipeline/src/etl/processor.rs +++ b/src/pipeline/src/etl/processor.rs @@ -51,7 +51,7 @@ use urlencoding::UrlEncodingProcessor; use vrl::value::Value as VrlValue; use crate::error::{ - Error, FailedParseFieldFromStringSnafu, FieldMustBeTypeSnafu, InvalidFieldRenameSnafu, + FailedParseFieldFromStringSnafu, FieldMustBeTypeSnafu, InvalidFieldRenameSnafu, ProcessorKeyMustBeStringSnafu, ProcessorMustBeMapSnafu, ProcessorMustHaveStringKeySnafu, Result, UnsupportedProcessorSnafu, }; @@ -173,13 +173,12 @@ impl std::ops::DerefMut for Processors { } } -impl TryFrom<&Vec> for Processors { - type Error = Error; - - fn try_from(vec: &Vec) -> Result { +impl Processors { + /// Parses processors using the server's VRL host access policy. + pub fn from_yaml(vec: &[yaml_rust::Yaml], allow_vrl_host_access: bool) -> Result { let mut processors_builders = vec![]; for doc in vec { - let processor = parse_processor(doc)?; + let processor = parse_processor(doc, allow_vrl_host_access)?; processors_builders.push(processor); } Ok(Processors { @@ -188,7 +187,7 @@ impl TryFrom<&Vec> for Processors { } } -fn parse_processor(doc: &yaml_rust::Yaml) -> Result { +fn parse_processor(doc: &yaml_rust::Yaml, allow_vrl_host_access: bool) -> Result { let map = doc.as_hash().context(ProcessorMustBeMapSnafu)?; let key = map.keys().next().context(ProcessorMustHaveStringKeySnafu)?; @@ -227,7 +226,9 @@ fn parse_processor(doc: &yaml_rust::Yaml) -> Result { json_parse::PROCESSOR_JSON_PARSE => { ProcessorKind::JsonParse(JsonParseProcessor::try_from(value)?) } - vrl_processor::PROCESSOR_VRL => ProcessorKind::Vrl(VrlProcessor::try_from(value)?), + vrl_processor::PROCESSOR_VRL => { + ProcessorKind::Vrl(VrlProcessor::from_yaml(value, allow_vrl_host_access)?) + } select::PROCESSOR_SELECT => ProcessorKind::Select(SelectProcessor::try_from(value)?), filter::PROCESSOR_FILTER => ProcessorKind::Filter(FilterProcessor::try_from(value)?), _ => return UnsupportedProcessorSnafu { processor: str_key }.fail(), diff --git a/src/pipeline/src/etl/processor/vrl_processor.rs b/src/pipeline/src/etl/processor/vrl_processor.rs index ee3452523d7..d950834a2a8 100644 --- a/src/pipeline/src/etl/processor/vrl_processor.rs +++ b/src/pipeline/src/etl/processor/vrl_processor.rs @@ -21,11 +21,14 @@ use snafu::{OptionExt, ensure}; use vrl::compiler::runtime::Runtime; use vrl::compiler::{Program, TargetValue, compile}; use vrl::diagnostic::Formatter; -use vrl::prelude::TimeZone; +use vrl::prelude::{ + ArgumentList, Compiled, Example, Function, FunctionCompileContext, Parameter, TimeZone, + TypeState, function, +}; use vrl::value::{Kind, Secrets, Value as VrlValue}; use crate::error::{ - CompileVrlSnafu, Error, ExecuteVrlSnafu, KeyMustBeStringSnafu, Result, VrlRegexValueSnafu, + CompileVrlSnafu, ExecuteVrlSnafu, KeyMustBeStringSnafu, Result, VrlRegexValueSnafu, VrlReturnValueSnafu, }; use crate::etl::processor::yaml_string; @@ -46,8 +49,8 @@ pub struct VrlProcessor { } impl VrlProcessor { - pub fn new(source: String) -> Result { - let fns = vrl::stdlib::all(); + pub fn new(source: String, allow_host_access: bool) -> Result { + let fns = functions(allow_host_access); let compile_result = compile(&source, &fns).map_err(|e| { CompileVrlSnafu { @@ -101,10 +104,9 @@ impl VrlProcessor { } } -impl TryFrom<&yaml_rust::yaml::Hash> for VrlProcessor { - type Error = Error; - - fn try_from(value: &yaml_rust::yaml::Hash) -> Result { +impl VrlProcessor { + /// Parses VRL source using the server's host access policy. + pub fn from_yaml(value: &yaml_rust::yaml::Hash, allow_host_access: bool) -> Result { let mut source = String::new(); for (k, v) in value.iter() { let key = k @@ -114,8 +116,111 @@ impl TryFrom<&yaml_rust::yaml::Hash> for VrlProcessor { source = yaml_string(v, SOURCE)?; } } - let processor = VrlProcessor::new(source)?; - Ok(processor) + Self::new(source, allow_host_access) + } +} + +fn functions(allow_host_access: bool) -> Vec> { + let mut functions = vrl::stdlib::all(); + if allow_host_access { + return functions; + } + + // These functions bypass VRL's event target, including file reads during compilation. + // When upgrading VRL, re-audit this denylist and the argument restrictions below; + // new host-access functions are otherwise enabled by default. + functions.retain(|function| { + !matches!( + function.identifier(), + "get_env_var" + | "get_hostname" + | "get_timezone_name" + | "http_request" + | "dns_lookup" + | "reverse_dns" + | "encode_proto" + | "parse_proto" + | "parse_etld" + | "validate_json_schema" + | "log" + ) + }); + functions + .into_iter() + .map(|function| match function.identifier() { + "parse_groks" | "parse_timestamp" | "format_timestamp" => { + Box::new(RestrictedFunction(function)) as Box + } + _ => function, + }) + .collect() +} + +/// Retains data-only uses of functions that also accept host-access arguments. +#[derive(Debug)] +struct RestrictedFunction(Box); + +impl Function for RestrictedFunction { + fn identifier(&self) -> &'static str { + self.0.identifier() + } + + fn usage(&self) -> &'static str { + self.0.usage() + } + + fn category(&self) -> &'static str { + self.0.category() + } + + fn return_kind(&self) -> u16 { + self.0.return_kind() + } + + fn examples(&self) -> &'static [Example] { + self.0.examples() + } + + fn parameters(&self) -> &'static [Parameter] { + self.0.parameters() + } + + fn pure(&self) -> bool { + self.0.pure() + } + + fn compile( + &self, + state: &TypeState, + ctx: &mut FunctionCompileContext, + arguments: ArgumentList, + ) -> Compiled { + if self.identifier() == "parse_groks" { + if arguments + .optional_array("alias_sources")? + .is_some_and(|sources| !sources.is_empty()) + { + return Err(function::Error::InvalidArgument { + keyword: "alias_sources", + value: "alias_sources".into(), + error: "file imports require pipeline.allow_vrl_host_access", + } + .into()); + } + } else if let Some(timezone) = arguments.optional_literal("timezone", state)? { + // A static named timezone cannot resolve to the host's local timezone at runtime. + if !matches!(&timezone, VrlValue::Bytes(bytes) + if matches!(TimeZone::parse(&String::from_utf8_lossy(bytes)), Some(TimeZone::Named(_)))) + { + return Err(function::Error::InvalidArgument { + keyword: "timezone", + value: timezone, + error: "a named timezone is required when pipeline.allow_vrl_host_access is false", + } + .into()); + } + } + self.0.compile(state, ctx, arguments) } } @@ -163,6 +268,141 @@ mod tests { use super::*; + #[test] + fn test_host_functions_require_opt_in() { + let calls = [ + ("get_env_var", r#"get_env_var!("GREPTIME_VRL_TEST_SECRET")"#), + ("get_hostname", "get_hostname!()"), + ("get_timezone_name", "get_timezone_name!()"), + ("http_request", r#"http_request!("http://127.0.0.1:9/")"#), + ("dns_lookup", r#"dns_lookup!("localhost")"#), + ("reverse_dns", r#"reverse_dns!("127.0.0.1")"#), + ( + "parse_proto", + r#"parse_proto!("", "missing.desc", "Message")"#, + ), + ( + "encode_proto", + r#"encode_proto!({}, "missing.desc", "Message")"#, + ), + ("parse_etld", r#"parse_etld!("example.com")"#), + ( + "validate_json_schema", + r#"validate_json_schema!("{}", "missing.json")"#, + ), + ("log", r#"log("host log write")"#), + ]; + let unrestricted = functions(true); + for (name, call) in calls { + assert!( + unrestricted + .iter() + .any(|function| function.identifier() == name) + ); + let source = format!(".result = {call}\n."); + let error = VrlProcessor::new(source, false).unwrap_err().to_string(); + assert!(error.contains("undefined function"), "{name}: {error}"); + } + + temp_env::with_var("GREPTIME_VRL_TEST_SECRET", Some("decoy"), || { + let source = ".secret = get_env_var!(\"GREPTIME_VRL_TEST_SECRET\")\n."; + let processor = VrlProcessor::new(source.to_string(), true).unwrap(); + let result = processor + .resolve(VrlValue::Object(BTreeMap::new())) + .unwrap(); + assert_eq!( + result.as_object().unwrap()["secret"], + VrlValue::from("decoy") + ); + assert!(VrlProcessor::new(source.to_string(), false).is_err()); + }); + } + + #[test] + fn test_grok_file_import_requires_opt_in() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("aliases.json"); + std::fs::write(&path, r#"{"custom_word":"%{word}"}"#).unwrap(); + let path = serde_json::to_string(&path).unwrap(); + let source = format!( + r#".parsed = parse_groks!("hello", patterns: ["%{{custom_word:message}}"], alias_sources: [{path}]) +."# + ); + let error = VrlProcessor::new(source.clone(), false) + .unwrap_err() + .to_string(); + assert!(error.contains("file imports require pipeline.allow_vrl_host_access")); + let result = VrlProcessor::new(source, true) + .unwrap() + .resolve(VrlValue::Object(BTreeMap::new())) + .unwrap(); + assert_eq!( + result.as_object().unwrap()["parsed"].as_object().unwrap()["message"], + VrlValue::from("hello") + ); + + let source = r#".parsed = parse_groks!("hello", patterns: ["%{custom_word:message}"], aliases: {"custom_word": "%{word}"}, alias_sources: []) +."#; + assert!(VrlProcessor::new(source.to_string(), false).is_ok()); + } + + #[test] + fn test_local_and_dynamic_timezones_require_opt_in() { + for timezone in [r#""local""#, r#""""#, "string!(.timezone)"] { + for call in [ + format!( + r#"format_timestamp!(t'2020-01-01T00:00:00Z', format: "%z", timezone: {timezone})"# + ), + format!( + r#"parse_timestamp!("2020-01-01", format: "%Y-%m-%d", timezone: {timezone})"# + ), + ] { + let source = format!(".result = {call}\n."); + assert!( + VrlProcessor::new(source.clone(), false).is_err(), + "{source}" + ); + assert!(VrlProcessor::new(source.clone(), true).is_ok(), "{source}"); + } + } + + let source = r#".parsed = parse_timestamp!("2020-01-01 00:00:00", format: "%Y-%m-%d %H:%M:%S") +.utc = format_timestamp!(.parsed, format: "%z") +.named = format_timestamp!(.parsed, format: "%z", timezone: "Asia/Shanghai") +."#; + let result = VrlProcessor::new(source.to_string(), false) + .unwrap() + .resolve(VrlValue::Object(BTreeMap::new())) + .unwrap(); + assert_eq!(result.as_object().unwrap()["utc"], VrlValue::from("+0000")); + assert_eq!( + result.as_object().unwrap()["named"], + VrlValue::from("+0800") + ); + } + + #[test] + fn test_pipeline_host_policy_is_server_controlled() { + let yaml = r#" +allow_vrl_host_access: true +processors: + - vrl: + allow_vrl_host_access: true + source: | + .secret = get_env_var!("GREPTIME_VRL_TEST_SECRET") + . +transform: + - field: secret + type: string +"#; + assert!(crate::parse(&crate::Content::Yaml(yaml)).is_err()); + let options = crate::PipelineOptions { + allow_vrl_host_access: true, + ..Default::default() + }; + assert!(crate::parse_with_options(&crate::Content::Yaml(yaml), &options).is_ok()); + } + #[test] fn test_vrl() { let source = r#" @@ -173,7 +413,7 @@ del(.user_info) . "#; - let v = VrlProcessor::new(source.to_string()); + let v = VrlProcessor::new(source.to_string(), false); assert!(v.is_ok()); let v = v.unwrap(); @@ -226,7 +466,7 @@ processors: .and_then(|x| x.as_hash()) .unwrap(); - let vrl = VrlProcessor::try_from(vrl_processor_yaml); + let vrl = VrlProcessor::from_yaml(vrl_processor_yaml, false); assert!(vrl.is_ok()); let vrl = vrl.unwrap(); @@ -245,7 +485,7 @@ del(.re) . "#; - let v = VrlProcessor::new(source.to_string()); + let v = VrlProcessor::new(source.to_string(), false); assert!(v.is_err()); } } diff --git a/src/pipeline/src/lib.rs b/src/pipeline/src/lib.rs index 15954b18f98..086758fa9cc 100644 --- a/src/pipeline/src/lib.rs +++ b/src/pipeline/src/lib.rs @@ -27,7 +27,7 @@ pub use etl::transform::transformer::greptime::{GreptimePipelineParams, SchemaIn pub use etl::transform::transformer::identity_pipeline; pub use etl::{ Content, DispatchedTo, Pipeline, PipelineExecOutput, PipelineProcessOutput, TransformedOutput, - TransformerMode, parse, + TransformerMode, parse, parse_with_options, }; pub use manager::{ GREPTIME_INTERNAL_IDENTITY_PIPELINE_NAME, GREPTIME_INTERNAL_TRACE_PIPELINE_V1_NAME, diff --git a/src/pipeline/src/manager/pipeline_operator.rs b/src/pipeline/src/manager/pipeline_operator.rs index c9731b36f20..8c6f6c7e2f9 100644 --- a/src/pipeline/src/manager/pipeline_operator.rs +++ b/src/pipeline/src/manager/pipeline_operator.rs @@ -14,7 +14,7 @@ use std::collections::HashMap; use std::sync::{Arc, RwLock}; -use std::time::{Duration, Instant}; +use std::time::Instant; use api::v1::CreateTableExpr; use catalog::{CatalogManagerRef, RegisterSystemTableRequest}; @@ -56,7 +56,7 @@ pub struct PipelineOperator { catalog_manager: CatalogManagerRef, query_engine: QueryEngineRef, tables: RwLock>, - cache_ttl: Duration, + options: PipelineOptions, } impl PipelineOperator { @@ -99,7 +99,7 @@ impl PipelineOperator { self.statement_executor.clone(), table, self.query_engine.clone(), - self.cache_ttl, + &self.options, )), ); } @@ -183,7 +183,7 @@ impl PipelineOperator { catalog_manager, tables: RwLock::new(HashMap::new()), query_engine, - cache_ttl: options.cache_ttl, + options: options.clone(), } } @@ -281,7 +281,7 @@ impl PipelineOperator { } /// Compile a pipeline. - pub fn build_pipeline(pipeline: &str) -> Result { - PipelineTable::compile_pipeline(pipeline) + pub fn build_pipeline(&self, pipeline: &str) -> Result { + crate::parse_with_options(&crate::Content::Yaml(pipeline), &self.options) } } diff --git a/src/pipeline/src/manager/table.rs b/src/pipeline/src/manager/table.rs index 0b81493b14c..ab675b6cea5 100644 --- a/src/pipeline/src/manager/table.rs +++ b/src/pipeline/src/manager/table.rs @@ -13,7 +13,6 @@ // limitations under the License. use std::sync::Arc; -use std::time::Duration; use api::v1::value::ValueData; use api::v1::{ @@ -46,10 +45,11 @@ use crate::error::{ ExecuteInternalStatementSnafu, InsertPipelineSnafu, InvalidPipelineVersionSnafu, MultiPipelineWithDiffSchemaSnafu, PipelineNotFoundSnafu, RecordBatchLenNotMatchSnafu, Result, }; -use crate::etl::{Content, Pipeline, parse}; +use crate::etl::{Content, Pipeline, parse_with_options}; use crate::manager::pipeline_cache::{PipelineCache, PipelineContent}; use crate::manager::{PipelineInfo, PipelineVersion}; use crate::metrics::METRIC_PIPELINE_TABLE_FIND_COUNT; +use crate::options::PipelineOptions; use crate::util::prepare_dataframe_conditions; pub(crate) const PIPELINE_TABLE_NAME: &str = "pipelines"; @@ -68,6 +68,7 @@ pub struct PipelineTable { table: TableRef, query_engine: QueryEngineRef, cache: PipelineCache, + options: PipelineOptions, } impl PipelineTable { @@ -77,14 +78,15 @@ impl PipelineTable { statement_executor: StatementExecutorRef, table: TableRef, query_engine: QueryEngineRef, - cache_ttl: Duration, + options: &PipelineOptions, ) -> Self { Self { inserter, statement_executor, table, query_engine, - cache: PipelineCache::new(cache_ttl), + cache: PipelineCache::new(options.cache_ttl), + options: options.clone(), } } @@ -198,9 +200,9 @@ impl PipelineTable { } /// Compile a pipeline from a string. - pub fn compile_pipeline(pipeline: &str) -> Result { + pub fn compile_pipeline(&self, pipeline: &str) -> Result { let yaml_content = Content::Yaml(pipeline); - parse(&yaml_content) + parse_with_options(&yaml_content, &self.options) } /// Insert a pipeline into the pipeline table. @@ -267,7 +269,7 @@ impl PipelineTable { self.cache .get_pipeline_with(schema, name, input_version, async { let pipeline_content = self.get_pipeline_str(schema, name, input_version).await?; - Ok(Arc::new(Self::compile_pipeline(&pipeline_content.content)?)) + Ok(Arc::new(self.compile_pipeline(&pipeline_content.content)?)) }) .await } @@ -373,7 +375,7 @@ impl PipelineTable { content_type: &str, pipeline: &str, ) -> Result { - let compiled_pipeline = Arc::new(Self::compile_pipeline(pipeline)?); + let compiled_pipeline = Arc::new(self.compile_pipeline(pipeline)?); // we will use the version in the future let version = self .insert_pipeline_to_pipeline_table(name, content_type, pipeline) diff --git a/src/pipeline/src/options.rs b/src/pipeline/src/options.rs index 6e34f0ce933..ad1c72a430b 100644 --- a/src/pipeline/src/options.rs +++ b/src/pipeline/src/options.rs @@ -24,12 +24,17 @@ pub struct PipelineOptions { /// duration. Default: "10s". #[serde(with = "humantime_serde")] pub cache_ttl: Duration, + /// Allows VRL programs to access the server environment, filesystem, hostname, + /// local timezone, network, and logs. Enable only for trusted pipeline authors. + /// Defaults to false. Changes require a restart. + pub allow_vrl_host_access: bool, } impl Default for PipelineOptions { fn default() -> Self { Self { cache_ttl: Duration::from_secs(10), + allow_vrl_host_access: false, } } } diff --git a/src/standalone/src/options.rs b/src/standalone/src/options.rs index af72cabef09..ea0b85e3efb 100644 --- a/src/standalone/src/options.rs +++ b/src/standalone/src/options.rs @@ -238,6 +238,19 @@ mod tests { ); } + #[test] + fn test_pipeline_host_access_config_forwarding() { + let defaults: StandaloneOptions = toml::from_str("").unwrap(); + assert!(!defaults.pipeline.allow_vrl_host_access); + assert!(!defaults.frontend_options().pipeline.allow_vrl_host_access); + let options: StandaloneOptions = + toml::from_str("[pipeline]\nallow_vrl_host_access = true").unwrap(); + assert!(options.frontend_options().pipeline.allow_vrl_host_access); + let serialized = toml::to_string(&options).unwrap(); + let restored: StandaloneOptions = toml::from_str(&serialized).unwrap(); + assert_eq!(options.pipeline, restored.pipeline); + } + #[test] fn test_logical_batcher_config_forwarding() { let opts: StandaloneOptions = toml::from_str("[pending_rows_batcher]\nprotocols = ['influxdb']\n[pending_rows_batcher.logical_table]\nprotocols = ['otlp', 'prom']\npending_rows_flush_interval = '10ms'").unwrap(); diff --git a/tests-integration/Cargo.toml b/tests-integration/Cargo.toml index 2c90f4ee2ae..79efd9ed3a0 100644 --- a/tests-integration/Cargo.toml +++ b/tests-integration/Cargo.toml @@ -56,6 +56,7 @@ meta-srv = { workspace = true, features = ["mock"] } mito2.workspace = true object-store.workspace = true operator.workspace = true +pipeline.workspace = true prost.workspace = true query.workspace = true rand.workspace = true @@ -107,7 +108,6 @@ opentelemetry-proto.workspace = true operator = { workspace = true, features = ["testing"] } otel-arrow-rust.workspace = true paste.workspace = true -pipeline.workspace = true plugins.workspace = true prost.workspace = true rand.workspace = true diff --git a/tests-integration/src/standalone.rs b/tests-integration/src/standalone.rs index d0354477fac..368f1584c40 100644 --- a/tests-integration/src/standalone.rs +++ b/tests-integration/src/standalone.rs @@ -92,6 +92,7 @@ pub struct GreptimeDbStandaloneBuilder { logical_batcher: Option, table_batcher: BatcherOptions, mito_config: Option, + pipeline_options: pipeline::PipelineOptions, } impl GreptimeDbStandaloneBuilder { @@ -115,6 +116,7 @@ impl GreptimeDbStandaloneBuilder { logical_batcher: None, table_batcher: BatcherOptions::default(), mito_config: None, + pipeline_options: pipeline::PipelineOptions::default(), } } @@ -132,6 +134,13 @@ impl GreptimeDbStandaloneBuilder { self } + /// Configures pipeline compilation and caching for this test instance. + #[must_use] + pub fn with_pipeline_options(mut self, options: pipeline::PipelineOptions) -> Self { + self.pipeline_options = options; + self + } + /// Configures ordinary-table batching for protocol integration tests. #[must_use] pub fn with_table_batcher(mut self, options: BatcherOptions) -> Self { @@ -405,6 +414,7 @@ impl GreptimeDbStandaloneBuilder { event_recorder: self.event_recorder_options.clone(), auto_create_table: self.auto_create_table, experimental_metric_export: self.experimental_metric_export, + pipeline: self.pipeline_options.clone(), pending_rows_batcher: PendingRowsBatcherOptions { logical_table: self.logical_batcher.clone(), table: self.table_batcher.clone(), diff --git a/tests-integration/tests/http.rs b/tests-integration/tests/http.rs index 8b608fe7694..2fa92a4e608 100644 --- a/tests-integration/tests/http.rs +++ b/tests-integration/tests/http.rs @@ -200,6 +200,7 @@ macro_rules! http_tests { test_pipeline_suffix_template, test_pipeline_context, test_pipeline_with_vrl, + test_pipeline_vrl_host_access, test_pipeline_with_hint_vrl, test_pipeline_one_to_many_vrl, test_pipeline_2, @@ -3063,6 +3064,7 @@ enable_heap_profiling = true [pipeline] cache_ttl = "10s" +allow_vrl_host_access = false [event_recorder] ttl = "2months 29days 2h 52m 48s" @@ -6555,6 +6557,72 @@ table_suffix: _${type} guard.remove_all().await; } +pub async fn test_pipeline_vrl_host_access(storage_type: StorageType) { + let pipeline = r#" +processors: + - vrl: + source: | + .value = is_string(get_env_var("GREPTIME_VRL_HOST_ACCESS_TEST") ?? "missing") + . +transform: + - field: value + type: boolean +"#; + + for allow_vrl_host_access in [false, true] { + let mut instance = tests_integration::standalone::GreptimeDbStandaloneBuilder::new( + "test_pipeline_vrl_host_access", + ) + .with_default_store_type(storage_type) + .with_pipeline_options(pipeline::PipelineOptions { + // Force named dry-runs to reload and compile the stored definition. + cache_ttl: Duration::ZERO, + allow_vrl_host_access, + }) + .build() + .await; + let server = HttpServerBuilder::new(HttpOptions::default()) + .with_log_ingest_handler(instance.fe_instance().clone(), None, None) + .build(); + let client = TestClient::new(server.build(server.make_app()).unwrap()).await; + + let response = client + .post("/v1/pipelines/_dryrun") + .header("Content-Type", "application/json") + .body(json!({"pipeline": pipeline, "data": "[{}]"}).to_string()) + .send() + .await; + let expected_status = if allow_vrl_host_access { + StatusCode::OK + } else { + StatusCode::BAD_REQUEST + }; + assert_eq!(response.status(), expected_status); + if !allow_vrl_host_access { + assert!(response.text().await.contains("undefined function")); + } + + let response = client + .post("/v1/pipelines/host_policy") + .header("Content-Type", "application/x-yaml") + .body(pipeline) + .send() + .await; + assert_eq!(response.status(), expected_status); + + if allow_vrl_host_access { + let response = client + .post("/v1/pipelines/_dryrun") + .header("Content-Type", "application/json") + .body(json!({"pipeline_name": "host_policy", "data": "[{}]"}).to_string()) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + } + instance.guard.remove_all().await; + } +} + pub async fn test_pipeline_with_vrl(storage_type: StorageType) { common_telemetry::init_default_ut_logging(); let (app, mut guard) =