diff --git a/Cargo.lock b/Cargo.lock index 768c4d22f5..4462fcbc24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4573,6 +4573,7 @@ dependencies = [ "universaldb", "universalpubsub", "url", + "urlencoding", "uuid", ] @@ -6753,6 +6754,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" diff --git a/Cargo.toml b/Cargo.toml index 9bbcfc153c..5f82a7a7ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -227,6 +227,9 @@ features = ["full","extra-traits"] version = "2.5.4" features = ["serde"] +[workspace.dependencies.urlencoding] +version = "2.1" + [workspace.dependencies.uuid] version = "1.11.0" features = ["v4","serde"] diff --git a/engine/packages/guard/Cargo.toml b/engine/packages/guard/Cargo.toml index 7da5a574f6..69692a15e4 100644 --- a/engine/packages/guard/Cargo.toml +++ b/engine/packages/guard/Cargo.toml @@ -48,6 +48,7 @@ tracing.workspace = true universaldb.workspace = true universalpubsub.workspace = true url.workspace = true +urlencoding.workspace = true uuid.workspace = true [dev-dependencies] diff --git a/engine/packages/guard/src/routing/mod.rs b/engine/packages/guard/src/routing/mod.rs index 35aaef552c..fb3db0ba32 100644 --- a/engine/packages/guard/src/routing/mod.rs +++ b/engine/packages/guard/src/routing/mod.rs @@ -208,9 +208,15 @@ pub fn parse_actor_path(path: &str) -> Option { return None; } - (aid.to_string(), Some(tok.to_string())) + // URL-decode both actor_id and token + let decoded_aid = urlencoding::decode(aid).ok()?.to_string(); + let decoded_tok = urlencoding::decode(tok).ok()?.to_string(); + + (decoded_aid, Some(decoded_tok)) } else { - (actor_id_segment.to_string(), None) + // URL-decode actor_id + let decoded_aid = urlencoding::decode(actor_id_segment).ok()?.to_string(); + (decoded_aid, None) }; // Calculate the position in the original path where remaining path starts diff --git a/engine/packages/guard/tests/parse_actor_path.rs b/engine/packages/guard/tests/parse_actor_path.rs index 802f502a8d..c3c527c0d0 100644 --- a/engine/packages/guard/tests/parse_actor_path.rs +++ b/engine/packages/guard/tests/parse_actor_path.rs @@ -107,7 +107,7 @@ fn test_parse_actor_path_special_characters() { #[test] fn test_parse_actor_path_encoded_characters() { - // URL encoded characters in path + // URL encoded characters in remaining path let path = "/gateway/actor-123/api%20endpoint/test%2Fpath"; let result = parse_actor_path(path).unwrap(); assert_eq!(result.actor_id, "actor-123"); @@ -115,6 +115,46 @@ fn test_parse_actor_path_encoded_characters() { assert_eq!(result.remaining_path, "/api%20endpoint/test%2Fpath"); } +#[test] +fn test_parse_actor_path_encoded_actor_id() { + // URL encoded characters in actor_id (e.g., actor-123 with hyphen encoded) + let path = "/gateway/actor%2D123/endpoint"; + let result = parse_actor_path(path).unwrap(); + assert_eq!(result.actor_id, "actor-123"); + assert_eq!(result.token, None); + assert_eq!(result.remaining_path, "/endpoint"); +} + +#[test] +fn test_parse_actor_path_encoded_token() { + // URL encoded characters in token (e.g., @ symbol encoded in token) + let path = "/gateway/actor-123@tok%40en/endpoint"; + let result = parse_actor_path(path).unwrap(); + assert_eq!(result.actor_id, "actor-123"); + assert_eq!(result.token, Some("tok@en".to_string())); + assert_eq!(result.remaining_path, "/endpoint"); +} + +#[test] +fn test_parse_actor_path_encoded_actor_id_and_token() { + // URL encoded characters in both actor_id and token + let path = "/gateway/actor%2D123@token%2Dwith%2Dencoded/endpoint"; + let result = parse_actor_path(path).unwrap(); + assert_eq!(result.actor_id, "actor-123"); + assert_eq!(result.token, Some("token-with-encoded".to_string())); + assert_eq!(result.remaining_path, "/endpoint"); +} + +#[test] +fn test_parse_actor_path_encoded_spaces() { + // URL encoded spaces in actor_id + let path = "/gateway/actor%20with%20spaces/endpoint"; + let result = parse_actor_path(path).unwrap(); + assert_eq!(result.actor_id, "actor with spaces"); + assert_eq!(result.token, None); + assert_eq!(result.remaining_path, "/endpoint"); +} + // Invalid path tests #[test] diff --git a/rivetkit-typescript/packages/rivetkit/src/manager/gateway.ts b/rivetkit-typescript/packages/rivetkit/src/manager/gateway.ts index 4cc1999add..a6be497a56 100644 --- a/rivetkit-typescript/packages/rivetkit/src/manager/gateway.ts +++ b/rivetkit-typescript/packages/rivetkit/src/manager/gateway.ts @@ -399,16 +399,31 @@ export function parseActorPath(path: string): ActorPathInfo | null { const atPos = actorSegment.indexOf("@"); if (atPos !== -1) { // Pattern: /gateway/{actor_id}@{token}/{...path} - actorId = actorSegment.slice(0, atPos); - token = actorSegment.slice(atPos + 1); + const rawActorId = actorSegment.slice(0, atPos); + const rawToken = actorSegment.slice(atPos + 1); // Check for empty actor_id or token - if (actorId.length === 0 || token.length === 0) { + if (rawActorId.length === 0 || rawToken.length === 0) { + return null; + } + + // URL-decode both actor_id and token + try { + actorId = decodeURIComponent(rawActorId); + token = decodeURIComponent(rawToken); + } catch (e) { + // Invalid URL encoding return null; } } else { // Pattern: /gateway/{actor_id}/{...path} - actorId = actorSegment; + // URL-decode actor_id + try { + actorId = decodeURIComponent(actorSegment); + } catch (e) { + // Invalid URL encoding + return null; + } token = undefined; } diff --git a/rivetkit-typescript/packages/rivetkit/tests/parse-actor-path.test.ts b/rivetkit-typescript/packages/rivetkit/tests/parse-actor-path.test.ts index 5ec8919e43..616dc3de67 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/parse-actor-path.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/parse-actor-path.test.ts @@ -178,6 +178,64 @@ describe("parseActorPath", () => { }); }); + describe("URL-encoded actor_id and token", () => { + test("should decode URL-encoded characters in actor_id", () => { + const path = "/gateway/actor%2D123/endpoint"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.actorId).toBe("actor-123"); + expect(result?.token).toBeUndefined(); + expect(result?.remainingPath).toBe("/endpoint"); + }); + + test("should decode URL-encoded characters in token", () => { + const path = "/gateway/actor-123@tok%40en/endpoint"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.actorId).toBe("actor-123"); + expect(result?.token).toBe("tok@en"); + expect(result?.remainingPath).toBe("/endpoint"); + }); + + test("should decode URL-encoded characters in both actor_id and token", () => { + const path = "/gateway/actor%2D123@token%2Dwith%2Dencoded/endpoint"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.actorId).toBe("actor-123"); + expect(result?.token).toBe("token-with-encoded"); + expect(result?.remainingPath).toBe("/endpoint"); + }); + + test("should decode URL-encoded spaces in actor_id", () => { + const path = "/gateway/actor%20with%20spaces/endpoint"; + const result = parseActorPath(path); + + expect(result).not.toBeNull(); + expect(result?.actorId).toBe("actor with spaces"); + expect(result?.token).toBeUndefined(); + expect(result?.remainingPath).toBe("/endpoint"); + }); + + test("should reject invalid URL encoding in actor_id", () => { + // %ZZ is invalid hex + const path = "/gateway/actor%ZZ123/endpoint"; + const result = parseActorPath(path); + + expect(result).toBeNull(); + }); + + test("should reject invalid URL encoding in token", () => { + // %GG is invalid hex + const path = "/gateway/actor-123@token%GG/endpoint"; + const result = parseActorPath(path); + + expect(result).toBeNull(); + }); + }); + describe("Invalid paths - wrong prefix", () => { test("should reject path with wrong prefix", () => { expect(parseActorPath("/api/123/endpoint")).toBeNull();