fix(stage-1): audit High-severity backend correctness + CI unblocker
Closes 4 High-severity audit findings:
- describe_event broken for HTTP-async payloads: was reading source/op
JSON fields that HttpDispatchPayload doesn't carry, producing empty
source on DL rows. from_wire("") then fell back to Kv on replay, the
dispatcher tried to resolve a non-existent trigger, and replay no-op'd
silently. Now branches on OutboxSourceKind: HTTP rows emit
"<method> <path>" + source="http"; Invoke rows emit "invoke_async" +
source="invoke"; TriggerEvent payloads keep top-level field extraction.
- HTTP outbound Authorization leaked across cross-origin redirects.
Manual redirect loop (Policy::none) reused header_map on every hop
and only scrubbed Content-Type for POST->GET. Now compares
url::Url::origin() across hops and strips Authorization,
Proxy-Authorization, Cookie when origin changes — matching reqwest's
default policy.
- F-S-010 inbound email HMAC had no replay protection. Signature was
computed over body only with no timestamp or nonce, so captured POSTs
were replayable indefinitely. Adds X-Picloud-Timestamp header bound
into the HMAC input (signed string is ts || "." || body), 300s
tolerance window, and a process-local LRU keyed on
(ts, sha256(body)) with 600s TTL to catch within-window replays.
Breaking change: webhook senders must include the timestamp header.
- expected_schema.txt re-blessed for migrations 0036-0039 (the previous
snapshot stopped at 0035 so CI would have gone red on first run).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,19 +18,20 @@ use picloud_manager_core::{
|
||||
ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState,
|
||||
AppRepository, AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, Dispatcher,
|
||||
DocsServiceImpl, EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig,
|
||||
FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl, KvServiceImpl, OutboxEventEmitter,
|
||||
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
||||
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo,
|
||||
PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo,
|
||||
PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo,
|
||||
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
|
||||
PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo,
|
||||
PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo,
|
||||
PostgresTriggerRepo, PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver,
|
||||
RouteAdminState, RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig,
|
||||
SecretsServiceImpl, SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig,
|
||||
TriggerRepo, TriggersState, UsersServiceConfig, UsersServiceImpl,
|
||||
FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl, InboundNonceDedup, KvServiceImpl,
|
||||
OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository,
|
||||
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
|
||||
PostgresAppMembersRepository, PostgresAppRepository, PostgresAppSecretsRepo,
|
||||
PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository,
|
||||
PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo,
|
||||
PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo,
|
||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo,
|
||||
PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo,
|
||||
PostgresTopicRepo, PostgresTriggerRepo, PrincipalResolver, PubsubServiceImpl,
|
||||
RealtimeAuthorityImpl, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
||||
ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig,
|
||||
TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState, UsersServiceConfig,
|
||||
UsersServiceImpl,
|
||||
};
|
||||
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
|
||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||
@@ -453,6 +454,7 @@ pub async fn build_app(
|
||||
triggers: trigger_repo,
|
||||
outbox: outbox_repo.clone(),
|
||||
master_key: master_key.clone(),
|
||||
nonce_dedup: Arc::new(InboundNonceDedup::new()),
|
||||
};
|
||||
let dead_letters_state = DeadLettersState {
|
||||
repo: dl_repo,
|
||||
|
||||
@@ -112,12 +112,18 @@ async fn create_email_trigger(
|
||||
created["id"].as_str().expect("trigger id").to_string()
|
||||
}
|
||||
|
||||
fn sign(secret: &str, body: &str) -> String {
|
||||
fn sign(secret: &str, ts: i64, body: &str) -> String {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("hmac key");
|
||||
mac.update(ts.to_string().as_bytes());
|
||||
mac.update(b".");
|
||||
mac.update(body.as_bytes());
|
||||
hex::encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
fn now_ts() -> i64 {
|
||||
chrono::Utc::now().timestamp()
|
||||
}
|
||||
|
||||
async fn poll_marker(pool: &PgPool, app_id: &str) -> Option<Value> {
|
||||
let app_uuid = Uuid::parse_str(app_id).expect("app uuid");
|
||||
for _ in 0..100 {
|
||||
@@ -148,10 +154,12 @@ async fn signed_post_accepts_and_fires_handler() {
|
||||
let handler = create_script(&server, &app_id, "eml-handler", MARKER_HANDLER).await;
|
||||
let trigger = create_email_trigger(&server, &app_id, &handler, Some("topsecret")).await;
|
||||
|
||||
let sig = sign("topsecret", BODY);
|
||||
let ts = now_ts();
|
||||
let sig = sign("topsecret", ts, BODY);
|
||||
server
|
||||
.post(&format!("/api/v1/email-inbound/{app_id}/{trigger}"))
|
||||
.add_header("x-picloud-signature", sig)
|
||||
.add_header("x-picloud-timestamp", ts.to_string())
|
||||
.text(BODY)
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::ACCEPTED);
|
||||
@@ -197,9 +205,11 @@ async fn wrong_signature_is_401() {
|
||||
let handler = create_script(&server, &app_id, "h", MARKER_HANDLER).await;
|
||||
let trigger = create_email_trigger(&server, &app_id, &handler, Some("topsecret")).await;
|
||||
|
||||
let ts = now_ts();
|
||||
server
|
||||
.post(&format!("/api/v1/email-inbound/{app_id}/{trigger}"))
|
||||
.add_header("x-picloud-signature", sign("WRONG", BODY))
|
||||
.add_header("x-picloud-signature", sign("WRONG", ts, BODY))
|
||||
.add_header("x-picloud-timestamp", ts.to_string())
|
||||
.text(BODY)
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::UNAUTHORIZED);
|
||||
|
||||
Reference in New Issue
Block a user