Firebase安全规则中的速率限制?

47

我启动了我的第一个开源仓库项目EphChat,人们迅速开始涌入请求。

Firebase有没有在安全规则中限制请求的方法?我认为可以使用请求时间和先前编写数据的时间来实现,但是在文档中找不到如何执行此操作的任何信息。

当前的安全规则如下所示。

{
    "rules": {
      "rooms": {
        "$RoomId": {
          "connections": {
              ".read": true,
              ".write": "auth.username == newData.child('FBUserId').val()"
          },
          "messages": {
            "$any": {
            ".write": "!newData.exists() || root.child('rooms').child(newData.child('RoomId').val()).child('connections').hasChild(newData.child('FBUserId').val())",
            ".validate": "newData.hasChildren(['RoomId','FBUserId','userName','userId','message']) && newData.child('message').val().length >= 1",
            ".read": "root.child('rooms').child(data.child('RoomId').val()).child('connections').hasChild(data.child('FBUserId').val())"
            }
          },
          "poll": {
            ".write": "auth.username == newData.child('FBUserId').val()",
            ".read": true
          }
        }
      }
    }
}

我希望对整个Rooms对象的写入(和读取?)进行速率限制,以便每秒只能进行1个请求(例如)。

4个回答

51

诀窍在于保留用户最后发布消息的审核记录。然后,您可以根据审核值强制规定每条消息发布的时间:

{
  "rules": {
          // this stores the last message I sent so I can throttle them by timestamp
      "last_message": {
        "$user": {
          // timestamp can't be deleted or I could just recreate it to bypass our throttle
          ".write": "newData.exists() && auth.uid === $user",
          // the new value must be at least 5000 milliseconds after the last (no more than one message every five seconds)
          // the new value must be before now (it will be since `now` is when it reaches the server unless I try to cheat)
          ".validate": "newData.isNumber() && newData.val() === now && (!data.exists() || newData.val() > data.val()+5000)"
        }
      },

      "messages": {
        "$message_id": {
          // message must have a timestamp attribute and a sender attribute
          ".write": "newData.hasChildren(['timestamp', 'sender', 'message'])",
          "sender": {
            ".validate": "newData.val() === auth.uid"
          },
          "timestamp": {
            // in order to write a message, I must first make an entry in timestamp_index
            // additionally, that message must be within 500ms of now, which means I can't
            // just re-use the same one over and over, thus, we've effectively required messages
            // to be 5 seconds apart
            ".validate": "newData.val() >= now - 500 && newData.val() === data.parent().parent().parent().child('last_message/'+auth.uid).val()"
          },
          "message": {
            ".validate": "newData.isString() && newData.val().length < 500" 
          },
          "$other": {
            ".validate": false 
          }
        }
      } 
  }
}

在这个fiddle中可以看到它的具体应用 (点击此处)。以下是fiddle中的主要内容:

var fb = new Firebase(URL);
var userId; // log in and store user.uid here

// run our create routine
createRecord(data, function (recordId, timestamp) {
   console.log('created record ' + recordId + ' at time ' + new Date(timestamp));
});

// updates the last_message/ path and returns the current timestamp
function getTimestamp(next) {
    var ref = fb.child('last_message/' + userId);
    ref.set(Firebase.ServerValue.TIMESTAMP, function (err) {
        if (err) { console.error(err); }
        else {
            ref.once('value', function (snap) {
                next(snap.val());
            });
        }
    });
}

function createRecord(data, next) {
    getTimestamp(function (timestamp) {
        // add the new timestamp to the record data
        var data = {
          sender: userId,
          timestamp: timestamp,
          message: 'hello world'
        };

        var ref = fb.child('messages').push(data, function (err) {
            if (err) { console.error(err); }
            else {
               next(ref.name(), timestamp);
            }
        });
    })
}

4
对于一位仁慈的客户而言这很棒。但如果遭受黑客攻击呢?有人可以选择不在last_message引用中发布任何内容。然后,他们可以不断地向您的引用发出请求并填充它。是否有任何方法提供速率限制以避免这种情况? - Justin Noel
4
实际上,我想撤回这个想法。看起来Kato已经处理好了这件事。你必须发布到“last_message”,否则写入“messages”的操作将失败。“last_message”可以防止拥挤,要求最后一条消息至少在5秒钟之前。非常优雅。 - Justin Noel
1
是的,这些规则并不是最简单的,但它们确实能完成工作! - Kato
1
有没有办法让未经身份验证的用户使用这个功能?我猜重要的不是做了它,所以一个GUID可能是第一个表中的关键,而不是$user。不确定当前的实现是否这样做,但您可能还需要限制重新发送,并可能为每个消息设置a和min时间窗口。这听起来对您来说正确吗?我想问题在于,您可以将消息组合在一起以绕过速率限制... - Merlyn Morgan-Graham
3
你想得太多,把一次性的事情搞得太复杂了,其实你不会再遇到这种情况。如果你需要应用程序银行级别的安全性,写一个服务器端进程并让它推送消息,添加任何你想要的限制。或者省点时间,把你的应用程序发布出去并使用吧。 - Kato
显示剩余5条评论

3
我没有足够的声望在评论中发表言论,但我同意Victor的评论。如果您将fb.child('messages').push(...)插入循环中(即for (let i = 0; i < 100; i++) {...}),则可以成功推送60-80条消息(在500ms窗口期内)。
受Kato方案的启发,我提出以下规则修改建议:
rules: {
  users: {
    "$uid": {
      "timestamp": { // similar to Kato's answer
        ".write": "auth.uid === $uid && newData.exists()"
        ,".read": "auth.uid === $uid"
        ,".validate": "newData.hasChildren(['time', 'key'])"
        ,"time": {
          ".validate": "newData.isNumber() && newData.val() === now && (!data.exists() || newData.val() > data.val() + 1000)"
        }
        ,"key": {

        }
      }
      ,"messages": {
        "$key": { /// this key has to be the same is the key in timestamp (checked by .validate)
           ".write": "auth.uid === $uid && !data.exists()" ///only 'create' allow
           ,".validate": "newData.hasChildren(['message']) && $key === root.child('/users/' + $uid + '/timestamp/key').val()"
           ,"message": { ".validate": "newData.isString()" }
           /// ...and any other datas such as 'time', 'to'....
        }
      }
    }
  }
}

.js代码与Kato的解决方案非常相似,唯一不同的是getTimestamp会返回{time: number, key: string}给下一个回调函数。然后我们只需要ref.update({[key]: data})即可。
此解决方案避免了500毫秒的时间窗口,我们不必担心客户端必须足够快地在500毫秒内推送消息。如果发送多个写入请求(垃圾邮件),它们只能写入messages中的1个单一键值。可选地,在messages中创建唯一规则可以防止这种情况发生。

2
我喜欢 Kato的回答,但它没有考虑到恶意用户在500ms窗口之间使用for循环来淹没聊天的情况。我提出了这个变体,消除了这种可能性:
{
  "rules": {
    "users": {
      "$uid": {
        "rateLimit": {
          "lastMessage": {
            // newData.exists() ensures newData is not null and prevents deleting node
            // and $uid === auth.uid ensures the user writing this child node is the owner
            ".write": "newData.exists() && $uid === auth.uid",
            // newData.val() === now ensures the value written is the current timestamp
            // to avoid tricking the rules writting false values
            // and (!data.exists() || newData.val() > data.val() + 5000)
            // ensures no data exists currently in the node. Otherwise it checks if the
            // data that will overwrite the node is a value higher than the current timestamp
            // plus the value that will rate limit our messages expressed in milliseconds.
            // In this case a value of 5000 means that we can only send a message if
            // the last message we sent was more than 5 seconds ago
            ".validate": "newData.val() === now && (!data.exists() || newData.val() > data.val() + 5000)"
          }
        }
      }
    },
    "messages": {
      "$messageId": {
        // This rule ensures that we write lastMessage node avoiding just sending the message without
        // registering a new timestamp
        ".write": "newData.parent().parent().child('users').child(auth.uid).child('rateLimit').child('lastMessage').val() === now",
        // This rule ensures that we have all the required message fields
        ".validate": "newData.hasChildren(['timestamp', 'uid', 'message'])",
        "uid": {
          // This rule ensures that the value written is the id of the message sender
          ".validate": "newData.val() === auth.uid"
        },
        "timestamp": {
          // This rule ensures that the message timestamp can't be modified
          ".write": "!data.exists()",
          // This rule ensures that the value written is the current timestamp
          ".validate": "newData.val() === now"
        },
        "message": {
          // This rule ensures that the value written is a string
          ".validate": "newData.isString()"
        },
        "$other": {
          // This rule ensures that we cant write other fields in the message other than
         // the explicitly declared above
         ".validate": false
        }
      }
    }
  }
}

代码实现使用多个位置的原子写入。如果一个验证失败,操作不会完成,并且数据库中不进行任何操作。
function sendMessage(message) {
    const database = firebase.database();

    const pushId = database.ref().child("messages").push().key;
    const userId = firebase.auth().currentUser.uid;
    const timestampPlaceholder = firebase.database.ServerValue.TIMESTAMP;

    let updates = {};
    updates["messages/" + pushId] = {
      uid: userId,
      timestamp: timestampPlaceholder,
      message: message,
    };
    updates[`users/${userId}/rateLimit/lastMessage`] = timestampPlaceholder;

    database.ref().update(updates);
  }

更安全的规则 - jasan

0
现有的答案使用了两个数据库更新:(1)标记时间戳,(2)将标记的时间戳附加到实际写入中。Kato's answer需要500毫秒的时间窗口,而ChiNhan的则需要记住下一个键。
有一种更简单的方法可以在单个数据库更新中完成。这个想法是使用update()方法一次性向数据库写入多个值。安全规则验证所写的值,以便写入不超过配额。配额定义为一对值:quotaTimestamppostCountpostCount是在quotaTimestamp的1分钟内写入的帖子数量。如果postCount超过某个值,则安全规则会简单地拒绝下一次写入。当quotaTimestamp比1分钟更旧时,postCount将被重置。
以下是发布新消息的方法:
function postMessage(user, message) {
  const now = Date.now() + serverTimeOffset;
  if (!user.quotaTimestamp || user.quotaTimestamp + 60 * 1000 < now) {
    // Resets the quota when 1 minute has elapsed since the quotaTimestamp.
    user.quotaTimestamp = database.ServerValue.TIMESTAMP;
    user.postCount = 0;
  }
  user.postCount++;

  const values = {};
  const messageId = // generate unique id
  values[`users/${user.uid}/quotaTimestamp`] = user.quotaTimestamp;
  values[`users/${user.uid}/postCount`] = user.postCount;
  values[`messages/${messageId}`] = {
    sender: ...,
    message: ...,
    ...
  };
  return this.db.database.ref().update(values);
}

限制每分钟最多发布5篇文章的安全规则:

{
  "rules": {
    "users": {
      "$uid": {
        ".read": "$uid === auth.uid",
        ".write": "$uid === auth.uid && newData.child('postCount').val() <= 5",
        "quotaTimestamp": {
          // Only allow updating quotaTimestamp if it's staler than 1 minute.
          ".validate": "
            newData.isNumber()
            && (newData.val() === now
              ? (data.val() + 60 * 1000 < now)
              : (data.val() == newData.val()))"
        },
        "postCount": {
          // Only allow postCount to be incremented by 1
          // or reset to 1 when the quotaTimestamp is being refreshed.
          ".validate": "
            newData.isNumber()
            && (data.exists()
              ? (data.val() + 1 === newData.val()
                || (newData.val() === 1
                    && newData.parent().child('quotaTimestamp').val() === now))
              : (newData.val() === 1))"
        },
        "$other": { ".validate": false }
      }
    },

    "messages": {
      ...
    }
  }
}

注意:应该保持serverTimeOffset以避免时钟偏差。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接