@@ -53,6 +53,7 @@ fn normalize_url(url_str: &str) -> Option<String> {
5353
5454 // Require a valid host for http(s) URLs
5555 let host = url. host_str ( ) ?;
56+ let port = url. port ( ) ;
5657 let path = url. path ( ) ;
5758
5859 // Normalize trailing slashes for consistency
@@ -62,7 +63,11 @@ fn normalize_url(url_str: &str) -> Option<String> {
6263 path. trim_end_matches ( '/' )
6364 } ;
6465
65- Some ( format ! ( "{}://{}{}" , scheme, host, normalized_path) )
66+ if let Some ( p) = port {
67+ Some ( format ! ( "{}://{}:{}{}" , scheme, host, p, normalized_path) )
68+ } else {
69+ Some ( format ! ( "{}://{}{}" , scheme, host, normalized_path) )
70+ }
6671}
6772
6873/// Validates CSRF protection by checking Origin/Referer headers against expected frontend URL.
@@ -398,21 +403,47 @@ pub async fn get_project(
398403
399404 // 4. Dual-layer security for embeds (VIP key + Guest List)
400405 if !is_owner {
401- // VIP Pass: compare URL ?key with stored embed_key using a match on Options
402- let vip_allowed = match ( params. get ( "key" ) , embed_key. as_ref ( ) ) {
403- ( Some ( request_key) , Some ( stored_key) ) if request_key == stored_key => true ,
404- _ => false ,
406+ // VIP Pass: compare URL ?key with stored embed_key
407+ // Note: Axum's Query extractor already URL-decodes parameters
408+ let vip_allowed = match ( params. get ( "key" ) , embed_key. as_ref ( ) ) {
409+ ( Some ( request_key) , Some ( stored_key) ) => {
410+ // Axum already decodes query params; treat spaces as '+' to handle
411+ // unencoded base64 keys in query strings.
412+ let trimmed_request = request_key. trim ( ) ;
413+ let normalized_request = trimmed_request. replace ( ' ' , "+" ) ;
414+ let trimmed_stored = stored_key. trim ( ) ;
415+
416+ let matches = trimmed_request == trimmed_stored
417+ || normalized_request == trimmed_stored;
418+
419+ tracing:: debug!(
420+ "VIP key comparison for project {}: request='{}' normalized='{}' stored='{}' match={}" ,
421+ project_id,
422+ trimmed_request,
423+ normalized_request,
424+ trimmed_stored,
425+ matches
426+ ) ;
427+
428+ matches
429+ }
430+ _ => {
431+ tracing:: debug!( "VIP key check failed for project {}: request_key={:?} stored_key={:?}" ,
432+ project_id, params. get( "key" ) , embed_key. as_ref( ) ) ;
433+ false
434+ }
405435 } ;
406436
407437 if !vip_allowed {
408- // Guest List: validate Referer header against project_whitelists
438+ // Guest List: validate parent page URL against project_whitelists.
439+ // Prefer X-Embed-Referer (forwarded by embed page) and fallback to Referer.
409440 let referer = headers
410- . get ( "X-Embed-Referer " )
441+ . get ( "x-embed-referer " )
411442 . and_then ( |v| v. to_str ( ) . ok ( ) )
412443 . map ( |s| s. to_string ( ) )
413444 . or_else ( || {
414445 headers
415- . get ( "Referer " )
446+ . get ( "referer " )
416447 . and_then ( |v| v. to_str ( ) . ok ( ) )
417448 . map ( |s| s. to_string ( ) )
418449 } ) ;
@@ -421,7 +452,7 @@ pub async fn get_project(
421452 let whitelist_allowed: Option < bool > = if let Some ( referer_url) = referer {
422453 // Normalize the Referer URL to prevent bypasses via query params or fragments
423454 let normalized_referer = normalize_url ( & referer_url) ;
424-
455+
425456 if let Some ( normalized) = normalized_referer {
426457 let exists_row: Option < ( bool , ) > = sqlx:: query_as (
427458 "SELECT TRUE FROM project_whitelists \
@@ -870,16 +901,16 @@ pub async fn resolve_secret_embed(
870901 State ( state) : State < AppState > ,
871902 Path ( token) : Path < String > ,
872903) -> Result < Html < String > , ( StatusCode , String ) > {
873- // 1. Validate Token from DB
874- let row: Option < ( String , String ) > = sqlx:: query_as (
875- "SELECT owner_username, slug FROM projects WHERE embed_token = $1"
904+ // 1. Validate Token from DB and fetch embed_key for VIP access
905+ let row: Option < ( String , String , Option < String > ) > = sqlx:: query_as (
906+ "SELECT owner_username, slug, embed_key FROM projects WHERE embed_token = $1"
876907 )
877908 . bind ( & token)
878909 . fetch_optional ( & state. db )
879910 . await
880911 . map_err ( |e| ( StatusCode :: INTERNAL_SERVER_ERROR , e. to_string ( ) ) ) ?;
881912
882- let ( owner, slug) = row. ok_or ( ( StatusCode :: NOT_FOUND , "Invalid embed token" . to_string ( ) ) ) ?;
913+ let ( owner, slug, embed_key ) = row. ok_or ( ( StatusCode :: NOT_FOUND , "Invalid embed token" . to_string ( ) ) ) ?;
883914
884915 // 2. Get Configurations
885916 let api_url = std:: env:: var ( "API_URL" ) . unwrap_or ( "http://localhost:3000" . to_string ( ) ) ;
@@ -889,8 +920,14 @@ pub async fn resolve_secret_embed(
889920 // 3. Generate Discovery URL for OEmbed (Points to Backend)
890921 let oembed_url = format ! ( "{}/api/oembed?url={}/e/{}" , api_url, api_url, token) ;
891922
892- // 4. Construct Target URL (Points to Frontend)
893- let target_url = format ! ( "{}/embed/{}/{}" , frontend_url, owner, slug) ;
923+ // 4. Construct Target URL with VIP key for bypass (Points to Frontend)
924+ let target_url = if let Some ( key) = embed_key {
925+ // URL encode the key to handle special characters in base64
926+ let encoded_key = urlencoding:: encode ( & key) ;
927+ format ! ( "{}/embed/{}/{}?key={}" , frontend_url, owner, slug, encoded_key)
928+ } else {
929+ format ! ( "{}/embed/{}/{}" , frontend_url, owner, slug)
930+ } ;
894931
895932 // 5. Return HTML with <link> tags + Meta Refresh
896933 let html = format ! ( r#"<!DOCTYPE html>
0 commit comments