13

I am developing a multiplayer game with Firebase. Player score is recorded in firebase after each game, and also a playerTotalScore field is updated with the new total. My question: Is it possible to secure playerTotalScore field against arbitrary manipulation by the user using only firebase security rules? If so, how?

I have perused firebase security information on the firebase website at length. While I understand that it is possible to implement some complex logic in the security rules (increment a number by a given amount such as this gist , or make field insert-only ( ".write": "!data.exists()" ), none of the information seems to help in this case. Increment-only rules will not be sufficient because the score can be manipulated by being incremented multiple times. Insert-only does appear to be an option for totalScore, because that is updated after each game.

Update

As requested by Kato, here is the specific use case.

The game I am developing is a quiz game in which players answer questions, and the players scores are displayed in real time.

During the course of the game, the score for that specific game is updated after each question by the following statement:

gameRef.child('players').child(UserId).child('score').set(gameScore)

After the game is over, the totalScore (all games played) for the player is calculated as totalScore=totalScore+gameScore and then the players total score is updated in Firebase using the following statement:

leaderboardRef.child(userId).setWithPriority({userName:userName, totalScore:totalScore}, totalScore)

Update2: Data Structure as requested by Kato

Here is the specific structure I currently have in place. This is not set in stone so I am open to changing it howsoever needed per the recommended approach to secure the data.

The score for each game played by a user(player) is stored in the following structure

<firebase_root>/app/games/<gameId>/players/<userId>/score/

<gameId> is the firebase generated key as a result of calling firebase push() method. <UserId> is the firebase simplelogin uid.

The totalScore (sum of all scores for all games played) for each user(player) is stored in the following data structure

<firebase_root>/app/leaderboard/<userId>/totalScore/

leaderboard data for totalScore is set using the totalScore as priority, for query purposes

leaderboardRef.child(userId).setWithPriority({userName:userName, totalScore:totalScore}, totalScore)

Both score and totalScore are numeric integer values. That is all the detail to the current data structure that I can think of.

Community
  • 1
  • 1
Jarnal
  • 2,138
  • 2
  • 26
  • 43
  • where is the update logic done? in user app? – webduvet Oct 08 '14 at 09:29
  • It should be perfectly practical to do with security rules, but since you're talking about complex rules for complex validation, it would be impossible to guess your specific needs. Please provide a detailed, specific use case you'd like to resolve as a starting point. – Kato Oct 08 '14 at 16:48
  • @Kato, here is the specific use case: The game I am developing is a quiz game in which players answer questions, and the players scores are displayed in real time. During the course of the game, the score for that specific game is updated after each question by the following statement gameRef.child('playerid').child('score').set(gameScore). After the game is over, the totalScore (all games played) for the player is calculated as totalScore=totalScore+gameScore and then the players total score is updated in Firebase using the following statement playerRef.child('totalScore').set(totalScore). – Jarnal Oct 08 '14 at 19:36
  • @lombausch, this is a Firebase app, so yes, the updated logic is in the client side user app. – Jarnal Oct 08 '14 at 19:39
  • There's still not enough detail here to help you write effective security rules. Burning 30 minutes to an hour on a wild guess could very well be futile. If you want help designing security rules, the desired and structure will need to be exact and detailed. – Kato Oct 09 '14 at 04:18
  • @Kato, fair enough. I have provided a second update that lists all the detail about the data structure as it currently stands. I am flexible with the structure so if a different structure suits the desired security objective, then we can change it. This is all the detail on the data structure that I can think of. – Jarnal Oct 09 '14 at 06:33

3 Answers3

13

Your question is technically how to complete this using security rules, but as it's a bit of an XY problem, and none of the other possibilities have been ruled out, I'll tackle some of them here as well.

I'll be making a great deal of assumptions, since answering this question actually requires a fully specified set of rules that need to be followed and is really a matter of implementing an entire application (increasing a score is a result of the game logic rules, not a simple math problem).

Total the score at the client

Perhaps the simplest answer to this conundrum is to simply not have a total score. Just grab the list of players and total them manually.

When this might be useful:

  • the list of players is hundreds or less
  • the player data is appropriately small (not 500k each)

How to do it:

var ref = new Firebase(URL);
function getTotalScore(gameId, callback) {
   ref.child('app/games/' + gameId + '/players').once('value', function(playerListSnap) {
      var total = 0;
      playerListSnap.forEach(function(playerSnap) {
         var data = playerSnap.val();
         total += data.totalScore || 0;
      });
      callback(gameId, total);
   });
}

Use a privileged worker to update the score

A very sophisticated and also simple approach (because it only requires that the security rules be set to something like ".write": "auth.uid === 'SERVER_PROCESS'") would be to use a server process that simply monitors the games and accumulates the totals. This is probably the simplest solution to get right and the easiest to maintain, but has the downside of requiring another working part.

When this might be useful:

  • you can spin up a Heroku service or deploy a .js file to webscript.io
  • an extra monthly subscription in the $5-$30 range are not a deal-breaker

How to do it:

Obviously, this involves a great deal of application design and there are various levels this has to be accomplished at. Let's focus simply on closing games and tallying the leaderboards, since this is a good example.

Begin by splitting the scoring code out to its own path, such as

/scores_entries/$gameid/$scoreid = < player: ..., score: ... >
/game_scores/$gameid/$playerid = <integer>

Now monitor the games to see when they close:

var rootRef = new Firebase(URL);
var gamesRef = rootRef.child('app/games');
var lbRef = rootRef.child('leaderboards');

gamesRef.on('child_added', watchGame);
gamesRef.child('app/games').on('child_remove', unwatchGame);

function watchGame(snap) {
    snap.ref().child('status').on('value', gameStatusChanged);
}

function unwatchGame(snap) {
    snap.ref().child('status').off('value', gameStatusChanged);
}

function gameStatusChanged(snap) {
    if( snap.val() === 'CLOSED' ) {
        unwatchGame(snap);
        calculateScores(snap.name());
    }
}

function calculateScores(gameId) {
    gamesRef.child(gameId).child('users').once('value', function(snap) {
        var userScores = {};
        snap.forEach(function(ss) {
            var score = ss.val() || 0;
            userScores[ss.name()] = score;
        });
        updateLeaderboards(userScores);
    });
}

function updateLeaderboards(userScores) {
    for(var userId in userScores) {
        var score = userScores[userId];
        lbRef.child(userId).transaction(function(currentValue) {
            return (currentValue||0) + score;
        });
    }
}

Use an audit path and security rules

This will, of course, be the most sophisticated and difficult of the available choices.

When this might be useful:

  • when we refuse to utilize any other strategy involving a server process
  • when dreadfully worried about players cheating
  • when we have lots of extra time to burn

Obviously, I'm biased against this approach. Primarily because it's very difficult to get right and requires a lot of energy that could be replaced with a small monetary investment.

Getting this right requires scrutiny at each individual write request. There are several obvious points to secure (probably more):

  1. Writing any game event that includes a score increment
  2. Writing the total for the game per user
  3. Writing the game's total to the leaderboard
  4. Writing each audit record
  5. Ensuring superfluous games can't be created and modified on the fly just to boost scores

Here are some basic fundamentals to securing each of these points:

  • use an audit trail where users can only add (not update or remove) entries
  • validate that each audit entry has a priority equal to the current timestamp
  • validate that each audit entry contains valid data according to the current game state
  • utilize the audit entries when trying to increment running totals

Let's take, for an example, updating the leaderboard securely. We'll assume the following:

  • the users' score in the game is valid
  • the user has created an audit entry to, say, leaderboard_audit/$userid/$gameid, with a current timestamp as the priority and the score as the value
  • each user record exists in the leaderboard ahead of time
  • only the user may update their own score

So here's our assumed data structure:

/games/$gameid/users/$userid/score
/leaderboard_audit/$userid/$gameid/score
/leaderboard/$userid = { last_game: $gameid, score: <int> }

Here's how our logic works:

  1. game score is set at /games/$gameid/users/$userid/score
  2. an audit record is created at /leaderboard_audit/$userid/games_played/$gameid
  3. the value at /leaderboard_audit/$userid/last_game is updated to match $gameid
  4. the leaderboard is updated by an amount exactly equal to last_game's audit record

And here's the actual rules:

{
    "rules": {
        "leaderboard_audit": {
            "$userid": {
                "$gameid": {
                   // newData.exists() ensures records cannot be deleted
                    ".write": "auth.uid === $userid && newData.exists()",

                    ".validate": "
                        // can only create new records
                        !data.exists()
                        // references a valid game
                        && root.child('games/' + $gameid).exists()
                        // has the correct score as the value
                        && newData.val() === root.child('games/' + $gameid + '/users/' + auth.uid + '/score').val()
                        // has a priority equal to the current timestamp
                        && newData.getPriority() === now
                        // is created after the previous last_game or there isn't a last_game
                        (
                            !root.child('leaderboard/' + auth.uid + '/last_game').exists() || 
                            newData.getPriority() > data.parent().child(root.child('leaderboard/' + auth.uid + '/last_game').val()).getPriority()
                        )

                    "
                }
            }
        },
        "leaderboard": {
            "$userid": {
                ".write": "auth.uid === $userid && newData.exists()",
                ".validate": "newData.hasChildren(['last_game', 'score'])",
                "last_game": {
                    ".validate": "
                        // must match the last_game entry
                        newData.val() === root.child('leaderboard_audit/' + auth.uid + '/last_game').val()
                        // must not be a duplicate
                        newData.val() !== data.val()
                        // must be a game created after the current last_game timestamp
                        (
                            !data.exists() ||
                            root.child('leaderboard_audit/' + auth.uid + '/' + data.val()).getPriority() 
                            < root.child('leaderboard_audit/' + auth.uid + '/' + newData.val()).getPriority()
                        )
                    "
                },
                "score": {
                    ".validate": "
                        // new score is equal to the old score plus the last_game's score
                        newData.val() === data.val() + 
                        root.child('games/' + newData.parent().child('last_game').val() + '/users/' + auth.uid + '/score').val()
                    "
                }
            }
        }
    }
}
Community
  • 1
  • 1
Kato
  • 40,352
  • 6
  • 119
  • 149
  • 4
    Wow... impressive answer. Keep in mind that you might run a privileged worker as a regular web client too. All it requires is a fixed/known-to-be-trusted account (such as your own) and security rules that filter on that. The server-based approach sounds more robust for sure, but a separate score-aggregator.html that you load periodically on your own machine/account might just as well do the trick. – Frank van Puffelen Oct 10 '14 at 15:10
  • 1
    Great point, @FrankvanPuffelen; the privileged worker is typically a script run on a remote service, but I've run some production services on a laptop at my house while migrating code; Firebase turns everything into a client and makes this a breeze. – Kato Oct 10 '14 at 16:09
  • @Kato, thanks for your detailed answer! I appreciate it as I am sure other readers will as well. Some further thoughts/questions: For the privileged worker option, what are the ramifications for high-availability and scalability? Would multiple privileged workers be possible? Would they cause interference? Also, once a privileged worker is up and running, how likely is it that the listener gets "dropped" eventually over a long period or would it require monitoring/reboots? – Jarnal Oct 11 '14 at 04:07
  • @FrankvanPuffelen, you bring up an interesting thought, it would be especially useful for for initial testing. – Jarnal Oct 11 '14 at 04:13
  • To be honest: I think you're suffering from a case of premature optimization here. If you're not beyond initial testing stages, I would not yet be worried about HA/scalability, nor worry about cheaters beyond the approaches already suggested in the answers. Both cheaters and scale can be handled, now go build a game and get the scale and cheaters. If you get either (or get stuck somewhere else while building), post a new question. – Frank van Puffelen Oct 11 '14 at 14:50
  • @Frank, IMHO, coming from years with enterprise architecture, HA/scalability is best handled 'prematurely' before going live, not after, when things become very difficult to change. This is even more true in the case of games, where there is a tendency either to have a hit with huge popularity very very quickly (rare) or lukewarm duds (often). One has to plan for a home run, because if/when one does have a popular game, things can move too quickly after going live to change easily. – Jarnal Oct 12 '14 at 00:25
  • But in the development sense, @Jarnal, it's best to write the code and see how it performs before adding additional cycles and complexity for optimization that may not be necessary. This should take place well before production release, but after you have become intimate with the tools (which you aren't at this point) and have discovered strengths and weaknesses of the product. So Frank's point is perfectly valid and applicable. – Kato Oct 12 '14 at 20:54
  • @Kato :) what you say (code then tweak) applies to *performance tuning* as distinct from *solution architecture* which is the result of *planning and evaluation*, in most cases well before the the rest of the development cycle. My follow-up question is about HA which is core architecture and depends on product/platform evaluation part of which is asking capability related questions of the platform provider, especially as it pertains to a *recommended* approach. I continue to be appreciative of your inputs. Perhaps HA deserves a separate question of its own, so I will post it separately. – Jarnal Oct 14 '14 at 21:49
  • All valid thoughts. I don't think there's any disagreement here. I'll keep an eye out for the follow ups. If they don't seem to fit well in the SO structure, feel free to move discussion to the firebase-talk mailing list. – Kato Oct 15 '14 at 03:19
4

It will be tricky to guard against invalid values using rules. Since you're giving the user rights to write a value, they can also reverse-engineer your code and write values that you'd rather not see. You can do many things to make the hacker's job more difficult, but there'll always be someone who is able to work around it. That said: there are some easy things you can do to make things for hackers a bit less trivial.

Something you can easily do is record/store enough information about the gameplay so that you can later determine if it is legit.

So for example in a typing game I did, I not only stored the final score for the player, but also each key they pressed and when they pressed it.

https://<my>.firebaseio.com/highscores/game_1_time_15/puf
  keystrokes: "[[747,'e'],[827,'i'],[971,'t'],[1036,'h']...[14880,'e']]"
  score: 61

So at 747ms into the game, I typed an e then i, t, h and so on, until finally after 14.8s I pressed e.

Using these values I can check if the keys pressed indeed lead to a score of 61. I could also replay the game, or do some analysis on it to see if it seems like a real human playing pressing the keys. If the timestamps are 100, 200, 300, etc, you'd be quite suspicious (although I created some bots that type exactly at such intervals).

It's still no guarantee of course, but it's a least a first stumbling block for the ref.child('score').set(10000000) hackers.

I got this idea from John Resig's Deap Leap, but I can't find the page where he describes it.

Frank van Puffelen
  • 565,676
  • 79
  • 828
  • 807
  • 1
    Some great ideas here Frank. I would disagree (in most cases) that this is impossible to use security rules, as write and validate rules can store some powerful expressions and even the audit trail you've suggested can be used as part of the rules. Not that this minor nit-pick should detract from the quality of this answer. – Kato Oct 08 '14 at 16:50
  • I said "tricky", not "impossible". ;-) But I was indeed wondering how much of this type of validation you could translation into Firebase's validation rules. In my defense: Firebase had no security model when I wrote this game. :-) – Frank van Puffelen Oct 08 '14 at 16:53
  • Frank, thanks for your input and thoughts on this. My thinking is similarly inclined. I am eager to see what @Kato and all the other good folks over at Firebase come up with as the recommended approach. – Jarnal Oct 08 '14 at 19:47
4

I have an idea. - since this is a multiplayer game you are going to have multiple players in one particular game. this means each of the players after the game over message is going to update the partial and total score.

In security rules you can check if the opponent has written the partial value regarding the same game. - thats would be read only access. Or you can check if all opponents partial values gives the required total number etc.

Hacker would have to come up with some elaborate plan involving control of multiple accounts and synchronising the attack.

edit: ...and I can see the further question - What about the first player to update? That could be done via intents. So first all the players write an intent to write score where the partial score will be and once there are some values everywhere they will be clear to write the actual score.

webduvet
  • 4,220
  • 2
  • 28
  • 39