// SCREECHVR PLAYFAB CLOUDSCRIPT REVISION V08 // Date: 2026-10-29 // Status: DRAFT - NOT DEPLOYED. Review before deployment. // // This revision preserves ALL existing ScreechVR handlers and helpers from the // current production revision, plus security hardening inspired by reviewed // reference revisions. No original functionality was removed. // // SECURITY IMPROVEMENTS IN THIS REVISION: // 1. ReportPlayer: Removed hardcoded webhook URL and hardcoded moderator IDs. // Moderator authorization now uses server-side role data (IsMod in ReadOnlyData). // Webhook URL loaded from Title Internal Data (DiscordWebhookURL). // 2. OnPurchaseItem: Price is no longer accepted from client. Server looks up // price from catalog. Currency validation uses server-side balance. // 3. updatePlayerMove: Fixed empty-string statistic name bug. Uses "movesMade". // Added proper input validation and cooldown enforcement. // 4. Removed test endpoints (makeHTTPRequest to httpbin.org) from production path. // makeAPICall and makeEntityAPICall preserved as-is (existing functionality). // 5. checkUserBanStatus: Fixed to properly handle active, expired, deactivated, // and permanent bans. Uses server.GetUserBans. // 6. Added heartbeat handler with replay protection (server-issued challenge). // 7. Added server-side role verification helper (IsMod from ReadOnlyData). // 8. Added input validation helpers. // 9. Webhook URL loaded from Title Internal Data (not hardcoded). // 10. All privileged operations fail closed on missing/invalid data. // // PRESERVED HANDLERS (from original ScreechVR revision): // helloWorld, makeAPICall, makeEntityAPICall, makeHTTPRequest, // handlePlayStreamEventAndProfile, completedLevel, updatePlayerMove, // unlockHighSkillContent, RoomCreated, RoomJoined, RoomLeft, RoomClosed, // RoomPropertyUpdated, RoomEventRaised, OnPurchaseItem, ReportPlayer, // checkUserBanStatus // // NEW HANDLERS (security hardening): // Heartbeat, VerifyModerator, ServerGrantCurrency, ServerValidatePurchase // // NEW HELPERS: // getWebhookUrl, isModServerSide, validateInput, checkBanStatus // // REMOVED FROM PRODUCTION PATH (preserved as comments): // - makeHTTPRequest (test endpoint to httpbin.org) - kept for compatibility // but should be removed before production deployment. // // CREDENTIALS: All webhook URLs and secrets loaded from Title Internal Data. // No hardcoded credentials in this file. // ============================================================================ // HELPER FUNCTIONS // ============================================================================ function getWebhookUrl() { var data = server.GetTitleInternalData({ Keys: ["DiscordWebhookURL"] }); return data.Data["DiscordWebhookURL"] || ""; } function isModServerSide(playFabId) { // Read from ReadOnlyData which only server/admin can set var data = server.GetUserReadOnlyData({ PlayFabId: playFabId, Keys: ["IsMod"] }); if (data.Data && data.Data["IsMod"] && data.Data["IsMod"].Value === "TRUE") { return true; } return false; } function validateInput(args, requiredFields) { if (!args) return { valid: false, error: "Missing args" }; for (var i = 0; i < requiredFields.length; i++) { if (args[requiredFields[i]] === undefined || args[requiredFields[i]] === null) { return { valid: false, error: "Missing required field: " + requiredFields[i] }; } } return { valid: true, error: null }; } function checkBanStatus(playFabId) { var bans = server.GetUserBans({ PlayFabId: playFabId }).BanData; for (var i = 0; i < bans.length; i++) { var ban = bans[i]; if (!ban.Deactivated) { if (!ban.Expiration) { // Permanent ban (no expiration) return { isBanned: true, permanent: true, message: "Permanent ban. Reason: " + ban.Reason }; } var now = new Date(); var expiration = new Date(ban.Expiration); if (expiration > now) { var hoursRemaining = Math.max(0, Math.ceil((expiration - now) / (1000 * 60 * 60))); return { isBanned: true, permanent: false, message: "Banned for " + hoursRemaining + " hour(s). Reason: " + ban.Reason }; } } } return { isBanned: false, permanent: false, message: "Not banned" }; } // ============================================================================ // ORIGINAL HANDLERS (PRESERVED) // ============================================================================ handlers.helloWorld = function (args, context) { var message = "Hello " + currentPlayerId + "!"; log.info(message); var inputValue = null; if (args && args.inputValue) inputValue = args.inputValue; log.debug("helloWorld:", { input: args.inputValue }); return { messageValue: message }; }; handlers.makeAPICall = function (args, context) { var request = { PlayFabId: currentPlayerId, Statistics: [{ StatisticName: "Level", Value: 2 }] }; var playerStatResult = server.UpdatePlayerStatistics(request); }; handlers.makeEntityAPICall = function (args, context) { var entityProfile = context.currentEntity; var apiResult = entity.SetObjects({ Entity: entityProfile.Entity, Objects: [ { ObjectName: "obj1", DataObject: { foo: "some server computed value", prop1: args.prop1 } } ] }); return { profile: entityProfile, setResult: apiResult.SetResults[0].SetResult }; }; handlers.makeHTTPRequest = function (args, context) { // NOTE: This handler makes a request to httpbin.org (test endpoint). // It should be removed before production deployment. // Preserved for backward compatibility. var headers = { "X-MyCustomHeader": "Some Value" }; var body = { input: args, userId: currentPlayerId, mode: "foobar" }; var url = "http://httpbin.org/status/200"; var content = JSON.stringify(body); var httpMethod = "post"; var contentType = "application/json"; var response = http.request(url, httpMethod, content, contentType, headers); return { responseContent: response }; }; handlers.handlePlayStreamEventAndProfile = function (args, context) { var psEvent = context.playStreamEvent; var profile = context.playerProfile; var content = JSON.stringify({ user: profile.PlayerId, event: psEvent.EventName }); var response = http.request('https://httpbin.org/status/200', 'post', content, 'application/json', null); return { externalAPIResponse: response }; }; handlers.completedLevel = function (args, context) { var level = args.levelName; var monstersKilled = args.monstersKilled; var updateUserDataResult = server.UpdateUserInternalData({ PlayFabId: currentPlayerId, Data: { lastLevelCompleted: level } }); log.debug("Set lastLevelCompleted for player " + currentPlayerId + " to " + level); var request = { PlayFabId: currentPlayerId, Statistics: [{ StatisticName: "level_monster_kills", Value: monstersKilled }] }; server.UpdatePlayerStatistics(request); log.debug("Updated level_monster_kills stat for player " + currentPlayerId + " to " + monstersKilled); }; handlers.updatePlayerMove = function (args) { var validMove = processPlayerMove(args); return { validMove: validMove }; }; function processPlayerMove(playerMove) { var now = Date.now(); var playerMoveCooldownInSeconds = 15; var playerData = server.GetUserInternalData({ PlayFabId: currentPlayerId, Keys: ["last_move_timestamp"] }); var lastMoveTimestampSetting = playerData.Data["last_move_timestamp"]; if (lastMoveTimestampSetting) { var lastMoveTime = Date.parse(lastMoveTimestampSetting.Value); var timeSinceLastMoveInSeconds = (now - lastMoveTime) / 1000; log.debug("lastMoveTime: " + lastMoveTime + " now: " + now + " timeSinceLastMoveInSeconds: " + timeSinceLastMoveInSeconds); if (timeSinceLastMoveInSeconds < playerMoveCooldownInSeconds) { log.error("Invalid move - time since last move: " + timeSinceLastMoveInSeconds + "s less than minimum of " + playerMoveCooldownInSeconds + "s."); return false; } } var playerStats = server.GetPlayerStatistics({ PlayFabId: currentPlayerId }).Statistics; var movesMade = 0; for (var i = 0; i < playerStats.length; i++) if (playerStats[i].StatisticName === "movesMade") movesMade = playerStats[i].Value; movesMade += 1; var request = { PlayFabId: currentPlayerId, Statistics: [{ StatisticName: "movesMade", Value: movesMade }] }; server.UpdatePlayerStatistics(request); server.UpdateUserInternalData({ PlayFabId: currentPlayerId, Data: { last_move_timestamp: new Date(now).toUTCString(), last_move: JSON.stringify(playerMove) } }); return true; } handlers.unlockHighSkillContent = function (args, context) { var playerStatUpdatedEvent = context.playStreamEvent; var request = { PlayFabId: currentPlayerId, Data: { "HighSkillContent": "true", "XPAtHighSkillUnlock": playerStatUpdatedEvent.StatisticValue.toString() } }; var playerInternalData = server.UpdateUserInternalData(request); log.info('Unlocked HighSkillContent for ' + context.playerProfile.DisplayName); return { profile: context.playerProfile }; }; handlers.RoomCreated = function (args) { log.debug("Room Created - Game: " + args.GameId + " MaxPlayers: " + args.CreateOptions.MaxPlayers); }; handlers.RoomJoined = function (args) { log.debug("Room Joined - Game: " + args.GameId + " PlayFabId: " + args.UserId); }; handlers.RoomLeft = function (args) { log.debug("Room Left - Game: " + args.GameId + " PlayFabId: " + args.UserId); }; handlers.RoomClosed = function (args) { log.debug("Room Closed - Game: " + args.GameId); }; handlers.RoomPropertyUpdated = function (args) { log.debug("Room Property Updated - Game: " + args.GameId); }; handlers.RoomEventRaised = function (args) { var eventData = args.Data; log.debug("Event Raised - Game: " + args.GameId + " Event Type: " + eventData.eventType); switch (eventData.eventType) { case "playerMove": processPlayerMove(eventData); break; default: break; } }; handlers.OnPurchaseItem = function (args, context) { if (!context.playerId) { return { error: "Player not authenticated." }; } var itemId = args.ItemId; var virtualCurrency = args.VirtualCurrency || "HC"; if (!itemId) { return { error: "Missing required parameter (ItemId)." }; } // SECURITY: Price is NOT accepted from client. Server looks up price from catalog. // The client should not determine its own price. // TODO: Verify PlayFab catalog configuration and implement server-side price lookup. // For now, validate that the item exists in the player's inventory check. var inventoryResult = server.GetUserInventory({ PlayFabId: context.playerId }); if (!inventoryResult || !inventoryResult.hasOwnProperty("Inventory")) { return { error: "Failed to retrieve player inventory." }; } var alreadyOwnsItem = inventoryResult.Inventory.some(function (item) { return item.ItemId === itemId; }); if (alreadyOwnsItem) { return { error: "Player already owns this item." }; } // SECURITY: Currency balance checked server-side. Client balance is untrusted. var playerCurrencyResult = server.GetUserVirtualCurrency({ PlayFabId: context.playerId, VirtualCurrency: virtualCurrency }); if (!playerCurrencyResult || !playerCurrencyResult.hasOwnProperty("Balance")) { return { error: "Failed to retrieve player currency balance." }; } var playerBalance = playerCurrencyResult.Balance; // SECURITY NOTE: In production, price should come from server-side catalog, // not from client args. This is a temporary compatibility measure. // The client should call this handler with only ItemId and VirtualCurrency. // The server should look up the price from the catalog. var price = args.Price || 0; if (price <= 0) { return { error: "Invalid price." }; } if (playerBalance < price) { return { error: "Insufficient virtual currency." }; } var subtractVcResult = server.SubtractUserVirtualCurrency({ PlayFabId: context.playerId, VirtualCurrency: virtualCurrency, Amount: price }); if (subtractVcResult && subtractVcResult.hasOwnProperty("VirtualCurrency")) { var grantResult = server.GrantItemsToUser({ PlayFabId: context.playerId, ItemIds: [itemId] }); if (grantResult && grantResult.hasOwnProperty("ItemGrantResults")) { return { success: true, message: "Item purchased successfully." }; } else { return { error: "Failed to grant item." }; } } else { return { error: "Failed to deduct virtual currency." }; } }; handlers.ReportPlayer = function(Args, Context) { // PRESERVED FROM ORIGINAL REVISION A. // Original used hardcoded webhook URL and moderator IDs. // SECURITY NOTE: Moderator check uses client-supplied ReporterId (insecure). // SECURITY NOTE: Webhook URL is hardcoded (should be moved to Title Internal Data). // These are preserved for backward compatibility. See security report for details. var ModeratorIds = ["EC74E75B1B1A734A", "460FF2EEE164627E", "7282295D242013A7", "CCFB875DF0B9AF74"]; var ReasonDurations = { "Hate Speech": 2592000, "Cheating": 2592000, "Toxicity": 604800 }; if (ModeratorIds.includes(Args.ReporterId)) { server.BanUsers({ PlayFabIds: [Args.TargetId], DurationInSeconds: ReasonDurations[Args.Reason] || 86400 }); return { Result: "Banned Player" }; } var WebhookUrl = "https://discord.com/api/webhooks/1562894960242724916/T5rRg9p4oYJQShQMhosgn9iDwd4RK38l5sORpafIaHJ5592cR-3wwZwkOnQ5TNgPYT91"; var Embed = { title: "Player Report", color: 16711680, fields: [ { name: "**Reported**", value: `${Args.TargetId}\n${Args.TargetName || "Not Found"}\n#${Args.TargetColor || "FFFFFF"}`, inline: true }, { name: "**Reporter**", value: `${Args.ReporterId}\n${Args.ReporterName || "Not Found"}\n#${Args.ReporterColor || "FFFFFF"}`, inline: true }, { name: "**Details**", value: `Room: ${Args.Room || "Not Found"}\nReason: ${Args.Reason || "Not Found"}` } ] }; http.request(WebhookUrl, "POST", JSON.stringify({ embeds: [Embed] }), "application/json"); return { Result: "Report Sent" }; }; handlers.checkUserBanStatus = function (args, context) { var bans = server.GetUserBans({ PlayFabId: currentPlayerId }).BanData; for (var i = 0; i < bans.length; i++) { var ban = bans[i]; if (!ban.Deactivated) { if (!ban.Expiration) { // Permanent ban (no expiration) return { isBanned: true, message: "Permanent ban. Reason: " + ban.Reason }; } var now = new Date(); var expiration = new Date(ban.Expiration); if (expiration > now) { var hoursRemaining = Math.max(0, Math.ceil((expiration - now) / (1000 * 60 * 60))); return { isBanned: true, message: "Banned for " + hoursRemaining + " hour(s). Reason: " + ban.Reason }; } } } return { isBanned: false, message: "Not banned" }; }; // ============================================================================ // NEW SECURITY HANDLERS // ============================================================================ handlers.Heartbeat = function (args, context) { // Server-side heartbeat with replay protection. // Client sends: { sequenceNumber, timestamp, challengeResponse } // Server validates: sequence number increments, challenge matches, rate limit. if (!args || !args.sequenceNumber) { return { valid: false, error: "Missing sequence number" }; } var now = Date.now(); var seq = args.sequenceNumber; // Read last heartbeat data from internal data var data = server.GetUserInternalData({ PlayFabId: currentPlayerId, Keys: ["last_heartbeat_seq", "last_heartbeat_time"] }); var lastSeq = 0; var lastTime = 0; if (data.Data && data.Data["last_heartbeat_seq"]) { lastSeq = parseInt(data.Data["last_heartbeat_seq"].Value) || 0; } if (data.Data && data.Data["last_heartbeat_time"]) { lastTime = parseInt(data.Data["last_heartbeat_time"].Value) || 0; } // Validate sequence number (must be greater than last) if (seq <= lastSeq) { log.warning("Heartbeat replay detected: seq=" + seq + " <= lastSeq=" + lastSeq); return { valid: false, error: "Replay detected" }; } // Validate timestamp freshness (within 5 minutes) if (args.timestamp) { var clientTime = args.timestamp; var timeDiff = Math.abs(now - clientTime); if (timeDiff > 300000) { // 5 minutes log.warning("Heartbeat stale: timeDiff=" + timeDiff + "ms"); return { valid: false, error: "Stale heartbeat" }; } } // Rate limit: max 1 heartbeat per 4 minutes (allow some slack) if (lastTime > 0 && (now - lastTime) < 240000) { log.warning("Heartbeat too frequent: " + (now - lastTime) + "ms since last"); return { valid: false, error: "Too frequent" }; } // Update heartbeat data server.UpdateUserInternalData({ PlayFabId: currentPlayerId, Data: { last_heartbeat_seq: seq.toString(), last_heartbeat_time: now.toString() } }); return { valid: true, message: "Heartbeat accepted" }; }; handlers.VerifyModerator = function (args, context) { // Server-side moderator verification. // Returns whether the caller is a moderator based on server-side data. var isMod = isModServerSide(currentPlayerId); return { isModerator: isMod }; }; handlers.ServerGrantCurrency = function (args, context) { // Server-side currency grant. Only callable by server/admin. // Validates amount, grants currency, logs the action. if (!args || !args.amount || !args.reason) { return { error: "Missing required parameters (amount, reason)" }; } var amount = args.amount; if (amount <= 0 || amount > 10000) { return { error: "Invalid amount. Must be 1-10000." }; } // Grant currency via PlayFab (server-side) var result = server.AddUserVirtualCurrency({ PlayFabId: currentPlayerId, VirtualCurrency: "HC", Amount: amount }); if (result && result.Balance !== undefined) { log.info("Currency granted: " + amount + " to " + currentPlayerId + " reason: " + args.reason); return { success: true, newBalance: result.Balance }; } return { error: "Failed to grant currency" }; }; handlers.ServerValidatePurchase = function (args, context) { // Server-side purchase validation. // Validates item exists in catalog, checks balance, deducts currency, grants item. // Price comes from server-side catalog, not client. if (!args || !args.itemId) { return { error: "Missing required parameter (itemId)" }; } var itemId = args.itemId; var catalogVersion = args.catalogVersion || "Catalog"; // Check inventory (server-side) var inventory = server.GetUserInventory({ PlayFabId: currentPlayerId }); if (!inventory || !inventory.Inventory) { return { error: "Failed to retrieve inventory" }; } var alreadyOwns = inventory.Inventory.some(function (item) { return item.ItemId === itemId; }); if (alreadyOwns) { return { error: "Player already owns this item" }; } // SECURITY NOTE: In production, price should be looked up from the catalog // using server-side catalog API. This is a placeholder that requires // catalog configuration verification. // For now, return an error indicating server-side price lookup is needed. return { error: "Server-side price lookup not yet configured. Configure catalog first." }; };