Remediate the HIGH and security-relevant findings from the 2026-07-11 audit. H1 — the per-env approval gate is now server-authoritative. The governing project is resolved from the target node's nearest-claimed ancestor (`governing_env_policy`/`_tree` + `governing_project_id` + `ProjectRepository::get_environments_by_id`), independent of the client-supplied `[project]`. Omitting or spoofing the project block can no longer skip a gate the owning project established; a to-create group resolves its declared parent's chain so a fresh subtree node inherits the gate. Fails closed on any read error. H2 — the API-key prefix slice (`&rest[..8]`) is now the boundary-safe `rest.get(..8)`, so an attacker-supplied multibyte bearer can't panic the request task (unauthenticated per-request DoS). Regression test added. C1 — admin sessions gain an absolute lifetime cap (migration 0070, `PICLOUD_SESSION_ABSOLUTE_TTL_HOURS`, default 30d): `lookup` filters it, `touch` clamps the sliding bump to it, so a continuously-used or stolen-but-warm token self-expires. Mirrors the data-plane app-user cap. C2 — `Cache-Control: no-store` on the login and API-key-mint responses (the two that return a raw credential), so a proxy/CDN/browser cache can't retain it. B8 — file downloads are header-safe: `sanitize_stored_filename` guarantees a valid `HeaderValue` (no panic on a control-char name) and BOTH the per-app and group download paths now set attachment + `X-Content-Type-Options: nosniff` + a restrictive CSP, closing a group-path stored-XSS gap. Also folds in the server-side plan-warning plumbing (`plan_warnings`, `PlanResult::warnings`) and the `app_only_reject` message helper that the CLI plan-preview change builds on, plus operator security notes (reads-open shared- topic SSE; the `--env` label is advisory, not a boundary). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
283 lines
9.4 KiB
Rust
283 lines
9.4 KiB
Rust
//! `/api/v1/admin/apps/{id}/files*` — minimal files admin endpoints
|
|
//! backing the dashboard's files view (v1.1.5).
|
|
//!
|
|
//! Two operations only, both operator-facing:
|
|
//! * `GET /apps/{id}/files?collection=<c>&cursor=&limit=` — list file
|
|
//! metadata for a collection (cursor-paginated).
|
|
//! * `DELETE /apps/{id}/files/{collection}/{file_id}` — remove a file.
|
|
//!
|
|
//! These talk to the `FilesRepo` directly (like `triggers_api` talks to
|
|
//! `TriggerRepo`), guarded by the same capability model as the SDK
|
|
//! (`AppFilesRead` / `AppFilesWrite`). **Admin deletes do NOT emit a
|
|
//! `files:delete` trigger event** — they're operator cleanup actions,
|
|
//! not script mutations (see HANDBACK §7). The capability binds to the
|
|
//! resource's `app_id` after the app is loaded.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::body::Body;
|
|
use axum::extract::{Path, Query, State};
|
|
use axum::http::header::{CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_TYPE};
|
|
use axum::http::StatusCode;
|
|
use axum::response::{IntoResponse, Json, Response};
|
|
use axum::routing::get;
|
|
use axum::{Extension, Router};
|
|
use picloud_shared::{validate_files_collection, AppId, Principal};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::json;
|
|
use uuid::Uuid;
|
|
|
|
use crate::app_repo::AppRepository;
|
|
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
|
use crate::files_repo::{FilesRepo, FilesRepoError};
|
|
|
|
#[derive(Clone)]
|
|
pub struct FilesAdminState {
|
|
pub files: Arc<dyn FilesRepo>,
|
|
pub apps: Arc<dyn AppRepository>,
|
|
pub authz: Arc<dyn AuthzRepo>,
|
|
}
|
|
|
|
pub fn files_admin_router(state: FilesAdminState) -> Router {
|
|
Router::new()
|
|
.route("/apps/{app_id}/files", get(list_files))
|
|
.route(
|
|
"/apps/{app_id}/files/{collection}/{file_id}",
|
|
get(get_file).delete(delete_file),
|
|
)
|
|
.with_state(state)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ListFilesQuery {
|
|
pub collection: String,
|
|
#[serde(default)]
|
|
pub cursor: Option<String>,
|
|
#[serde(default)]
|
|
pub limit: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct FileMetaDto {
|
|
id: String,
|
|
collection: String,
|
|
name: String,
|
|
content_type: String,
|
|
size: u64,
|
|
checksum: String,
|
|
created_at: String,
|
|
updated_at: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ListFilesResponse {
|
|
files: Vec<FileMetaDto>,
|
|
next_cursor: Option<String>,
|
|
}
|
|
|
|
async fn list_files(
|
|
State(s): State<FilesAdminState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(id_or_slug): Path<String>,
|
|
Query(q): Query<ListFilesQuery>,
|
|
) -> Result<Json<ListFilesResponse>, FilesApiError> {
|
|
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
|
require(
|
|
s.authz.as_ref(),
|
|
&principal,
|
|
Capability::AppFilesRead(app_id),
|
|
)
|
|
.await?;
|
|
// Audit 2026-06-11 (F-FS-002) — the admin endpoints hit the repo
|
|
// directly (not via FilesServiceImpl, which validates), so a
|
|
// traversal-shaped collection would otherwise only be caught by the
|
|
// repo's `guard_collection` as an opaque 500. Validate here for a
|
|
// clean 422 and as the outer layer of the belt-and-suspenders guard.
|
|
validate_files_collection(&q.collection).map_err(|e| FilesApiError::Invalid(e.to_string()))?;
|
|
let page = s
|
|
.files
|
|
.list(
|
|
app_id,
|
|
&q.collection,
|
|
q.cursor.as_deref(),
|
|
q.limit.unwrap_or(0),
|
|
)
|
|
.await?;
|
|
let files = page
|
|
.files
|
|
.into_iter()
|
|
.map(|m| FileMetaDto {
|
|
id: m.id.to_string(),
|
|
collection: m.collection,
|
|
name: m.name,
|
|
content_type: m.content_type,
|
|
size: m.size,
|
|
checksum: m.checksum,
|
|
created_at: m.created_at.to_rfc3339(),
|
|
updated_at: m.updated_at.to_rfc3339(),
|
|
})
|
|
.collect();
|
|
Ok(Json(ListFilesResponse {
|
|
files,
|
|
next_cursor: page.next_cursor,
|
|
}))
|
|
}
|
|
|
|
/// `GET /apps/{id}/files/{collection}/{file_id}` — stream the file's
|
|
/// bytes as a download. The dashboard's Download button hits this;
|
|
/// metadata is read via `FilesRepo::head`, bytes via `FilesRepo::get`
|
|
/// (which checksum-verifies on read).
|
|
///
|
|
/// Audit 2026-06-11 C-2 — response hardening. The previous handler
|
|
/// served `Content-Disposition: inline` with the user-supplied
|
|
/// `Content-Type` and no `nosniff` / CSP, so an SVG or HTML upload
|
|
/// rendered same-origin and hijacked the admin session. Now:
|
|
/// * `Content-Disposition: attachment` always.
|
|
/// * `Content-Type` re-sanitized to the storage allowlist as
|
|
/// belt-and-suspenders (`files_service::create` / `update` already
|
|
/// coerce at write, but older rows may pre-date that change).
|
|
/// * `X-Content-Type-Options: nosniff` blocks MIME-sniffing fallbacks.
|
|
/// * Restrictive CSP scopes any residual rendering attempt.
|
|
async fn get_file(
|
|
State(s): State<FilesAdminState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path((id_or_slug, collection, file_id)): Path<(String, String, String)>,
|
|
) -> Result<Response, FilesApiError> {
|
|
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
|
require(
|
|
s.authz.as_ref(),
|
|
&principal,
|
|
Capability::AppFilesRead(app_id),
|
|
)
|
|
.await?;
|
|
validate_files_collection(&collection).map_err(|e| FilesApiError::Invalid(e.to_string()))?;
|
|
let id = Uuid::parse_str(&file_id).map_err(|_| FilesApiError::NotFound)?;
|
|
let meta = s
|
|
.files
|
|
.head(app_id, &collection, id)
|
|
.await?
|
|
.ok_or(FilesApiError::NotFound)?;
|
|
let bytes = s
|
|
.files
|
|
.get(app_id, &collection, id)
|
|
.await?
|
|
.ok_or(FilesApiError::NotFound)?;
|
|
let safe_ct = picloud_shared::sanitize_stored_content_type(&meta.content_type);
|
|
// The stored name is upload-supplied; sanitize to header-safe ASCII so the
|
|
// response builder can never error on a control byte (which `.expect()`
|
|
// below would turn into a per-request panic).
|
|
let disposition = format!(
|
|
"attachment; filename=\"{}\"",
|
|
picloud_shared::sanitize_stored_filename(&meta.name)
|
|
);
|
|
let len = bytes.len();
|
|
Ok(Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(CONTENT_TYPE, safe_ct)
|
|
.header(CONTENT_DISPOSITION, disposition)
|
|
.header(CONTENT_LENGTH, len)
|
|
.header("X-Content-Type-Options", "nosniff")
|
|
.header(
|
|
"Content-Security-Policy",
|
|
"default-src 'none'; sandbox; frame-ancestors 'none'",
|
|
)
|
|
.header("Referrer-Policy", "no-referrer")
|
|
.body(Body::from(bytes))
|
|
.expect("build files download response"))
|
|
}
|
|
|
|
async fn delete_file(
|
|
State(s): State<FilesAdminState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path((id_or_slug, collection, file_id)): Path<(String, String, String)>,
|
|
) -> Result<StatusCode, FilesApiError> {
|
|
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
|
require(
|
|
s.authz.as_ref(),
|
|
&principal,
|
|
Capability::AppFilesWrite(app_id),
|
|
)
|
|
.await?;
|
|
validate_files_collection(&collection).map_err(|e| FilesApiError::Invalid(e.to_string()))?;
|
|
let id = Uuid::parse_str(&file_id).map_err(|_| FilesApiError::NotFound)?;
|
|
if s.files.delete(app_id, &collection, id).await?.is_none() {
|
|
return Err(FilesApiError::NotFound);
|
|
}
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, FilesApiError> {
|
|
crate::app_repo::resolve_app(apps, ident)
|
|
.await
|
|
.map_err(|e| FilesApiError::Backend(e.to_string()))?
|
|
.map(|l| l.app.id)
|
|
.ok_or(FilesApiError::AppNotFound)
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum FilesApiError {
|
|
#[error("app not found")]
|
|
AppNotFound,
|
|
#[error("file not found")]
|
|
NotFound,
|
|
#[error("invalid request: {0}")]
|
|
Invalid(String),
|
|
#[error("forbidden")]
|
|
Forbidden,
|
|
#[error("authorization repo error: {0}")]
|
|
AuthzRepo(String),
|
|
#[error("files backend: {0}")]
|
|
Backend(String),
|
|
}
|
|
|
|
impl From<AuthzDenied> for FilesApiError {
|
|
fn from(d: AuthzDenied) -> Self {
|
|
match d {
|
|
AuthzDenied::Denied => Self::Forbidden,
|
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<AuthzError> for FilesApiError {
|
|
fn from(e: AuthzError) -> Self {
|
|
Self::AuthzRepo(e.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<FilesRepoError> for FilesApiError {
|
|
fn from(e: FilesRepoError) -> Self {
|
|
Self::Backend(e.to_string())
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for FilesApiError {
|
|
fn into_response(self) -> Response {
|
|
let (status, body) = match &self {
|
|
Self::AppNotFound | Self::NotFound => {
|
|
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
|
}
|
|
Self::Invalid(_) => (
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
json!({ "error": self.to_string() }),
|
|
),
|
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
|
Self::AuthzRepo(e) => {
|
|
tracing::error!(error = %e, "files admin authz repo error");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
json!({ "error": "internal error" }),
|
|
)
|
|
}
|
|
Self::Backend(e) => {
|
|
tracing::error!(error = %e, "files admin backend error");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
json!({ "error": "internal error" }),
|
|
)
|
|
}
|
|
};
|
|
(status, Json(body)).into_response()
|
|
}
|
|
}
|