Files
windmill/backend/windmill-api/src/service_logs.rs
T
Ruben Fiszel bb90f4ce83 fix(api): authorize and harden log-file reading endpoints (#9368)
* 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>
2026-05-29 04:50:31 +00:00

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")))
}
}