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:
MechaCat02
2026-06-13 09:59:24 +02:00
parent 30bad27711
commit a91b134285
9 changed files with 156 additions and 4 deletions

1
Cargo.lock generated
View File

@@ -1962,6 +1962,7 @@ dependencies = [
"clap", "clap",
"directories", "directories",
"libc", "libc",
"percent-encoding",
"picloud-shared", "picloud-shared",
"postgres", "postgres",
"predicates", "predicates",

View File

@@ -10,6 +10,9 @@
//! // for anonymous public scripts. //! // for anonymous public scripts.
//! let free = users::email_available("a@b"); // bool — anonymous-safe pre-check //! let free = users::email_available("a@b"); // bool — anonymous-safe pre-check
//! // for self-serve registration. //! // for self-serve registration.
//! // Leaks only "exists/doesn't" but
//! // is cheap + unthrottled: throttle
//! // the route if enumeration matters.
//! users::update(id, #{ display_name: "Alicia" }); //! users::update(id, #{ display_name: "Alicia" });
//! let removed = users::delete(id); // bool //! let removed = users::delete(id); // bool
//! let page = users::list(#{ "$limit": 50, cursor: () }); //! let page = users::list(#{ "$limit": 50, cursor: () });

View File

@@ -520,9 +520,17 @@ impl UsersService for UsersServiceImpl {
// Gated like `create` (the registration write path) so the probe // Gated like `create` (the registration write path) so the probe
// is available exactly where self-serve registration is — and, // is available exactly where self-serve registration is — and,
// crucially, WITHOUT find_by_email's anonymous-principal rejection. // crucially, WITHOUT find_by_email's anonymous-principal rejection.
// It returns only a boolean, so it leaks no more than `create`'s //
// own uniqueness error already does (F-S-003: enumeration risk is // SECURITY (F-S-003, enumeration): a single probe reveals only
// bounded to "exists / doesn't"). // "exists / doesn't" — the same bit a register form leaks via its
// "email already taken" path. But unlike `create`, this probe has
// no Argon2 cost and no side effect (a `create` miss writes a junk
// row), so it is a *cheaper, quieter* enumeration oracle when a
// script exposes it on an anonymous public route. It is NOT rate
// limited here, and there is no built-in per-route throttle/CAPTCHA
// primitive. Scripts that put this behind a public endpoint and
// care about enumeration must add their own `kv`-based counter.
// See docs/stdlib-reference.md.
self.require(cx.principal.as_ref(), Capability::AppUsersWrite(cx.app_id)) self.require(cx.principal.as_ref(), Capability::AppUsersWrite(cx.app_id))
.await?; .await?;
let normalized = validate_email(email)?; let normalized = validate_email(email)?;

View File

@@ -170,7 +170,12 @@ where
E: ExecutorClient + 'static, E: ExecutorClient + 'static,
R: ScriptResolver + 'static, R: ScriptResolver + 'static,
{ {
let method = request.method().as_str().to_string(); // Uppercased so `ctx.request.method` honors its documented contract
// (extension methods arrive in their original case). Route matching is
// case-insensitive (see `routing::matcher::method_matches`), so this
// only normalizes the value surfaced to scripts / persisted in the
// async-dispatch payload.
let method = request.method().as_str().to_ascii_uppercase();
let uri = request.uri().clone(); let uri = request.uri().clone();
let path = uri.path().to_string(); let path = uri.path().to_string();
let query_str = uri.query().unwrap_or("").to_string(); let query_str = uri.query().unwrap_or("").to_string();

View File

@@ -24,6 +24,7 @@ path = "tests/cli.rs"
[dependencies] [dependencies]
picloud-shared.workspace = true picloud-shared.workspace = true
reqwest = { workspace = true, features = ["json"] } reqwest = { workspace = true, features = ["json"] }
percent-encoding.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }

View File

@@ -9,6 +9,7 @@ use std::collections::BTreeMap;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use picloud_shared::{ use picloud_shared::{
AdminUserId, ApiKeyId, App, AppDomain, AppId, AppRole, AppUser, DispatchMode, ExecutionLog, AdminUserId, ApiKeyId, App, AppDomain, AppId, AppRole, AppUser, DispatchMode, ExecutionLog,
HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId,
@@ -19,6 +20,24 @@ use serde_json::Value;
use crate::config::Credentials; 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 { pub struct Client {
http: reqwest::Client, http: reqwest::Client,
url: String, url: String,
@@ -73,6 +92,7 @@ impl Client {
/// `GET /api/v1/admin/apps/{id_or_slug}` — slug or UUID accepted. /// `GET /api/v1/admin/apps/{id_or_slug}` — slug or UUID accepted.
pub async fn apps_get(&self, ident: &str) -> Result<AppLookupDto> { pub async fn apps_get(&self, ident: &str) -> Result<AppLookupDto> {
let ident = seg(ident);
let resp = self let resp = self
.request(Method::GET, &format!("/api/v1/admin/apps/{ident}")) .request(Method::GET, &format!("/api/v1/admin/apps/{ident}"))
.send() .send()
@@ -119,6 +139,7 @@ impl Client {
/// Server requires `AppAdmin` capability; without `force`, returns /// Server requires `AppAdmin` capability; without `force`, returns
/// 409 if the app still has scripts. /// 409 if the app still has scripts.
pub async fn apps_delete(&self, ident: &str, force: bool) -> Result<()> { pub async fn apps_delete(&self, ident: &str, force: bool) -> Result<()> {
let ident = seg(ident);
let path = if force { let path = if force {
format!("/api/v1/admin/apps/{ident}?force=true") format!("/api/v1/admin/apps/{ident}?force=true")
} else { } else {
@@ -131,6 +152,7 @@ impl Client {
/// `DELETE /api/v1/admin/scripts/{id}` — requires `AppAdmin` on the /// `DELETE /api/v1/admin/scripts/{id}` — requires `AppAdmin` on the
/// owning app (stricter than the edit endpoints, by design). /// owning app (stricter than the edit endpoints, by design).
pub async fn scripts_delete(&self, id: &str) -> Result<()> { pub async fn scripts_delete(&self, id: &str) -> Result<()> {
let id = seg(id);
let resp = self let resp = self
.request(Method::DELETE, &format!("/api/v1/admin/scripts/{id}")) .request(Method::DELETE, &format!("/api/v1/admin/scripts/{id}"))
.send() .send()
@@ -151,6 +173,7 @@ impl Client {
/// `PUT /api/v1/admin/scripts/{id}` — matches the dashboard, which /// `PUT /api/v1/admin/scripts/{id}` — matches the dashboard, which
/// uses PUT despite the field-level update semantics. /// uses PUT despite the field-level update semantics.
pub async fn scripts_update_source(&self, id: &str, source: &str) -> Result<Script> { pub async fn scripts_update_source(&self, id: &str, source: &str) -> Result<Script> {
let id = seg(id);
let body = UpdateScriptBody { source }; let body = UpdateScriptBody { source };
let resp = self let resp = self
.request(Method::PUT, &format!("/api/v1/admin/scripts/{id}")) .request(Method::PUT, &format!("/api/v1/admin/scripts/{id}"))
@@ -169,6 +192,7 @@ impl Client {
body: Value, body: Value,
headers: &[(String, String)], headers: &[(String, String)],
) -> Result<ExecuteResponse> { ) -> Result<ExecuteResponse> {
let id = seg(id);
let mut req = self let mut req = self
.request(Method::POST, &format!("/api/v1/execute/{id}")) .request(Method::POST, &format!("/api/v1/execute/{id}"))
.json(&body); .json(&body);
@@ -199,6 +223,7 @@ impl Client {
/// `GET /api/v1/admin/scripts/{id}/logs?limit=N` /// `GET /api/v1/admin/scripts/{id}/logs?limit=N`
pub async fn logs_list(&self, script_id: &str, limit: u32) -> Result<Vec<ExecutionLog>> { pub async fn logs_list(&self, script_id: &str, limit: u32) -> Result<Vec<ExecutionLog>> {
let script_id = seg(script_id);
let resp = self let resp = self
.request( .request(
Method::GET, Method::GET,
@@ -244,6 +269,7 @@ impl Client {
/// `DELETE /api/v1/admin/api-keys/{id}` — 404 covers both "doesn't /// `DELETE /api/v1/admin/api-keys/{id}` — 404 covers both "doesn't
/// exist" and "not yours" (server flattens to avoid enumeration). /// exist" and "not yours" (server flattens to avoid enumeration).
pub async fn apikeys_delete(&self, id: &str) -> Result<()> { pub async fn apikeys_delete(&self, id: &str) -> Result<()> {
let id = seg(id);
let resp = self let resp = self
.request(Method::DELETE, &format!("/api/v1/admin/api-keys/{id}")) .request(Method::DELETE, &format!("/api/v1/admin/api-keys/{id}"))
.send() .send()
@@ -253,6 +279,7 @@ impl Client {
/// `GET /api/v1/admin/scripts/{id}/routes` /// `GET /api/v1/admin/scripts/{id}/routes`
pub async fn routes_list_for_script(&self, script_id: &str) -> Result<Vec<Route>> { pub async fn routes_list_for_script(&self, script_id: &str) -> Result<Vec<Route>> {
let script_id = seg(script_id);
let resp = self let resp = self
.request( .request(
Method::GET, Method::GET,
@@ -269,6 +296,7 @@ impl Client {
script_id: &str, script_id: &str,
body: &CreateRouteBody<'_>, body: &CreateRouteBody<'_>,
) -> Result<Route> { ) -> Result<Route> {
let script_id = seg(script_id);
let resp = self let resp = self
.request( .request(
Method::POST, Method::POST,
@@ -282,6 +310,7 @@ impl Client {
/// `DELETE /api/v1/admin/routes/{route_id}` /// `DELETE /api/v1/admin/routes/{route_id}`
pub async fn routes_delete(&self, route_id: &str) -> Result<()> { pub async fn routes_delete(&self, route_id: &str) -> Result<()> {
let route_id = seg(route_id);
let resp = self let resp = self
.request(Method::DELETE, &format!("/api/v1/admin/routes/{route_id}")) .request(Method::DELETE, &format!("/api/v1/admin/routes/{route_id}"))
.send() .send()
@@ -322,6 +351,7 @@ impl Client {
/// `GET /api/v1/admin/admins/{id}` /// `GET /api/v1/admin/admins/{id}`
pub async fn admins_get(&self, id: &str) -> Result<AdminDto> { pub async fn admins_get(&self, id: &str) -> Result<AdminDto> {
let id = seg(id);
let resp = self let resp = self
.request(Method::GET, &format!("/api/v1/admin/admins/{id}")) .request(Method::GET, &format!("/api/v1/admin/admins/{id}"))
.send() .send()
@@ -341,6 +371,7 @@ impl Client {
/// `PATCH /api/v1/admin/admins/{id}` /// `PATCH /api/v1/admin/admins/{id}`
pub async fn admins_patch(&self, id: &str, body: &PatchAdminBody<'_>) -> Result<AdminDto> { pub async fn admins_patch(&self, id: &str, body: &PatchAdminBody<'_>) -> Result<AdminDto> {
let id = seg(id);
let resp = self let resp = self
.request(Method::PATCH, &format!("/api/v1/admin/admins/{id}")) .request(Method::PATCH, &format!("/api/v1/admin/admins/{id}"))
.json(body) .json(body)
@@ -351,6 +382,7 @@ impl Client {
/// `DELETE /api/v1/admin/admins/{id}` /// `DELETE /api/v1/admin/admins/{id}`
pub async fn admins_delete(&self, id: &str) -> Result<()> { pub async fn admins_delete(&self, id: &str) -> Result<()> {
let id = seg(id);
let resp = self let resp = self
.request(Method::DELETE, &format!("/api/v1/admin/admins/{id}")) .request(Method::DELETE, &format!("/api/v1/admin/admins/{id}"))
.send() .send()
@@ -360,6 +392,7 @@ impl Client {
/// `GET /api/v1/admin/apps/{id}/triggers` /// `GET /api/v1/admin/apps/{id}/triggers`
pub async fn triggers_list(&self, app: &str) -> Result<TriggerListDto> { pub async fn triggers_list(&self, app: &str) -> Result<TriggerListDto> {
let app = seg(app);
let resp = self let resp = self
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/triggers")) .request(Method::GET, &format!("/api/v1/admin/apps/{app}/triggers"))
.send() .send()
@@ -369,6 +402,7 @@ impl Client {
/// `DELETE /api/v1/admin/apps/{id}/triggers/{trigger_id}` /// `DELETE /api/v1/admin/apps/{id}/triggers/{trigger_id}`
pub async fn triggers_delete(&self, app: &str, trigger_id: &str) -> Result<()> { pub async fn triggers_delete(&self, app: &str, trigger_id: &str) -> Result<()> {
let (app, trigger_id) = (seg(app), seg(trigger_id));
let resp = self let resp = self
.request( .request(
Method::DELETE, Method::DELETE,
@@ -388,6 +422,7 @@ impl Client {
kind: &str, kind: &str,
body: &serde_json::Value, body: &serde_json::Value,
) -> Result<TriggerDto> { ) -> Result<TriggerDto> {
let (app, kind) = (seg(app), seg(kind));
let resp = self let resp = self
.request( .request(
Method::POST, Method::POST,
@@ -401,6 +436,7 @@ impl Client {
/// `GET /api/v1/admin/apps/{id}/dead_letters/count` /// `GET /api/v1/admin/apps/{id}/dead_letters/count`
pub async fn dead_letters_count(&self, app: &str) -> Result<DeadLetterCountDto> { pub async fn dead_letters_count(&self, app: &str) -> Result<DeadLetterCountDto> {
let app = seg(app);
let resp = self let resp = self
.request( .request(
Method::GET, Method::GET,
@@ -418,6 +454,7 @@ impl Client {
unresolved: bool, unresolved: bool,
limit: u32, limit: u32,
) -> Result<DeadLetterListDto> { ) -> Result<DeadLetterListDto> {
let app = seg(app);
let path = let path =
format!("/api/v1/admin/apps/{app}/dead_letters?unresolved={unresolved}&limit={limit}"); format!("/api/v1/admin/apps/{app}/dead_letters?unresolved={unresolved}&limit={limit}");
let resp = self.request(Method::GET, &path).send().await?; 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}` /// `GET /api/v1/admin/apps/{id}/dead_letters/{dl_id}`
pub async fn dead_letters_get(&self, app: &str, dl_id: &str) -> Result<DeadLetterDto> { 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 let resp = self
.request( .request(
Method::GET, Method::GET,
@@ -438,6 +476,7 @@ impl Client {
/// `POST /api/v1/admin/apps/{id}/dead_letters/{dl_id}/replay` /// `POST /api/v1/admin/apps/{id}/dead_letters/{dl_id}/replay`
pub async fn dead_letters_replay(&self, app: &str, dl_id: &str) -> Result<()> { 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 let resp = self
.request( .request(
Method::POST, Method::POST,
@@ -450,6 +489,7 @@ impl Client {
/// `POST /api/v1/admin/apps/{id}/dead_letters/{dl_id}/resolve` /// `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<()> { 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 let resp = self
.request( .request(
Method::POST, Method::POST,
@@ -463,6 +503,7 @@ impl Client {
/// `GET /api/v1/admin/apps/{id}/secrets` /// `GET /api/v1/admin/apps/{id}/secrets`
pub async fn secrets_list(&self, app: &str) -> Result<SecretListDto> { pub async fn secrets_list(&self, app: &str) -> Result<SecretListDto> {
let app = seg(app);
let resp = self let resp = self
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/secrets")) .request(Method::GET, &format!("/api/v1/admin/apps/{app}/secrets"))
.send() .send()
@@ -472,6 +513,8 @@ impl Client {
/// `POST /api/v1/admin/apps/{id}/secrets` /// `POST /api/v1/admin/apps/{id}/secrets`
pub async fn secrets_set(&self, app: &str, name: &str, value: serde_json::Value) -> Result<()> { 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 let resp = self
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/secrets")) .request(Method::POST, &format!("/api/v1/admin/apps/{app}/secrets"))
.json(&serde_json::json!({ "name": name, "value": value })) .json(&serde_json::json!({ "name": name, "value": value }))
@@ -482,6 +525,7 @@ impl Client {
/// `DELETE /api/v1/admin/apps/{id}/secrets/{name}` /// `DELETE /api/v1/admin/apps/{id}/secrets/{name}`
pub async fn secrets_delete(&self, app: &str, name: &str) -> Result<()> { pub async fn secrets_delete(&self, app: &str, name: &str) -> Result<()> {
let (app, name) = (seg(app), seg(name));
let resp = self let resp = self
.request( .request(
Method::DELETE, Method::DELETE,
@@ -496,6 +540,7 @@ impl Client {
/// `GET /api/v1/admin/apps/{id_or_slug}/domains` /// `GET /api/v1/admin/apps/{id_or_slug}/domains`
pub async fn domains_list(&self, app: &str) -> Result<Vec<AppDomain>> { pub async fn domains_list(&self, app: &str) -> Result<Vec<AppDomain>> {
let app = seg(app);
let resp = self let resp = self
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/domains")) .request(Method::GET, &format!("/api/v1/admin/apps/{app}/domains"))
.send() .send()
@@ -505,6 +550,7 @@ impl Client {
/// `POST /api/v1/admin/apps/{id_or_slug}/domains` /// `POST /api/v1/admin/apps/{id_or_slug}/domains`
pub async fn domains_create(&self, app: &str, pattern: &str) -> Result<AppDomain> { pub async fn domains_create(&self, app: &str, pattern: &str) -> Result<AppDomain> {
let app = seg(app);
let resp = self let resp = self
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/domains")) .request(Method::POST, &format!("/api/v1/admin/apps/{app}/domains"))
.json(&serde_json::json!({ "pattern": pattern })) .json(&serde_json::json!({ "pattern": pattern }))
@@ -515,6 +561,7 @@ impl Client {
/// `DELETE /api/v1/admin/apps/{id_or_slug}/domains/{domain_id}` /// `DELETE /api/v1/admin/apps/{id_or_slug}/domains/{domain_id}`
pub async fn domains_delete(&self, app: &str, domain_id: &str) -> Result<()> { pub async fn domains_delete(&self, app: &str, domain_id: &str) -> Result<()> {
let (app, domain_id) = (seg(app), seg(domain_id));
let resp = self let resp = self
.request( .request(
Method::DELETE, Method::DELETE,
@@ -529,6 +576,7 @@ impl Client {
/// `GET /api/v1/admin/apps/{id_or_slug}/topics` /// `GET /api/v1/admin/apps/{id_or_slug}/topics`
pub async fn topics_list(&self, app: &str) -> Result<Vec<TopicDto>> { pub async fn topics_list(&self, app: &str) -> Result<Vec<TopicDto>> {
let app = seg(app);
let resp = self let resp = self
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/topics")) .request(Method::GET, &format!("/api/v1/admin/apps/{app}/topics"))
.send() .send()
@@ -545,6 +593,7 @@ impl Client {
external_subscribable: bool, external_subscribable: bool,
auth_mode: &str, auth_mode: &str,
) -> Result<TopicDto> { ) -> Result<TopicDto> {
let app = seg(app);
let resp = self let resp = self
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/topics")) .request(Method::POST, &format!("/api/v1/admin/apps/{app}/topics"))
.json(&serde_json::json!({ .json(&serde_json::json!({
@@ -573,6 +622,7 @@ impl Client {
if let Some(m) = auth_mode { if let Some(m) = auth_mode {
body.insert("auth_mode".into(), Value::String(m.to_string())); body.insert("auth_mode".into(), Value::String(m.to_string()));
} }
let (app, name) = (seg(app), seg(name));
let resp = self let resp = self
.request( .request(
Method::PATCH, Method::PATCH,
@@ -586,6 +636,7 @@ impl Client {
/// `DELETE /api/v1/admin/apps/{id_or_slug}/topics/{name}` /// `DELETE /api/v1/admin/apps/{id_or_slug}/topics/{name}`
pub async fn topics_delete(&self, app: &str, name: &str) -> Result<()> { pub async fn topics_delete(&self, app: &str, name: &str) -> Result<()> {
let (app, name) = (seg(app), seg(name));
let resp = self let resp = self
.request( .request(
Method::DELETE, Method::DELETE,
@@ -600,6 +651,7 @@ impl Client {
/// `GET /api/v1/admin/apps/{id_or_slug}/users?limit={n}` /// `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>> { pub async fn app_users_list(&self, app: &str, limit: u32) -> Result<Vec<AppUser>> {
let app = seg(app);
let resp = self let resp = self
.request( .request(
Method::GET, Method::GET,
@@ -613,6 +665,7 @@ impl Client {
/// `GET /api/v1/admin/apps/{id_or_slug}/users/{user_id}` /// `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> { 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 let resp = self
.request( .request(
Method::GET, Method::GET,
@@ -629,6 +682,7 @@ impl Client {
app: &str, app: &str,
user_id: &str, user_id: &str,
) -> Result<ResetPasswordResponseDto> { ) -> Result<ResetPasswordResponseDto> {
let (app, user_id) = (seg(app), seg(user_id));
let resp = self let resp = self
.request( .request(
Method::POST, Method::POST,
@@ -645,6 +699,7 @@ impl Client {
app: &str, app: &str,
user_id: &str, user_id: &str,
) -> Result<RevokeSessionsResponseDto> { ) -> Result<RevokeSessionsResponseDto> {
let (app, user_id) = (seg(app), seg(user_id));
let resp = self let resp = self
.request( .request(
Method::POST, Method::POST,
@@ -1096,4 +1151,20 @@ mod tests {
let c = Client::new("http://localhost:8000/", "pic_x").unwrap(); let c = Client::new("http://localhost:8000/", "pic_x").unwrap();
assert_eq!(c.url(), "http://localhost:8000"); 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"
);
}
} }

View File

@@ -47,6 +47,10 @@ pub struct NewHttpOutbox {
pub struct HttpDispatchPayload { pub struct HttpDispatchPayload {
pub script_name: String, pub script_name: String,
pub path: String, pub path: String,
/// HTTP method (uppercased). `#[serde(default)]` so async-HTTP outbox
/// rows enqueued before this field existed still decode (as `""`,
/// matching non-HTTP triggers) instead of dead-lettering on upgrade.
#[serde(default)]
pub method: String, pub method: String,
pub headers: std::collections::BTreeMap<String, String>, pub headers: std::collections::BTreeMap<String, String>,
pub body: serde_json::Value, pub body: serde_json::Value,
@@ -70,3 +74,30 @@ pub enum OutboxWriterError {
#[error("outbox write failed: {0}")] #[error("outbox write failed: {0}")]
Backend(String), Backend(String),
} }
#[cfg(test)]
mod tests {
use super::*;
/// Async-HTTP outbox rows enqueued before `method` existed must still
/// decode after upgrade (it's `#[serde(default)]`), not dead-letter on
/// a "missing field `method`" error. Regression guard for that field.
#[test]
fn http_payload_decodes_without_method_field() {
let legacy = serde_json::json!({
"script_name": "todos",
"path": "/todos",
// no "method" key — pre-upgrade row
"headers": {},
"body": null,
"params": {},
"query": {},
"rest": "",
"timeout_seconds": 30
});
let payload: HttpDispatchPayload =
serde_json::from_value(legacy).expect("legacy payload must decode");
assert_eq!(payload.method, "");
assert_eq!(payload.path, "/todos");
}
}

View File

@@ -207,6 +207,13 @@ pub trait UsersService: Send + Sync {
/// this leaks only a single boolean — no id, profile, or timing /// this leaks only a single boolean — no id, profile, or timing
/// distinction beyond "exists / doesn't" — so a public registration /// distinction beyond "exists / doesn't" — so a public registration
/// script can pre-check before calling `create`. /// script can pre-check before calling `create`.
///
/// Enumeration note: that bit is the same one a register form already
/// leaks ("email taken"), but this probe is cheaper (no password hash),
/// has no side effect (a `create` miss writes a junk row), and is not
/// rate limited (there is no built-in throttle/CAPTCHA primitive). A
/// script exposing it on an anonymous route inherits account-enumeration
/// risk and must add its own `kv`-based throttle if that matters.
async fn email_available(&self, cx: &SdkCallCx, email: &str) -> Result<bool, UsersError>; async fn email_available(&self, cx: &SdkCallCx, email: &str) -> Result<bool, UsersError>;
async fn update( async fn update(

View File

@@ -255,6 +255,31 @@ Always include `statusCode` when you mean to set headers or a specific
body shape. Returning a bare value (`42`, `#{ ok: true }`) is fine — it body shape. Returning a bare value (`42`, `#{ ok: true }`) is fine — it
becomes a `200` body as-is. becomes a `200` body as-is.
### Security note — `users::email_available` and account enumeration
`users::email_available(email) -> bool` is the anonymous-safe way to
pre-check an email during self-serve registration (`users::find_by_email`
is forbidden for anonymous public scripts to prevent enumeration). The
probe returns only `true`/`false` — the same bit a register form already
leaks via "email already taken" (and via `create`'s own uniqueness error),
so it adds no *new* enumeration vector — but it is **cheap (no password
hash), has no side effect, and is not rate limited**, which makes bulk
probing faster than going through `create`.
There is no built-in per-route throttle or CAPTCHA primitive today (only
the global `PICLOUD_MAX_CONCURRENT_EXECUTIONS` cap). If account enumeration
matters for your app, the available mitigation is a `kv`-based counter
keyed on the client (e.g. an IP/email bucket you increment and check) —
the same pattern you'd use for any custom rate limit:
```rhai
// Pre-check, then create. Gate behind your own kv counter if enumeration
// is a concern — the platform does not rate-limit this probe for you.
if !users::email_available(email) {
return #{ statusCode: 409, body: #{ error: "email already registered" } };
}
```
## What's not here ## What's not here
- **Crypto** (sha256/hmac/argon2/encryption) — deferred to a focused - **Crypto** (sha256/hmac/argon2/encryption) — deferred to a focused