max / makenotwork
5 files changed,
+71 insertions,
-40 deletions
| @@ -85,17 +85,17 @@ impl GrantedScopes { | |||
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | 87 | /// True when this is a full-sync request rather than the read-only userinfo | |
| 88 | - | /// RP flow. A request is "sync" if it explicitly carries the `sync` scope, | |
| 89 | - | /// or — for backward compatibility with desktop clients that predate the | |
| 90 | - | /// explicit scope — if it carries no recognized scope at all. | |
| 88 | + | /// RP flow. A request is "sync" **only** if it explicitly carries the `sync` | |
| 89 | + | /// scope. | |
| 91 | 90 | /// | |
| 92 | - | /// The empty-scope path is deprecated: it exists only so already-shipped | |
| 93 | - | /// clients keep working during the transition. Once the first-party clients | |
| 94 | - | /// (GoingsOn, AudioFiles, mnw-cli) send `scope=sync`, the empty path should | |
| 95 | - | /// be rejected outright so a userinfo client that merely forgets its `scope` | |
| 96 | - | /// param can no longer be silently escalated to a sync-capable token. | |
| 91 | + | /// An empty / unrecognized scope set is NOT sync: it takes the least- | |
| 92 | + | /// privilege userinfo path. This closes the escalation where a relying party | |
| 93 | + | /// that merely omitted `scope` was silently granted the broadest 7-day | |
| 94 | + | /// sync-API token (audit Run 17 Security). First-party clients now send | |
| 95 | + | /// `scope=sync` via `synckit-client`'s authorize URL; a client that predates | |
| 96 | + | /// that build and sends no scope gets a userinfo token, not sync. | |
| 97 | 97 | pub fn is_sync_request(&self) -> bool { | |
| 98 | - | self.0.is_empty() || self.contains(OAuthScope::Sync) | |
| 98 | + | self.contains(OAuthScope::Sync) | |
| 99 | 99 | } | |
| 100 | 100 | ||
| 101 | 101 | /// True if every scope in `self` is also in `other`. The downgrade-only | |
| @@ -188,12 +188,16 @@ mod tests { | |||
| 188 | 188 | ||
| 189 | 189 | #[test] | |
| 190 | 190 | fn sync_request_detection() { | |
| 191 | - | // Explicit opt-in and the deprecated empty scope both mint a sync token. | |
| 191 | + | // Only the explicit `sync` scope mints a sync token. | |
| 192 | 192 | assert!(GrantedScopes::parse("sync").is_sync_request()); | |
| 193 | - | assert!(GrantedScopes::parse("").is_sync_request()); | |
| 194 | - | assert!(GrantedScopes::parse(" ").is_sync_request()); | |
| 195 | - | // A userinfo request (which is what a client that *forgot* nothing sends) | |
| 196 | - | // must NOT be treated as a sync request — this is the escalation guard. | |
| 193 | + | // Empty / whitespace-only scope is NO LONGER treated as sync — it takes | |
| 194 | + | // the least-privilege userinfo path. This is the Run 17 escalation fix: | |
| 195 | + | // a client that omits `scope` can no longer be upgraded to a sync token. | |
| 196 | + | assert!(!GrantedScopes::parse("").is_sync_request()); | |
| 197 | + | assert!(!GrantedScopes::parse(" ").is_sync_request()); | |
| 198 | + | // Unrecognized-only scopes parse to empty → also not sync. | |
| 199 | + | assert!(!GrantedScopes::parse("admin:everything").is_sync_request()); | |
| 200 | + | // A userinfo request must NOT be treated as a sync request. | |
| 197 | 201 | assert!(!GrantedScopes::parse("profile:read").is_sync_request()); | |
| 198 | 202 | assert!(!GrantedScopes::parse("profile:read perks:read").is_sync_request()); | |
| 199 | 203 | // `sync` round-trips through the canonical string form. |
| @@ -42,12 +42,26 @@ fn validate_targets(targets: &[String]) -> Result<()> { | |||
| 42 | 42 | Ok(()) | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | - | /// Validate build_command and artifact_path for shell safety. | |
| 46 | - | fn validate_build_config_fields(build_command: &str, artifact_path: &str) -> Result<()> { | |
| 45 | + | /// Validate build_command, artifact_path, and signing_key_path for shell + | |
| 46 | + | /// traversal safety. `signing_key_path` is optional (empty = unsigned); when | |
| 47 | + | /// set it names a path the build runner will read a signing key from, so it | |
| 48 | + | /// must pass the same relative-path/no-`..`/no-metacharacter check as | |
| 49 | + | /// `artifact_path` — otherwise a stored `../` or absolute path becomes a | |
| 50 | + | /// cross-tenant key-read foothold the moment the runner wires up signing | |
| 51 | + | /// (audit Run 17 Security: the one build-config field that bypassed validation). | |
| 52 | + | fn validate_build_config_fields( | |
| 53 | + | build_command: &str, | |
| 54 | + | artifact_path: &str, | |
| 55 | + | signing_key_path: &str, | |
| 56 | + | ) -> Result<()> { | |
| 47 | 57 | crate::build_runner::validate_build_command(build_command) | |
| 48 | 58 | .map_err(|e| AppError::validation(format!("build_command: {e}")))?; | |
| 49 | 59 | crate::build_runner::validate_artifact_path(artifact_path) | |
| 50 | 60 | .map_err(|e| AppError::validation(format!("artifact_path: {e}")))?; | |
| 61 | + | if !signing_key_path.is_empty() { | |
| 62 | + | crate::build_runner::validate_artifact_path(signing_key_path) | |
| 63 | + | .map_err(|e| AppError::validation(format!("signing_key_path: {e}")))?; | |
| 64 | + | } | |
| 51 | 65 | Ok(()) | |
| 52 | 66 | } | |
| 53 | 67 | ||
| @@ -206,7 +220,7 @@ async fn create_config( | |||
| 206 | 220 | ) -> Result<impl IntoResponse> { | |
| 207 | 221 | verify_app_owner(&state, &sync_user, app_id).await?; | |
| 208 | 222 | validate_targets(&req.targets)?; | |
| 209 | - | validate_build_config_fields(&req.build_command, &req.artifact_path)?; | |
| 223 | + | validate_build_config_fields(&req.build_command, &req.artifact_path, &req.signing_key_path)?; | |
| 210 | 224 | ||
| 211 | 225 | // Verify repo ownership | |
| 212 | 226 | let repo = db::git_repos::get_repo_by_id(&state.db, req.repo_id) | |
| @@ -260,7 +274,7 @@ async fn update_config( | |||
| 260 | 274 | ) -> Result<impl IntoResponse> { | |
| 261 | 275 | verify_app_owner(&state, &sync_user, app_id).await?; | |
| 262 | 276 | validate_targets(&req.targets)?; | |
| 263 | - | validate_build_config_fields(&req.build_command, &req.artifact_path)?; | |
| 277 | + | validate_build_config_fields(&req.build_command, &req.artifact_path, &req.signing_key_path)?; | |
| 264 | 278 | ||
| 265 | 279 | let existing = db::builds::get_build_config_by_app(&state.db, app_id) | |
| 266 | 280 | .await? |
| @@ -719,21 +719,11 @@ async fn token_authorization_code( | |||
| 719 | 719 | ||
| 720 | 720 | let scope = GrantedScopes::parse(&oauth_code.scope); | |
| 721 | 721 | ||
| 722 | - | // A sync request (explicit `scope=sync`, or — deprecated — no scope at all) | |
| 723 | - | // mints the full 7-day sync token. This is the only path that issues a | |
| 724 | - | // sync-API-capable token from /oauth/token. | |
| 722 | + | // Only an explicit `scope=sync` mints the full 7-day sync token — the sole | |
| 723 | + | // path that issues a sync-API-capable token from /oauth/token. An omitted or | |
| 724 | + | // unrecognized scope now falls through to the least-privilege userinfo path | |
| 725 | + | // below rather than being escalated to sync (audit Run 17 Security). | |
| 725 | 726 | if scope.is_sync_request() { | |
| 726 | - | // The empty-scope path is a backward-compatibility bridge only: a | |
| 727 | - | // userinfo client that merely forgets its `scope` param lands here and | |
| 728 | - | // is silently upgraded to a sync-capable token. Log every use so the | |
| 729 | - | // remaining empty-scope clients can be identified and migrated to | |
| 730 | - | // `scope=sync`, after which this branch should reject empty scope. | |
| 731 | - | if scope.is_empty() { | |
| 732 | - | tracing::warn!( | |
| 733 | - | app_id = %oauth_code.app_id, | |
| 734 | - | "oauth: full-sync token issued for empty scope (deprecated; client should send scope=sync)" | |
| 735 | - | ); | |
| 736 | - | } | |
| 737 | 727 | let token = synckit_auth::create_sync_token( | |
| 738 | 728 | secret, | |
| 739 | 729 | oauth_code.user_id, |
| @@ -94,7 +94,7 @@ async fn authorize( | |||
| 94 | 94 | let resp = h | |
| 95 | 95 | .client | |
| 96 | 96 | .get(&format!( | |
| 97 | - | "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256", | |
| 97 | + | "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope=sync", | |
| 98 | 98 | urlencoding::encode(client_id), | |
| 99 | 99 | urlencoding::encode(redirect_uri), | |
| 100 | 100 | state_param, | |
| @@ -112,7 +112,7 @@ async fn authorize( | |||
| 112 | 112 | ||
| 113 | 113 | // POST credentials (CSRF goes in form body as _csrf) | |
| 114 | 114 | let body = format!( | |
| 115 | - | "client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&login={}&password={}&_csrf={}", | |
| 115 | + | "client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope=sync&login={}&password={}&_csrf={}", | |
| 116 | 116 | urlencoding::encode(client_id), | |
| 117 | 117 | urlencoding::encode(redirect_uri), | |
| 118 | 118 | state_param, | |
| @@ -171,6 +171,13 @@ async fn oauth_full_flow() { | |||
| 171 | 171 | assert!(token.expires_in > 0); | |
| 172 | 172 | assert_eq!(token.user_id, user_id); | |
| 173 | 173 | assert_eq!(token.app_id, app_id); | |
| 174 | + | ||
| 175 | + | // `scope=sync` (sent by the authorize helper, mirroring synckit-client) mints | |
| 176 | + | // a sync-capable token that authenticates the sync API. | |
| 177 | + | h.client.set_bearer_token(&token.access_token); | |
| 178 | + | let resp = h.client.get("/api/v1/sync/status").await; | |
| 179 | + | assert_ne!(resp.status.as_u16(), 401, "scope=sync token should authenticate the sync API"); | |
| 180 | + | h.client.clear_bearer_token(); | |
| 174 | 181 | } | |
| 175 | 182 | ||
| 176 | 183 | #[tokio::test] | |
| @@ -655,18 +662,26 @@ async fn oauth_userinfo_requires_perks_scope() { | |||
| 655 | 662 | } | |
| 656 | 663 | ||
| 657 | 664 | #[tokio::test] | |
| 658 | - | async fn oauth_no_scope_yields_sync_token_without_refresh() { | |
| 659 | - | // The legacy/desktop path: omitting scope mints a full sync token (works on | |
| 660 | - | // the sync API) and no refresh token. | |
| 665 | + | async fn oauth_no_scope_yields_userinfo_token_not_sync() { | |
| 666 | + | // Run 17 security flip: omitting `scope` now mints a LEAST-PRIVILEGE userinfo | |
| 667 | + | // token, not a sync token. A relying party that merely forgot its `scope` | |
| 668 | + | // param can no longer be silently escalated to the 7-day full-sync token; a | |
| 669 | + | // client that wants sync must send `scope=sync` explicitly (synckit-client | |
| 670 | + | // does). No scope also means no refresh token (no offline_access). | |
| 661 | 671 | let mut h = TestHarness::new().await; | |
| 662 | 672 | h.signup("legacyuser", "legacy@test.com", "Password1!").await; | |
| 663 | 673 | let token = obtain_scoped_token(&mut h, "legacyuser", "Password1!", "").await; | |
| 664 | 674 | assert!(token.refresh_token.is_none(), "no scope => no refresh token"); | |
| 665 | - | assert_eq!(token.scope, ""); | |
| 666 | 675 | ||
| 676 | + | // The omitted-scope token must NOT authenticate the sync API — the escalation | |
| 677 | + | // guard. (Contrast oauth_full_flow, which sends scope=sync and succeeds.) | |
| 667 | 678 | h.client.set_bearer_token(&token.access_token); | |
| 668 | 679 | let resp = h.client.get("/api/v1/sync/status").await; | |
| 669 | - | assert_ne!(resp.status.as_u16(), 401, "legacy sync token should authenticate the sync API"); | |
| 680 | + | assert_eq!( | |
| 681 | + | resp.status.as_u16(), | |
| 682 | + | 401, | |
| 683 | + | "an omitted-scope token must NOT authenticate the sync API" | |
| 684 | + | ); | |
| 670 | 685 | } | |
| 671 | 686 | ||
| 672 | 687 | #[tokio::test] |
| @@ -118,6 +118,11 @@ impl SyncKitClient { | |||
| 118 | 118 | /// | |
| 119 | 119 | /// The caller is responsible for generating the PKCE verifier/challenge, | |
| 120 | 120 | /// starting the localhost callback server, and opening the browser. | |
| 121 | + | /// | |
| 122 | + | /// Sends `scope=sync` explicitly: this is the SyncKit pairing flow and the | |
| 123 | + | /// full sync-API token is what it needs. The server treats an omitted scope | |
| 124 | + | /// as least-privilege userinfo (not sync), so the scope must be sent for the | |
| 125 | + | /// token exchange to yield a sync-capable token. | |
| 121 | 126 | pub fn build_authorize_url( | |
| 122 | 127 | &self, | |
| 123 | 128 | redirect_port: u16, | |
| @@ -125,7 +130,7 @@ impl SyncKitClient { | |||
| 125 | 130 | code_challenge: &str, | |
| 126 | 131 | ) -> String { | |
| 127 | 132 | format!( | |
| 128 | - | "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256", | |
| 133 | + | "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope=sync", | |
| 129 | 134 | self.config.server_url, | |
| 130 | 135 | urlencoding::encode(&self.config.api_key), | |
| 131 | 136 | urlencoding::encode(&format!("http://127.0.0.1:{}/", redirect_port)), | |
| @@ -269,6 +274,9 @@ mod tests { | |||
| 269 | 274 | assert!(url.contains("state=random-state")); | |
| 270 | 275 | assert!(url.contains("code_challenge=challenge123")); | |
| 271 | 276 | assert!(url.contains("code_challenge_method=S256")); | |
| 277 | + | // The pairing flow must request the sync scope explicitly — the server | |
| 278 | + | // treats an omitted scope as least-privilege userinfo, not sync. | |
| 279 | + | assert!(url.contains("scope=sync")); | |
| 272 | 280 | } | |
| 273 | 281 | ||
| 274 | 282 | #[test] |