mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 08:01:35 +00:00
bb90f4ce83
* fix(api): don't follow symlinks when reading service log files Defense in depth on top of the existing `..` path-traversal check in the get_log_file handler: reject the request if the final path component is a symlink, so a planted symlink in the logs directory cannot be used to read arbitrary files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): authorize and harden the jobs_u get_log_file endpoint The unauthenticated jobs_u get_log_file endpoint served any job's log file to anyone who knew the job UUID, with no authorization. Gate it the same way as get_job_logs: look up the job (the log directory name is the job id) filtered by workspace and the caller's scope tags, and only allow non-logged-in callers to read logs of jobs created by the anonymous user. Also add defense in depth: refuse to read through a symlink so a planted symlink in the logs directory cannot be used to exfiltrate arbitrary files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
154 lines
4.9 KiB
Rust
154 lines
4.9 KiB
Rust
/*
|
|
* Author: Ruben Fiszel
|
|
* Copyright: Windmill Labs, Inc 2022
|
|
* This file and its contents are licensed under the AGPLv3 License.
|
|
* Please see the included NOTICE for copyright information and
|
|
* LICENSE-AGPL for a copy of the license.
|
|
*/
|
|
|
|
use crate::utils::{content_plain, require_devops_role};
|
|
use axum::{body::Body, extract::Query, response::Response, routing::get, Extension, Json, Router};
|
|
use serde::Serialize;
|
|
|
|
use windmill_common::{
|
|
error::{Error, JsonResult},
|
|
utils::Pagination,
|
|
};
|
|
|
|
use crate::db::{ApiAuthed, DB};
|
|
|
|
pub fn global_service() -> Router {
|
|
Router::new()
|
|
.route("/list_files", get(list_files))
|
|
.route("/get_log_file/{*path}", get(get_log_file))
|
|
}
|
|
use axum::extract::Path;
|
|
|
|
#[derive(Debug, serde::Deserialize)]
|
|
pub struct LogFileQuery {
|
|
before: Option<chrono::DateTime<chrono::Utc>>,
|
|
after: Option<chrono::DateTime<chrono::Utc>>,
|
|
with_error: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, sqlx::FromRow, Serialize)]
|
|
pub struct LogFile {
|
|
pub hostname: String,
|
|
pub mode: String,
|
|
pub worker_group: Option<String>,
|
|
pub log_ts: chrono::NaiveDateTime,
|
|
pub file_path: String,
|
|
pub ok_lines: Option<i64>,
|
|
pub err_lines: Option<i64>,
|
|
pub json_fmt: bool,
|
|
}
|
|
async fn list_files(
|
|
ApiAuthed { email, .. }: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Query(pagination): Query<Pagination>,
|
|
Query(lq): Query<LogFileQuery>,
|
|
) -> JsonResult<Vec<LogFile>> {
|
|
require_devops_role(&db, &email).await?;
|
|
let (per_page, offset) = windmill_common::utils::paginate(pagination);
|
|
|
|
let mut sqlb = sql_builder::SqlBuilder::select_from("log_file")
|
|
.fields(&[
|
|
"hostname",
|
|
"mode::text",
|
|
"worker_group",
|
|
"log_ts",
|
|
"file_path",
|
|
"ok_lines",
|
|
"err_lines",
|
|
"json_fmt",
|
|
])
|
|
.order_by("log_ts", true)
|
|
.offset(offset)
|
|
.limit(per_page)
|
|
.clone();
|
|
|
|
if let Some(dt) = &lq.before {
|
|
sqlb.and_where_le(
|
|
"log_ts",
|
|
format!("to_timestamp({} / 1000.0)", dt.timestamp_millis()),
|
|
);
|
|
}
|
|
if let Some(dt) = &lq.after {
|
|
sqlb.and_where_ge(
|
|
"log_ts",
|
|
format!("to_timestamp({} / 1000.0)", dt.timestamp_millis()),
|
|
);
|
|
}
|
|
|
|
if let Some(true) = lq.with_error {
|
|
sqlb.and_where("err_lines > 0");
|
|
}
|
|
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
|
let rows = sqlx::query_as::<_, LogFile>(&sql).fetch_all(&db).await?;
|
|
Ok(Json(rows))
|
|
}
|
|
|
|
async fn get_log_file(
|
|
ApiAuthed { email, .. }: ApiAuthed,
|
|
Extension(db): Extension<DB>,
|
|
Path(path): Path<windmill_common::utils::StripPath>,
|
|
) -> windmill_common::error::Result<Response> {
|
|
use windmill_common::tracing_init::TMP_WINDMILL_LOGS_SERVICE;
|
|
|
|
require_devops_role(&db, &email).await?;
|
|
let path = path.to_path();
|
|
if path.contains("..") {
|
|
return Err(Error::BadRequest("Invalid path".to_string()));
|
|
}
|
|
#[cfg(feature = "parquet")]
|
|
let s3_client = windmill_object_store::get_object_store().await;
|
|
#[cfg(feature = "parquet")]
|
|
if let Some(s3_client) = s3_client {
|
|
let path = format!("{}{}", windmill_common::tracing_init::LOGS_SERVICE, path);
|
|
let file = s3_client
|
|
.get(&windmill_object_store::object_store_reexports::Path::from(
|
|
path,
|
|
))
|
|
.await;
|
|
match file {
|
|
Ok(file) => {
|
|
let bytes = file.bytes().await;
|
|
match bytes {
|
|
Ok(bytes) => {
|
|
return Ok(content_plain(Body::from(bytes::Bytes::from(bytes))));
|
|
}
|
|
Err(e) => {
|
|
return Err(Error::internal_err(format!(
|
|
"Error pulling the bytes: {}",
|
|
e
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
return Err(Error::internal_err(format!(
|
|
"Error fetching the file: {}",
|
|
e
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
let full_path = format!("{}{}", *TMP_WINDMILL_LOGS_SERVICE, path);
|
|
// SECURITY (defense in depth): refuse to read through a symlink so a planted
|
|
// symlink in the logs directory cannot be used to exfiltrate arbitrary files.
|
|
// `symlink_metadata` returns the link's own metadata without following it.
|
|
match tokio::fs::symlink_metadata(&full_path).await {
|
|
Ok(meta) if meta.file_type().is_symlink() => {
|
|
return Err(Error::BadRequest("Invalid path".to_string()));
|
|
}
|
|
Ok(_) => {}
|
|
Err(_) => return Err(Error::NotFound(format!("File {path} not found"))),
|
|
}
|
|
let file = tokio::fs::read(&full_path).await;
|
|
if let Ok(bytes) = file {
|
|
Ok(content_plain(Body::from(bytes::Bytes::from(bytes))))
|
|
} else {
|
|
Err(Error::NotFound(format!("File {path} not found")))
|
|
}
|
|
}
|