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>
361 lines
13 KiB
Rust
361 lines
13 KiB
Rust
//! Plain multipart upload → Garage (S3-compatible), for `type: file`
|
|
//! requirement fields. A raw Axum handler, not a Leptos server fn -
|
|
//! those aren't built for binary bodies. The stored object key becomes
|
|
//! that field's value in `responses_json` (`app.rs`'s file-input
|
|
//! widget), so nothing about `submit_answer`'s generic
|
|
//! `Map<String, String>` flow needs to know a file was involved.
|
|
#![cfg(feature = "ssr")]
|
|
|
|
use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region};
|
|
use aws_sdk_s3::primitives::ByteStream;
|
|
use axum::extract::{Multipart, State};
|
|
use axum::http::StatusCode;
|
|
use axum::Json;
|
|
use serde::Serialize;
|
|
|
|
use crate::server::AppState;
|
|
|
|
pub const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024;
|
|
|
|
#[derive(Clone)]
|
|
pub struct Garage {
|
|
client: aws_sdk_s3::Client,
|
|
bucket: String,
|
|
}
|
|
|
|
impl Garage {
|
|
/// Builds a client from the `GARAGE_*` env vars. `None` (not an
|
|
/// error) when unset - uploads are optional, everything else works
|
|
/// without Garage configured.
|
|
pub fn from_env() -> Option<Self> {
|
|
let endpoint = std::env::var("GARAGE_S3_ENDPOINT")
|
|
.unwrap_or_else(|_| "http://garage:3900".to_string());
|
|
let access_key = std::env::var("GARAGE_ACCESS_KEY").ok()?;
|
|
let secret_key = std::env::var("GARAGE_SECRET_KEY").ok()?;
|
|
let bucket = std::env::var("GARAGE_UPLOADS_BUCKET")
|
|
.unwrap_or_else(|_| "portal-attachments".to_string());
|
|
|
|
let credentials = Credentials::new(access_key, secret_key, None, None, "portal");
|
|
let config = aws_sdk_s3::Config::builder()
|
|
.behavior_version(BehaviorVersion::latest())
|
|
.region(Region::new("garage"))
|
|
.endpoint_url(endpoint)
|
|
.credentials_provider(credentials)
|
|
// Garage expects path-style bucket addressing, not the
|
|
// virtual-hosted `bucket.host` style AWS defaults to.
|
|
.force_path_style(true)
|
|
.build();
|
|
Some(Self {
|
|
client: aws_sdk_s3::Client::from_conf(config),
|
|
bucket,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct UploadResponse {
|
|
key: String,
|
|
}
|
|
|
|
/// POST /upload - takes the `file` field, plus the `question` and
|
|
/// `alternative` it belongs to, and hands back its object key.
|
|
///
|
|
/// The two names are not decoration: they are what makes this an
|
|
/// authorized route rather than an open one. An upload is half a
|
|
/// submission, so it asks the policy exactly what the submission will
|
|
/// ask - `Submit` on that page - and it only accepts a page that
|
|
/// actually declares a `type: file` field. Without that, anybody who
|
|
/// could reach the host could put twenty megabytes in the bucket as
|
|
/// often as they liked, on a site whose forms are public.
|
|
///
|
|
/// Server-side hygiene beyond the HTML `accept` hint (a UX signal, not
|
|
/// a boundary): a hard size cap, and a refusal rather than a silent
|
|
/// success when Garage isn't configured.
|
|
pub async fn upload(
|
|
State(state): State<AppState>,
|
|
session: tower_sessions::Session,
|
|
mut multipart: Multipart,
|
|
) -> Result<Json<UploadResponse>, (StatusCode, String)> {
|
|
let garage = state.garage.as_ref().ok_or((
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"uploads are not configured".to_string(),
|
|
))?;
|
|
|
|
let mut question_id = String::new();
|
|
let mut alternative = String::new();
|
|
let mut file: Option<(String, String, Vec<u8>)> = None;
|
|
|
|
while let Some(mut field) = multipart
|
|
.next_field()
|
|
.await
|
|
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
|
|
{
|
|
match field.name() {
|
|
Some("question") | Some("alternative") => {
|
|
let name = field.name().unwrap_or_default().to_string();
|
|
let text = field
|
|
.text()
|
|
.await
|
|
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
|
|
if name == "question" {
|
|
question_id = text;
|
|
} else {
|
|
alternative = text;
|
|
}
|
|
}
|
|
Some("file") => {
|
|
let content_type = field
|
|
.content_type()
|
|
.unwrap_or("application/octet-stream")
|
|
.to_string();
|
|
let filename = field.file_name().unwrap_or("upload").to_string();
|
|
// Chunk by chunk, refused the moment the cap is
|
|
// crossed: an oversized body is never buffered whole
|
|
// before being turned away. The route's body limit
|
|
// (main.rs) is the outer fence.
|
|
let mut bytes: Vec<u8> = Vec::new();
|
|
while let Some(chunk) = field
|
|
.chunk()
|
|
.await
|
|
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
|
|
{
|
|
if bytes.len() + chunk.len() > MAX_UPLOAD_BYTES {
|
|
return Err((StatusCode::PAYLOAD_TOO_LARGE, "file too large".to_string()));
|
|
}
|
|
bytes.extend_from_slice(&chunk);
|
|
}
|
|
file = Some((filename, content_type, bytes));
|
|
}
|
|
_ => continue,
|
|
}
|
|
}
|
|
|
|
let (filename, content_type, bytes) =
|
|
file.ok_or((StatusCode::BAD_REQUEST, "no file field in request".to_string()))?;
|
|
|
|
// Nothing has been written yet. Everything below decides whether
|
|
// anything will be.
|
|
if !may_upload(&state, &session, &question_id, &alternative).await {
|
|
return Err((StatusCode::FORBIDDEN, "not authorized to upload here".to_string()));
|
|
}
|
|
|
|
let key = format!("{}-{filename}", uuid::Uuid::new_v4());
|
|
garage
|
|
.client
|
|
.put_object()
|
|
.bucket(&garage.bucket)
|
|
.key(&key)
|
|
.body(ByteStream::from(bytes))
|
|
.content_type(content_type)
|
|
.send()
|
|
.await
|
|
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("upload failed: {e}")))?;
|
|
|
|
Ok(Json(UploadResponse { key }))
|
|
}
|
|
|
|
/// Whether this caller may attach a file to that page's alternative:
|
|
/// the alternative has to exist, has to declare a `type: file` field,
|
|
/// and the policy has to let this principal submit that page. The same
|
|
/// question `submit_answer` asks, asked before the bytes land rather
|
|
/// than after.
|
|
async fn may_upload(
|
|
state: &AppState,
|
|
session: &tower_sessions::Session,
|
|
question_id: &str,
|
|
alternative: &str,
|
|
) -> bool {
|
|
use crate::auth::{User, SESSION_USER_KEY};
|
|
|
|
let questions = state.questions.load();
|
|
let Some(page) = page_taking_files(&questions, question_id, alternative) else {
|
|
return false;
|
|
};
|
|
let user = session.get::<User>(SESSION_USER_KEY).await.ok().flatten();
|
|
let principal = crate::access::Principal::from_user(user.as_ref());
|
|
crate::access::decide_and_publish(
|
|
&state.nats,
|
|
&state.access.load(),
|
|
&principal,
|
|
crate::access::Action::Submit,
|
|
&crate::access::Resource::Page { id: page },
|
|
serde_json::json!({ "alternative": alternative }),
|
|
)
|
|
.allow
|
|
}
|
|
|
|
/// The resolved page id, when that page has an alternative of this
|
|
/// name that is open and actually asks for a file. `None` for a page
|
|
/// that does not exist, an alternative that does not, one that is
|
|
/// disabled, or one that asks for no file at all - an upload that
|
|
/// names none of the site's own file fields is not an upload this site
|
|
/// asked for.
|
|
fn page_taking_files(
|
|
questions: &std::collections::HashMap<String, crate::content::Question>,
|
|
question_id: &str,
|
|
alternative: &str,
|
|
) -> Option<String> {
|
|
let question = crate::content::resolve_question(questions, question_id)?;
|
|
question
|
|
.alternatives
|
|
.iter()
|
|
.find(|a| a.name == alternative && !a.disabled)?
|
|
.features
|
|
.iter()
|
|
.any(|f| f.requirements.iter().any(|r| r.kind == "file"))
|
|
.then(|| question.id.clone())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn questions(yaml: &str) -> std::collections::HashMap<String, crate::content::Question> {
|
|
crate::content::build_questions(&[("index.yaml".to_string(), yaml.to_string())]).unwrap()
|
|
}
|
|
|
|
const PAGE: &str = "name: Home
|
|
alternatives:
|
|
- name: Send drawings
|
|
record_as: enquiries
|
|
features:
|
|
- name: Files
|
|
requirements:
|
|
- { name: drawings, type: file, optional: true }
|
|
- name: Just ask
|
|
features:
|
|
- name: Words
|
|
requirements:
|
|
- { name: question, type: textarea }
|
|
";
|
|
|
|
// The bytes are only ever written for a page of this site that
|
|
// asked for a file. Everything else is somebody using the host as
|
|
// a disk.
|
|
#[test]
|
|
fn an_upload_must_name_a_page_that_asked_for_a_file() {
|
|
let qs = questions(PAGE);
|
|
assert_eq!(page_taking_files(&qs, "/", "Send drawings"), Some("/".to_string()));
|
|
// An alternative on the same page that takes no file.
|
|
assert_eq!(page_taking_files(&qs, "/", "Just ask"), None);
|
|
// Names that are not there at all.
|
|
assert_eq!(page_taking_files(&qs, "/", "Nonexistent"), None);
|
|
assert_eq!(page_taking_files(&qs, "/nowhere", "Send drawings"), None);
|
|
assert_eq!(page_taking_files(&qs, "", ""), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_closed_alternative_takes_nothing() {
|
|
let qs = questions(&PAGE.replace(" record_as: enquiries\n", " record_as: enquiries\n disabled: true\n"));
|
|
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(),
|
|
},
|
|
)
|
|
})
|
|
})
|
|
})
|
|
}
|