2

I have a server-side-rendered reactjs app using firebase firestore.

I have an area of my site that server-side-renders content that needs to be retrieved from firestore.

Currently, I am using firestore rules to allow anyone to read data from these particular docs

What worries me is that some bad person could setup a script to just continuously hit my database with reads and rack up my bills (since we are charged on a per-read basis, it seems that it's never wise to allow anyone to perform reads.)

Current Rule

// Allow anonymous users to read feeds
match /landingPageFeeds/{pageId}/feeds/newsFeed {
  allow read: if true;
}

Best Way Forward?

How do I allow my server-side script to read from firestore, but not allow anyone else to do so?

Keep in mind, this is an initial action that runs server-side before hydrating the client-side with the pre-loaded state. This function / action is also shared with client-side for page-to-page navigation.

I considered anonymous login - which worked, however, this generated a new anonymous user with every page load - and Firebase does throttle new email/password and anonymous user accounts. It did not seem practical.

Matthew Rideout
  • 7,330
  • 2
  • 42
  • 61
  • Anonymous auth will not create a new user with every page load unless you code it to do that. The created user is remembered between page loads and even between browser launches - you just have to check if that's the case. It's a common mistake. – Doug Stevenson Jan 06 '19 at 23:11
  • Also bear in mind that if your page load causes a read from the database, all a bad actor has to do is reload your page repeatedly in order to incur unnecessary reads. You're not really solving the problem, you're just shifting it around. Bottom line is that you can't really stop people from incurring billing on your project if you expose any of its services publicly. – Doug Stevenson Jan 06 '19 at 23:13
  • The anonymous user account creation is running on a server-side initial action, immediately reading the data with the anonymouse user, saving to state, and then sending this preloaded state to the client's initial load via hydrate, and it is creating an anonymous user every time I hit reload I was surprised this portion of the client SDK was working server-side. (I setup email notifications for new user setups). via this method (https://firebase.google.com/docs/auth/web/anonymous-auth) So this is happening every refresh. I guess since the limit is IP based it's not really an issue? – Matthew Rideout Jan 06 '19 at 23:17
  • I was going to try to get around this with server-side caching - so that people who hit reload just get the cached response (the initial action won't run again). I'm more worried about a third party bot sniffing for unsecured firestore databases. – Matthew Rideout Jan 06 '19 at 23:18
  • Firebase Authentication logins are intended to be used only within mobile clients, not on server environments. I can't really say what you're getting into if you try to use the client SDK on a server. – Doug Stevenson Jan 06 '19 at 23:19
  • If all you want is security rules that deny all external client access, it's given to you in the documentation: https://firebase.google.com/docs/firestore/security/get-started – Doug Stevenson Jan 06 '19 at 23:21
  • Is there a way to deny all access to a doc, except for requests from my specific app - not user account related, but the request must come from an app with my project's credentials? – Matthew Rideout Jan 06 '19 at 23:22
  • 1
    Security rules don't affect access from the Admin SDK, which is used to run server side code to access Firebase projects. – Doug Stevenson Jan 06 '19 at 23:23

1 Answers1

2

Solution

Per Doug's comment, I thought about the admin SDK more. I ended up creating a separate API in firebase functions for anonymous requests requiring secure firestore reads that can be cached.

Goals

  1. Continue to deny public reads of my firestore database
  2. Allow anonymous users to trigger firestore reads for server-side-rendered reactjs pages that require data from Firestore database (like first-time visitors, search engines).
  3. Prevent "read spam" where a third party could hit my database with millions of reads to drive up my cloud costs by using server-side CDN cache for the responses. (by invoking unnessary reads in a loop, I once racked up a huge bill on accident - I want to make sure strangers can't do this maliciously)

Admin SDK & Firebase Function Caching

The admin SDK allows me to securely read from firestore. My firestore security rules can deny access to non-authenticated users.

Firebase functions that are handling GET requests support server caching the response. This means that subsequent hits from identical queries will not re-run all of my functions (firebase reads, other function invocations) - it will just instantly respond with the same data again.

Process

  1. Anonymous client visits a server-side rendered reactjs page
  2. Initial load rendering on server triggers a firebase function (https trigger)
  3. Firebase function uses Admin SDK to read from secured firestore database
  4. Function caches the response for 3 hours res.set('Cache-Control', 'public, max-age=600, s-maxage=10800');
  5. Subsequent requests from any client anywhere for the next 3 hours are served from the cache - avoiding unnecessary reads or additional computation / resource usage

Note - caching does not work on local - must deploy to firebase to test caching effect.

Example Function

const functions = require("firebase-functions");
const cors = require('cors')({origin: true});
const { sendResponse } = require("./includes/sendResponse");
const { getFirestoreDataWithAdminSDK } = require("./includes/getFirestoreDataWithAdminSDK");


const cachedApi = functions.https.onRequest((req, res) => {
    cors(req, res, async () => {
        // Set a cache for the response to limit the impact of identical request on expensive resources
        res.set('Cache-Control', 'public, max-age=600, s-maxage=10800');

        // If POST - response with bad request code - POST requests are not cached
        if(req.method === "POST") {
            return sendResponse(res, 400);
        } else {

            // Get GET request action from query
            let action = (req.query.action) ? req.query.action : null;
            console.log("Action: ", action);

            try {
                // Handle Actions Appropriately
                switch(true) {
                    
                    // Get Feed Data
                    case(action === "feed"): {
                        console.log("Getting feed...");

                        // Get feed id
                        let feedId = (req.query.feedId) ? req.query.feedId : null;

                        // Get feed data
                        let feedData = await getFirestoreDataWithAdminSDK(feedId);

                        return sendResponse(res, 200, feedData);
                    }

                    // No valid action specified
                    default: {
                        return sendResponse(res, 400);
                    }
                }
            } catch(err) {
                console.log("Cached API Error: ", err);
                return sendResponse(res, 500);
            }
        }
    });
});

module.exports = {
    cachedApi
}
Community
  • 1
  • 1
Matthew Rideout
  • 7,330
  • 2
  • 42
  • 61
  • This is a good way to go about things, generally speaking. In my case, I require users to be authenticated and have an active paid subscription in order to view content. If I allowed that content to be cached on the CDN, it would be available for anyone to view, whether they had an active subscription or not. I guess I am stuck doing the reads each time in my scenario. Maybe I can rearchitect my app to do some client-side caching. – TheRealMikeD Feb 25 '22 at 23:04