fix(review): harden the E2E-gap fixes after security/regression review
Addresses findings from an independent review of the gap-closing commits: - R1 (regression): add #[serde(default)] to HttpDispatchPayload.method so async-HTTP outbox rows enqueued before the field existed still decode after upgrade instead of dead-lettering on "missing field `method`". Adds a regression test for the missing-key path. - method case: uppercase ctx.request.method at the orchestrator boundary so it honors its documented "uppercased" contract for extension verbs. Route matching is already case-insensitive, so matching is unaffected. - CLI hardening: percent-encode free-form path segments (app slug, topic name, ids, secret name) in the reqwest client so a value containing `/ ? #` can't break out of its URL segment. Adds a `seg()` helper + unit test and applies it uniformly across all path-interpolating methods (new and pre-existing). - S1 (docs): correct the users::email_available enumeration framing — it adds no new vector vs. create's uniqueness error, but is cheaper and unthrottled; there is no built-in throttle/CAPTCHA primitive, so the honest mitigation is a kv-based counter. Updated SDK doc-comments, trait docs, and stdlib-reference. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
|
||||
use picloud_shared::{
|
||||
AdminUserId, ApiKeyId, App, AppDomain, AppId, AppRole, AppUser, DispatchMode, ExecutionLog,
|
||||
HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId,
|
||||
@@ -19,6 +20,24 @@ use serde_json::Value;
|
||||
|
||||
use crate::config::Credentials;
|
||||
|
||||
/// Characters that must be escaped inside a single URL **path segment** so
|
||||
/// a free-form value (an app slug, a topic name) can't break out of its
|
||||
/// segment — `/` would start a new segment, `?`/`#` would start the query
|
||||
/// or fragment. Encodes controls, space, and the structural delimiters.
|
||||
const PATH_SEGMENT: &AsciiSet = &CONTROLS
|
||||
.add(b' ')
|
||||
.add(b'/')
|
||||
.add(b'?')
|
||||
.add(b'#')
|
||||
.add(b'%')
|
||||
.add(b'&')
|
||||
.add(b'+');
|
||||
|
||||
/// Percent-encode a value for safe interpolation into one path segment.
|
||||
fn seg(s: &str) -> String {
|
||||
utf8_percent_encode(s, PATH_SEGMENT).to_string()
|
||||
}
|
||||
|
||||
pub struct Client {
|
||||
http: reqwest::Client,
|
||||
url: String,
|
||||
@@ -73,6 +92,7 @@ impl Client {
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}` — slug or UUID accepted.
|
||||
pub async fn apps_get(&self, ident: &str) -> Result<AppLookupDto> {
|
||||
let ident = seg(ident);
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/apps/{ident}"))
|
||||
.send()
|
||||
@@ -119,6 +139,7 @@ impl Client {
|
||||
/// Server requires `AppAdmin` capability; without `force`, returns
|
||||
/// 409 if the app still has scripts.
|
||||
pub async fn apps_delete(&self, ident: &str, force: bool) -> Result<()> {
|
||||
let ident = seg(ident);
|
||||
let path = if force {
|
||||
format!("/api/v1/admin/apps/{ident}?force=true")
|
||||
} else {
|
||||
@@ -131,6 +152,7 @@ impl Client {
|
||||
/// `DELETE /api/v1/admin/scripts/{id}` — requires `AppAdmin` on the
|
||||
/// owning app (stricter than the edit endpoints, by design).
|
||||
pub async fn scripts_delete(&self, id: &str) -> Result<()> {
|
||||
let id = seg(id);
|
||||
let resp = self
|
||||
.request(Method::DELETE, &format!("/api/v1/admin/scripts/{id}"))
|
||||
.send()
|
||||
@@ -151,6 +173,7 @@ impl Client {
|
||||
/// `PUT /api/v1/admin/scripts/{id}` — matches the dashboard, which
|
||||
/// uses PUT despite the field-level update semantics.
|
||||
pub async fn scripts_update_source(&self, id: &str, source: &str) -> Result<Script> {
|
||||
let id = seg(id);
|
||||
let body = UpdateScriptBody { source };
|
||||
let resp = self
|
||||
.request(Method::PUT, &format!("/api/v1/admin/scripts/{id}"))
|
||||
@@ -169,6 +192,7 @@ impl Client {
|
||||
body: Value,
|
||||
headers: &[(String, String)],
|
||||
) -> Result<ExecuteResponse> {
|
||||
let id = seg(id);
|
||||
let mut req = self
|
||||
.request(Method::POST, &format!("/api/v1/execute/{id}"))
|
||||
.json(&body);
|
||||
@@ -199,6 +223,7 @@ impl Client {
|
||||
|
||||
/// `GET /api/v1/admin/scripts/{id}/logs?limit=N`
|
||||
pub async fn logs_list(&self, script_id: &str, limit: u32) -> Result<Vec<ExecutionLog>> {
|
||||
let script_id = seg(script_id);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
@@ -244,6 +269,7 @@ impl Client {
|
||||
/// `DELETE /api/v1/admin/api-keys/{id}` — 404 covers both "doesn't
|
||||
/// exist" and "not yours" (server flattens to avoid enumeration).
|
||||
pub async fn apikeys_delete(&self, id: &str) -> Result<()> {
|
||||
let id = seg(id);
|
||||
let resp = self
|
||||
.request(Method::DELETE, &format!("/api/v1/admin/api-keys/{id}"))
|
||||
.send()
|
||||
@@ -253,6 +279,7 @@ impl Client {
|
||||
|
||||
/// `GET /api/v1/admin/scripts/{id}/routes`
|
||||
pub async fn routes_list_for_script(&self, script_id: &str) -> Result<Vec<Route>> {
|
||||
let script_id = seg(script_id);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
@@ -269,6 +296,7 @@ impl Client {
|
||||
script_id: &str,
|
||||
body: &CreateRouteBody<'_>,
|
||||
) -> Result<Route> {
|
||||
let script_id = seg(script_id);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::POST,
|
||||
@@ -282,6 +310,7 @@ impl Client {
|
||||
|
||||
/// `DELETE /api/v1/admin/routes/{route_id}`
|
||||
pub async fn routes_delete(&self, route_id: &str) -> Result<()> {
|
||||
let route_id = seg(route_id);
|
||||
let resp = self
|
||||
.request(Method::DELETE, &format!("/api/v1/admin/routes/{route_id}"))
|
||||
.send()
|
||||
@@ -322,6 +351,7 @@ impl Client {
|
||||
|
||||
/// `GET /api/v1/admin/admins/{id}`
|
||||
pub async fn admins_get(&self, id: &str) -> Result<AdminDto> {
|
||||
let id = seg(id);
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/admins/{id}"))
|
||||
.send()
|
||||
@@ -341,6 +371,7 @@ impl Client {
|
||||
|
||||
/// `PATCH /api/v1/admin/admins/{id}`
|
||||
pub async fn admins_patch(&self, id: &str, body: &PatchAdminBody<'_>) -> Result<AdminDto> {
|
||||
let id = seg(id);
|
||||
let resp = self
|
||||
.request(Method::PATCH, &format!("/api/v1/admin/admins/{id}"))
|
||||
.json(body)
|
||||
@@ -351,6 +382,7 @@ impl Client {
|
||||
|
||||
/// `DELETE /api/v1/admin/admins/{id}`
|
||||
pub async fn admins_delete(&self, id: &str) -> Result<()> {
|
||||
let id = seg(id);
|
||||
let resp = self
|
||||
.request(Method::DELETE, &format!("/api/v1/admin/admins/{id}"))
|
||||
.send()
|
||||
@@ -360,6 +392,7 @@ impl Client {
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id}/triggers`
|
||||
pub async fn triggers_list(&self, app: &str) -> Result<TriggerListDto> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/triggers"))
|
||||
.send()
|
||||
@@ -369,6 +402,7 @@ impl Client {
|
||||
|
||||
/// `DELETE /api/v1/admin/apps/{id}/triggers/{trigger_id}`
|
||||
pub async fn triggers_delete(&self, app: &str, trigger_id: &str) -> Result<()> {
|
||||
let (app, trigger_id) = (seg(app), seg(trigger_id));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::DELETE,
|
||||
@@ -388,6 +422,7 @@ impl Client {
|
||||
kind: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<TriggerDto> {
|
||||
let (app, kind) = (seg(app), seg(kind));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::POST,
|
||||
@@ -401,6 +436,7 @@ impl Client {
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id}/dead_letters/count`
|
||||
pub async fn dead_letters_count(&self, app: &str) -> Result<DeadLetterCountDto> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
@@ -418,6 +454,7 @@ impl Client {
|
||||
unresolved: bool,
|
||||
limit: u32,
|
||||
) -> Result<DeadLetterListDto> {
|
||||
let app = seg(app);
|
||||
let path =
|
||||
format!("/api/v1/admin/apps/{app}/dead_letters?unresolved={unresolved}&limit={limit}");
|
||||
let resp = self.request(Method::GET, &path).send().await?;
|
||||
@@ -426,6 +463,7 @@ impl Client {
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id}/dead_letters/{dl_id}`
|
||||
pub async fn dead_letters_get(&self, app: &str, dl_id: &str) -> Result<DeadLetterDto> {
|
||||
let (app, dl_id) = (seg(app), seg(dl_id));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
@@ -438,6 +476,7 @@ impl Client {
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id}/dead_letters/{dl_id}/replay`
|
||||
pub async fn dead_letters_replay(&self, app: &str, dl_id: &str) -> Result<()> {
|
||||
let (app, dl_id) = (seg(app), seg(dl_id));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::POST,
|
||||
@@ -450,6 +489,7 @@ impl Client {
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id}/dead_letters/{dl_id}/resolve`
|
||||
pub async fn dead_letters_resolve(&self, app: &str, dl_id: &str, reason: &str) -> Result<()> {
|
||||
let (app, dl_id) = (seg(app), seg(dl_id));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::POST,
|
||||
@@ -463,6 +503,7 @@ impl Client {
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id}/secrets`
|
||||
pub async fn secrets_list(&self, app: &str) -> Result<SecretListDto> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/secrets"))
|
||||
.send()
|
||||
@@ -472,6 +513,8 @@ impl Client {
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id}/secrets`
|
||||
pub async fn secrets_set(&self, app: &str, name: &str, value: serde_json::Value) -> Result<()> {
|
||||
// `name` travels in the JSON body, not the path — only `app` needs encoding.
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/secrets"))
|
||||
.json(&serde_json::json!({ "name": name, "value": value }))
|
||||
@@ -482,6 +525,7 @@ impl Client {
|
||||
|
||||
/// `DELETE /api/v1/admin/apps/{id}/secrets/{name}`
|
||||
pub async fn secrets_delete(&self, app: &str, name: &str) -> Result<()> {
|
||||
let (app, name) = (seg(app), seg(name));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::DELETE,
|
||||
@@ -496,6 +540,7 @@ impl Client {
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/domains`
|
||||
pub async fn domains_list(&self, app: &str) -> Result<Vec<AppDomain>> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/domains"))
|
||||
.send()
|
||||
@@ -505,6 +550,7 @@ impl Client {
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id_or_slug}/domains`
|
||||
pub async fn domains_create(&self, app: &str, pattern: &str) -> Result<AppDomain> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/domains"))
|
||||
.json(&serde_json::json!({ "pattern": pattern }))
|
||||
@@ -515,6 +561,7 @@ impl Client {
|
||||
|
||||
/// `DELETE /api/v1/admin/apps/{id_or_slug}/domains/{domain_id}`
|
||||
pub async fn domains_delete(&self, app: &str, domain_id: &str) -> Result<()> {
|
||||
let (app, domain_id) = (seg(app), seg(domain_id));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::DELETE,
|
||||
@@ -529,6 +576,7 @@ impl Client {
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/topics`
|
||||
pub async fn topics_list(&self, app: &str) -> Result<Vec<TopicDto>> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/topics"))
|
||||
.send()
|
||||
@@ -545,6 +593,7 @@ impl Client {
|
||||
external_subscribable: bool,
|
||||
auth_mode: &str,
|
||||
) -> Result<TopicDto> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/topics"))
|
||||
.json(&serde_json::json!({
|
||||
@@ -573,6 +622,7 @@ impl Client {
|
||||
if let Some(m) = auth_mode {
|
||||
body.insert("auth_mode".into(), Value::String(m.to_string()));
|
||||
}
|
||||
let (app, name) = (seg(app), seg(name));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::PATCH,
|
||||
@@ -586,6 +636,7 @@ impl Client {
|
||||
|
||||
/// `DELETE /api/v1/admin/apps/{id_or_slug}/topics/{name}`
|
||||
pub async fn topics_delete(&self, app: &str, name: &str) -> Result<()> {
|
||||
let (app, name) = (seg(app), seg(name));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::DELETE,
|
||||
@@ -600,6 +651,7 @@ impl Client {
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/users?limit={n}`
|
||||
pub async fn app_users_list(&self, app: &str, limit: u32) -> Result<Vec<AppUser>> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
@@ -613,6 +665,7 @@ impl Client {
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/users/{user_id}`
|
||||
pub async fn app_user_get(&self, app: &str, user_id: &str) -> Result<AppUser> {
|
||||
let (app, user_id) = (seg(app), seg(user_id));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
@@ -629,6 +682,7 @@ impl Client {
|
||||
app: &str,
|
||||
user_id: &str,
|
||||
) -> Result<ResetPasswordResponseDto> {
|
||||
let (app, user_id) = (seg(app), seg(user_id));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::POST,
|
||||
@@ -645,6 +699,7 @@ impl Client {
|
||||
app: &str,
|
||||
user_id: &str,
|
||||
) -> Result<RevokeSessionsResponseDto> {
|
||||
let (app, user_id) = (seg(app), seg(user_id));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::POST,
|
||||
@@ -1096,4 +1151,20 @@ mod tests {
|
||||
let c = Client::new("http://localhost:8000/", "pic_x").unwrap();
|
||||
assert_eq!(c.url(), "http://localhost:8000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seg_escapes_path_delimiters() {
|
||||
// A free-form segment can't break out into a new path segment,
|
||||
// the query, or the fragment.
|
||||
assert_eq!(seg("a/b"), "a%2Fb");
|
||||
assert_eq!(seg("a?b"), "a%3Fb");
|
||||
assert_eq!(seg("a#b"), "a%23b");
|
||||
assert_eq!(seg("a b"), "a%20b");
|
||||
// Ordinary slugs / UUIDs pass through unchanged.
|
||||
assert_eq!(seg("my-app"), "my-app");
|
||||
assert_eq!(
|
||||
seg("4c8b8775-2a9e-4212-9187-a8f56a1b60e7"),
|
||||
"4c8b8775-2a9e-4212-9187-a8f56a1b60e7"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user