fix(video): keep signed stream tokens out of logs and traces

The access log and the tracing root span's http.target both recorded the
full /video/original/stream?token=… URL, i.e. a credential for a
possibly sensitive file, in the journal and OTel exports. An outermost
middleware now moves the token into request extensions and rewrites the
URI to token=redacted before either logger sees it; the handler reads
the token from extensions and fails closed (401) without the middleware.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-13 22:32:39 -04:00
1 parent 40d9dcdbb6
commit c0e6a68ca4
2 files changed
+125 -10

No files matched your search

+120 -10
View File
@@ -8,13 +8,18 @@ use tracing_opentelemetry::OpenTelemetrySpanExt;
use actix_files::NamedFile;
use actix_web::{
HttpRequest, HttpResponse, Responder, get, post,
HttpMessage, HttpRequest, HttpResponse, Responder,
body::MessageBody,
dev::{ServiceRequest, ServiceResponse},
get,
middleware::Next,
post,
web::{self, Data},
};
use log::{debug, error, info, warn};
use opentelemetry::KeyValue;
use opentelemetry::trace::Status;
use serde::{Deserialize, Serialize};
use serde::Serialize;
use crate::content_hash;
use crate::data::{
@@ -301,29 +306,71 @@ pub async fn original_video_url(
span.set_status(Status::Ok);
HttpResponse::Ok().json(OriginalVideoUrlResponse {
url: format!("/video/original/stream?token={}", token),
url: format!("{}?token={}", ORIGINAL_STREAM_PATH, token),
expires_at,
})
}
#[derive(Deserialize)]
struct OriginalStreamQuery {
token: String,
const ORIGINAL_STREAM_PATH: &str = "/video/original/stream";
/// A stream token lifted out of the request URI by
/// [`redact_original_stream_token`].
struct OriginalStreamToken(String);
/// Moves `token=` off `/video/original/stream` requests into request
/// extensions and rewrites the URI to `token=redacted`, so neither the
/// access log nor the tracing span's `http.target` (exported to OTel)
/// records a credential for a possibly sensitive file. Must be the
/// outermost `.wrap()` so it runs before both loggers; without it the
/// stream handler finds no token and 401s (fails closed).
pub async fn redact_original_stream_token(
mut req: ServiceRequest,
next: Next<impl MessageBody>,
) -> Result<ServiceResponse<impl MessageBody>, actix_web::Error> {
if req.path() == ORIGINAL_STREAM_PATH
&& let Some((token, redacted_query)) = take_token_param(req.query_string())
&& let Ok(uri) = format!("{}?{}", ORIGINAL_STREAM_PATH, redacted_query).parse()
{
req.head_mut().uri = uri;
req.extensions_mut().insert(OriginalStreamToken(token));
}
next.call(req).await
}
/// Split the first `token=` value out of a query string, returning it and
/// the query with that value replaced by `redacted`.
fn take_token_param(query: &str) -> Option<(String, String)> {
let mut token = None;
let redacted: Vec<&str> = query
.split('&')
.map(|pair| match pair.strip_prefix("token=") {
Some(value) if token.is_none() => {
token = Some(value.to_string());
"token=redacted"
}
_ => pair,
})
.collect();
token.map(|t| (t, redacted.join("&")))
}
/// Stream an original video named by a token from `POST /video/original`.
/// Authenticated by the token alone (no Claims). `NamedFile` honours Range,
/// so players can seek.
/// Authenticated by the token alone (no Claims), which
/// [`redact_original_stream_token`] has already moved out of the URI.
/// `NamedFile` honours Range, so players can seek.
#[get("/video/original/stream")]
#[tracing::instrument(name = "stream_original_video", skip_all)]
pub async fn stream_original_video(
request: HttpRequest,
query: web::Query<OriginalStreamQuery>,
app_state: Data<AppState>,
) -> impl Responder {
let span = tracing::Span::current();
let Some(claims) = original_url::verify(&query.token) else {
let claims = request
.extensions()
.get::<OriginalStreamToken>()
.and_then(|t| original_url::verify(&t.0));
let Some(claims) = claims else {
span.set_status(Status::error("bad token"));
return HttpResponse::Unauthorized().finish();
};
@@ -745,6 +792,7 @@ mod tests {
write_library_file(&app_state, "clips/a.mp4", b"0123456789");
let app = actix_web::test::init_service(
App::new()
.wrap(actix_web::middleware::from_fn(redact_original_stream_token))
.service(original_video_url)
.service(stream_original_video)
.app_data(app_state),
@@ -796,12 +844,74 @@ mod tests {
assert_eq!(actix_web::test::call_service(&app, req).await.status(), 401);
}
#[test]
fn take_token_param_redacts_only_the_token() {
assert_eq!(
take_token_param("token=abc.def.ghi"),
Some(("abc.def.ghi".into(), "token=redacted".into()))
);
assert_eq!(
take_token_param("x=1&token=abc&y=2"),
Some(("abc".into(), "x=1&token=redacted&y=2".into()))
);
assert_eq!(take_token_param("x=1&tokens=abc"), None);
assert_eq!(take_token_param(""), None);
}
/// What the loggers (and handler) see after the middleware runs.
#[get("/video/original/stream")]
async fn echo_uri_and_token(request: HttpRequest) -> HttpResponse {
let token = request
.extensions()
.get::<OriginalStreamToken>()
.map(|t| t.0.clone())
.unwrap_or_default();
HttpResponse::Ok().body(format!("{} {}", request.uri(), token))
}
#[actix_rt::test]
async fn redact_middleware_strips_token_from_uri() {
let app = actix_web::test::init_service(
App::new()
.wrap(actix_web::middleware::from_fn(redact_original_stream_token))
.service(echo_uri_and_token),
)
.await;
let req = actix_web::test::TestRequest::get()
.uri("/video/original/stream?token=secret.jwt.value")
.to_request();
let body = actix_web::test::call_and_read_body(&app, req).await;
assert_eq!(
std::str::from_utf8(&body).unwrap(),
"/video/original/stream?token=redacted secret.jwt.value"
);
}
#[actix_rt::test]
async fn stream_original_video_fails_closed_without_middleware() {
let app_state = Data::new(AppState::test_state());
write_library_file(&app_state, "clips/a.mp4", b"0123456789");
let app = actix_web::test::init_service(
App::new()
.service(stream_original_video)
.app_data(app_state),
)
.await;
let (token, _) =
original_url::sign("1", crate::libraries::PRIMARY_LIBRARY_ID, "clips/a.mp4");
let req = actix_web::test::TestRequest::get()
.uri(&format!("/video/original/stream?token={}", token))
.to_request();
assert_eq!(actix_web::test::call_service(&app, req).await.status(), 401);
}
#[actix_rt::test]
async fn stream_original_video_rejects_bad_tokens() {
let app_state = Data::new(AppState::test_state());
write_library_file(&app_state, "clips/a.mp4", b"0123456789");
let app = actix_web::test::init_service(
App::new()
.wrap(actix_web::middleware::from_fn(redact_original_stream_token))
.service(stream_original_video)
.app_data(app_state),
)
+5
View File
@@ -318,6 +318,11 @@ fn main() -> std::io::Result<()> {
.wrap(middleware::Logger::default())
.wrap(cors)
.wrap(tracing_actix_web::TracingLogger::default())
// Outermost: strips signed stream tokens from the URI before
// either logger above records it.
.wrap(middleware::from_fn(
handlers::video::redact_original_stream_token,
))
.service(
web::resource("/login")
.wrap(Governor::new(&governor_conf))