A file can come back out, and a script cannot come back forever
Two halves of the same evening.
Uploads were one-way. A `type: file` field put an object in the bucket
and wrote its key into the record, and nothing ever read it back - a
desk showed `0aba5734-...-drawing.pdf` as a string with no link, so a
builder could receive drawings and never open one.
`GET /attachment/{bucket}/{record}/{field}` streams it. The key is not
the credential and is not taken from the caller: it is read out of the
record named, so a file is reachable only through a record somebody is
allowed to see, and a key that leaks off a desk is a link to nothing.
And there was no rate limiting anywhere. A burst of 30 per caller IP,
refilled one every two seconds, outside the session layer so anyone
over it is turned away before a handler is entered. Well above a
person filling in a form - a page load fetches several resources at
once - and well below being a nuisance. PORTAL_RATE_BURST and
PORTAL_RATE_SECONDS retune it; either 0 turns it off for an instance
behind something that already does this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8d6f29011c
commit
d5de25e8c6
+48
-2
@@ -132,6 +132,42 @@ async fn main() -> anyhow::Result<()> {
|
||||
.with_same_site(tower_sessions::cookie::SameSite::Lax)
|
||||
.with_name("portal_session");
|
||||
|
||||
// What a person does costs a little; what a script does costs a
|
||||
// lot. A burst of 30 with one refilled every two seconds is well
|
||||
// above anything a visitor filling in a form will reach - a page
|
||||
// load fetches several resources at once - and well below what it
|
||||
// takes to be a nuisance. Per caller IP, in memory, no store: this
|
||||
// is a fence, not an accounting system, and the fence that matters
|
||||
// for uploads is the policy check on the route itself.
|
||||
//
|
||||
// PORTAL_RATE_PER_SECOND / PORTAL_RATE_BURST retune it per site;
|
||||
// 0 for either turns it off, for an instance behind something that
|
||||
// already does this.
|
||||
let rate_seconds: u64 = std::env::var("PORTAL_RATE_SECONDS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(2);
|
||||
let rate_burst: u32 = std::env::var("PORTAL_RATE_BURST")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(30);
|
||||
let governor = (rate_seconds > 0 && rate_burst > 0).then(|| {
|
||||
// SmartIpKeyExtractor reads X-Forwarded-For/X-Real-IP, which is
|
||||
// what every one of these instances is behind; it falls back to
|
||||
// the socket address when neither is set.
|
||||
let conf = tower_governor::governor::GovernorConfigBuilder::default()
|
||||
.per_second(rate_seconds)
|
||||
.burst_size(rate_burst)
|
||||
.key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor)
|
||||
.finish()
|
||||
.expect("a rate limit of at least one per second and a burst of at least one");
|
||||
std::sync::Arc::new(conf)
|
||||
});
|
||||
match &governor {
|
||||
Some(_) => tracing::info!(rate_seconds, rate_burst, "rate limiting per caller IP"),
|
||||
None => tracing::warn!("rate limiting is off (PORTAL_RATE_SECONDS or PORTAL_RATE_BURST is 0)"),
|
||||
}
|
||||
|
||||
async fn server_fn_handler(
|
||||
State(state): State<AppState>,
|
||||
request: Request<Body>,
|
||||
@@ -175,6 +211,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
"/upload",
|
||||
post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(upload::MAX_UPLOAD_BYTES + 64 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/attachment/{bucket}/{record}/{field}",
|
||||
get(upload::attachment),
|
||||
)
|
||||
.route("/gitea-repo", get(content::gitea_repo_handler))
|
||||
// Content-shipped assets (hero module etc.), same-origin.
|
||||
.route("/site/{*path}", get(content::site_asset_handler))
|
||||
@@ -224,8 +264,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
},
|
||||
)
|
||||
.fallback(leptos_axum::file_and_error_handler::<AppState, _>(shell))
|
||||
.layer(session_layer)
|
||||
.with_state(state);
|
||||
.layer(session_layer);
|
||||
// Outermost, so a caller who is over the limit is turned away
|
||||
// before a session is loaded or a handler is entered.
|
||||
let app = match governor {
|
||||
Some(conf) => app.layer(tower_governor::GovernorLayer::new(conf)),
|
||||
None => app,
|
||||
}
|
||||
.with_state(state);
|
||||
|
||||
tracing::info!("listening on http://{addr}");
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
|
||||
+108
@@ -250,3 +250,111 @@ alternatives:
|
||||
assert_eq!(page_taking_files(&qs, "/", "Send drawings"), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /attachment/{bucket}/{record}/{field} - the other half of an
|
||||
/// upload. A `type: file` field stores an object key, which on its own
|
||||
/// is a string a desk can read and nobody can open; this streams the
|
||||
/// object back.
|
||||
///
|
||||
/// The key is never the credential. It is not even taken from the
|
||||
/// caller: the caller names a record, and the key is read out of that
|
||||
/// record's own field, so a file can only be fetched through a record
|
||||
/// somebody is allowed to see. Guessing keys gets you nowhere, and a
|
||||
/// key that leaks out of a desk is not a link to anything.
|
||||
pub async fn attachment(
|
||||
State(state): State<AppState>,
|
||||
session: tower_sessions::Session,
|
||||
axum::extract::Path((bucket, record, field)): axum::extract::Path<(String, String, String)>,
|
||||
) -> Result<axum::response::Response, (StatusCode, String)> {
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
let garage = state.garage.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"uploads are not configured".to_string(),
|
||||
))?;
|
||||
|
||||
// Refusals read the same as a miss all the way down, so this
|
||||
// cannot be used to learn which buckets, records or fields exist.
|
||||
let missing = || (StatusCode::NOT_FOUND, "not found".to_string());
|
||||
|
||||
if !may_read_bucket(&state, &session, &bucket).await {
|
||||
return Err(missing());
|
||||
}
|
||||
|
||||
let store = state.jetstream.get_key_value(&bucket).await.map_err(|_| missing())?;
|
||||
let bytes = store.get(&record).await.map_err(|_| missing())?.ok_or_else(missing)?;
|
||||
let answer: crate::answers::Answer = serde_json::from_slice(&bytes).map_err(|_| missing())?;
|
||||
let key = answer
|
||||
.responses
|
||||
.get(&field)
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|k| !k.is_empty())
|
||||
.ok_or_else(missing)?;
|
||||
|
||||
let object = garage
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&garage.bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| missing())?;
|
||||
|
||||
let content_type = object
|
||||
.content_type()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let body = object
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("reading the file failed: {e}")))?
|
||||
.into_bytes();
|
||||
|
||||
// The stored key is `<uuid>-<original name>`; the person who gets
|
||||
// the file should see the name they sent, not the uuid.
|
||||
let filename = key.split_once('-').map(|(_, rest)| rest).unwrap_or(key);
|
||||
let filename = filename.rsplit('/').next().unwrap_or(filename).replace(['"', '\\'], "");
|
||||
Ok((
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, content_type),
|
||||
(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{filename}\""),
|
||||
),
|
||||
],
|
||||
body,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// Whether this caller may see this bucket at all: whether any feature
|
||||
/// in the loaded content lists it and the policy lets them read that
|
||||
/// feature. The desk that shows the record is what grants the file.
|
||||
async fn may_read_bucket(
|
||||
state: &AppState,
|
||||
session: &tower_sessions::Session,
|
||||
bucket: &str,
|
||||
) -> bool {
|
||||
use crate::auth::{User, SESSION_USER_KEY};
|
||||
|
||||
let user = session.get::<User>(SESSION_USER_KEY).await.ok().flatten();
|
||||
let principal = crate::access::Principal::from_user(user.as_ref());
|
||||
let access = state.access.load();
|
||||
state.questions.load().values().any(|question| {
|
||||
question.alternatives.iter().any(|alternative| {
|
||||
alternative.features.iter().any(|feature| {
|
||||
feature.resource.as_ref().and_then(|r| r.bucket()) == Some(bucket)
|
||||
&& access.allows(
|
||||
&principal,
|
||||
crate::access::Action::Read,
|
||||
&crate::access::Resource::Feature {
|
||||
page: question.id.clone(),
|
||||
alternative: alternative.name.clone(),
|
||||
feature: feature.name.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user