From 711c9bdf433bff42e81e21a708ef893db870531d Mon Sep 17 00:00:00 2001 From: David Backeus Date: Fri, 7 Jun 2024 14:12:32 +0200 Subject: [PATCH] Cloudflare edge caching --- .env.test | 2 + app/api/cloudflare.rb | 47 ++++++++ app/controllers/application_controller.rb | 6 + app/controllers/posts_controller.rb | 45 +++++-- app/models/cached_url.rb | 16 +++ cloudflare-csrf-worker/index.js | 111 ++++++++++++++++++ config/application.rb | 4 + .../20240607180302_create_cached_urls.rb | 13 ++ db/schema.rb | 11 +- spec/models/cached_url_spec.rb | 26 ++++ 10 files changed, 267 insertions(+), 14 deletions(-) create mode 100644 .env.test create mode 100644 app/api/cloudflare.rb create mode 100644 app/models/cached_url.rb create mode 100644 cloudflare-csrf-worker/index.js create mode 100644 db/migrate/20240607180302_create_cached_urls.rb create mode 100644 spec/models/cached_url_spec.rb diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..6d5b9b4 --- /dev/null +++ b/.env.test @@ -0,0 +1,2 @@ +CLOUDFLARE_API_TOKEN=cloudflare-token +CLOUDFLARE_ZONE_ID=zone-id diff --git a/app/api/cloudflare.rb b/app/api/cloudflare.rb new file mode 100644 index 0000000..860ba41 --- /dev/null +++ b/app/api/cloudflare.rb @@ -0,0 +1,47 @@ +module Cloudflare + BASE_URL = "https://api.cloudflare.com/client/v4".freeze + + # https://developers.cloudflare.com/api/operations/zone-purge#purge-cached-content-by-tag-host-or-prefix + # + # Rate-limiting: Cache-Tag, host and prefix purging each have a rate limit + # of 30,000 purge API calls in every 24 hour period. You may purge up to + # 30 tags, hosts, or prefixes in one API call. This rate limit can be + # raised for customers who need to purge at higher volume. + # + # Provide tags as an Array of Strings, eg: ["mnd-assets-id-xxx", ...] or a single String + def self.purge_by_tags(tags, zone_id: ENV.fetch("CLOUDFLARE_ZONE_ID")) + tags = Array.wrap(tags) + + post("zones/#{zone_id}/purge_cache", tags:) + end + + # https://developers.cloudflare.com/api/operations/zone-purge#purge-cached-content-by-url + def self.purge_by_urls(urls, zone_id: ENV.fetch("CLOUDFLARE_ZONE_ID")) + urls = Array.wrap(urls) + + post("zones/#{zone_id}/purge_cache", files: urls) + end + + # https://developers.cloudflare.com/api/operations/zone-purge#purge-all-cached-content + def self.purge_everything(zone_id: ENV.fetch("CLOUDFLARE_ZONE_ID")) + post("zones/#{zone_id}/purge_cache", purge_everything: true) + end + + %w[get post delete patch].each do |verb| + define_singleton_method(verb) do |path, params = {}| + request(verb.upcase, path, params) + end + end + + def self.request(verb, path, params) + HTTPX.send( + verb.downcase, + "#{BASE_URL}/#{path}", + headers: { + "Authorization" => "Bearer #{ENV.fetch('CLOUDFLARE_API_TOKEN')}", + "Accept" => "application/json", + }, + json: params, + ).raise_for_status + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 09705d1..cc531d3 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,2 +1,8 @@ class ApplicationController < ActionController::Base + if ENV["CLOUDFLARE_WORKER_HOST"].present? + def redirect_to(options = {}, response_options = {}) + response_options[:allow_other_host] = true unless response_options.key?(:allow_other_host) + super(options, response_options) + end + end end diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 4a75017..17c711c 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -1,5 +1,6 @@ class PostsController < ApplicationController - before_action :set_post, only: %i[ show edit update destroy ] + before_action :enable_caching, only: %i[index show new edit] + skip_before_action :verify_authenticity_token # GET /posts def index @@ -8,6 +9,7 @@ def index # GET /posts/1 def show + @post = Post.find(params[:id]) end # GET /posts/new @@ -17,6 +19,7 @@ def new # GET /posts/1/edit def edit + @post = Post.find(params[:id]) end # POST /posts @@ -24,7 +27,8 @@ def create @post = Post.new(post_params) if @post.save - redirect_to @post, notice: "Post was successfully created." + CachedUrl.expire_by_tags(["posts:all"]) + redirect_to post_url(@post, nocache: true), notice: "Post was successfully created." else render :new, status: :unprocessable_entity end @@ -32,8 +36,11 @@ def create # PATCH/PUT /posts/1 def update + @post = Post.find(params[:id]) + if @post.update(post_params) - redirect_to @post, notice: "Post was successfully updated." + CachedUrl.expire_by_tags(["posts:all", "posts:#{@post.id}"]) + redirect_to post_url(@post, nocache: true), notice: "Post was successfully updated." else render :edit, status: :unprocessable_entity end @@ -41,18 +48,30 @@ def update # DELETE /posts/1 def destroy - @post.destroy! - redirect_to posts_url, notice: "Post was successfully destroyed.", status: :see_other + post = Post.find(params[:id]) + + post.destroy! + + CachedUrl.expire_by_tags(["posts:all", "posts:#{post.id}"]) + + redirect_to posts_url(nocache: true), notice: "Post was successfully destroyed.", status: :see_other end private - # Use callbacks to share common setup or constraints between actions. - def set_post - @post = Post.find(params[:id]) - end - # Only allow a list of trusted parameters through. - def post_params - params.require(:post).permit(:title, :body) - end + def post_params + params.require(:post).permit(:title, :body) + end + + def enable_caching + return if params.key?(:nocache) + + # don't cache cookies (note: Cloudflare won't cache responses with cookies) + request.session_options[:skip] = true + + tags = action_name == "index" ? ["section:posts", "posts:all"] : ["section:posts", "posts:#{params[:id]}"] + + CachedUrl.upsert({ url: request.url, tags:, expires_at: 1.hour.from_now }, unique_by: :url) + expires_in 1.hour, public: true + end end diff --git a/app/models/cached_url.rb b/app/models/cached_url.rb new file mode 100644 index 0000000..b80fa70 --- /dev/null +++ b/app/models/cached_url.rb @@ -0,0 +1,16 @@ +class CachedUrl < ApplicationRecord + scope :tagged_one_of, -> (tags) { where("tags && ARRAY[?]::varchar[]", tags) } + + def self.expire_by_tags(tags) + transaction do + cached_urls = tagged_one_of(tags) + + now = Time.now + urls_to_purge = cached_urls.map { |cu| cu.url unless cu.expires_at < now }.compact + + Cloudflare.purge_by_urls(urls_to_purge) + + cached_urls.delete_all + end + end +end diff --git a/cloudflare-csrf-worker/index.js b/cloudflare-csrf-worker/index.js new file mode 100644 index 0000000..2e08c1d --- /dev/null +++ b/cloudflare-csrf-worker/index.js @@ -0,0 +1,111 @@ +// In a production setting these would be set as environment variables in the Cloudflare dashboard +const SECRET = "0c55b6b18a5072a6ba83773679c6a114234798c1be4d8591f628023e9475f11300d97ccc14fd4293cfb038b1253937704e9311677610a15875a48899bc70be91" +const ORIGIN_HOST = "rails-example.staging.mynewsdesk.dev" + +addEventListener('fetch', event => { + event.respondWith(handleRequest(event.request)) +}) + +encodeText = text => new TextEncoder().encode(text) // text to ArrayBuffer +decodeText = buffer => new TextDecoder().decode(buffer) // ArrayBuffer to text + +// Function to generate a crypto key from a secret key +async function generateCryptoKey(secretKey) { + return await crypto.subtle.importKey( + 'raw', + encodeText(secretKey), + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'] + ) +} + +// SHA256 the secret to ensure it's the correct length +async function hashSecretKey(secretKey) { + return await crypto.subtle.digest('SHA-256', encodeText(secretKey)) +} + +function generateRandomToken() { + return Array.from(crypto.getRandomValues(new Uint8Array(32))) + .map(int => int.toString(16).padStart(2, '0')) + .join('') +} + +function getCookieValue(cookieString, name) { + const cookies = cookieString.split('; ') + for (const cookie of cookies) { + const [cookieName, cookieValue] = cookie.split('=') + if (cookieName === name) { + return cookieValue + } + } + return null +} + +async function encryptMessage(secretKey, message) { + const iv = crypto.getRandomValues(new Uint8Array(12)); // Initialization vector + const key = await generateCryptoKey(secretKey) + const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encodeText(message)) + + // Combine iv and encrypted data + const encryptedArray = new Uint8Array(encrypted) + const combinedArray = new Uint8Array(iv.length + encryptedArray.length) + combinedArray.set(iv, 0) + combinedArray.set(encryptedArray, iv.length) + + return btoa(String.fromCharCode(...combinedArray)) +} + +async function decryptMessage(secretKey, encryptedMessage) { + const combinedArray = new Uint8Array(atob(encryptedMessage).split('').map(char => char.charCodeAt(0))) + const iv = combinedArray.slice(0, 12) + const encryptedArray = combinedArray.slice(12) + + const key = await generateCryptoKey(secretKey) + const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encryptedArray) + + return decodeText(decrypted) +} + +async function handleRequest(request, env, ctx) { + const requestUrl = new URL(request.url) + requestUrl.hostname = ORIGIN_HOST + + const secretKey = hashSecretKey(SECRET) + + if (request.method == 'POST' || request.method == 'PUT' || request.method == 'DELETE' || request.method == 'PATCH') { + // Read form data from a clone to avoid corrupting the original request before we forward it. + const clonedRequest = request.clone() + const form = await clonedRequest.formData() + const csrfToken = form.get('authenticity_token') + if (!csrfToken) return new Response(`CSRF token not found!\n${form}`, { status: 403 }) + + const cookieToken = getCookieValue(request.headers.get('Cookie'), 'csrf_token') + const decryptedToken = await decryptMessage(secretKey, cookieToken) + + // console.log('csrfToken:', csrfToken) + // console.log('cookieToken:', cookieToken) + // console.log('decryptedToken:', decryptedToken) + + if (csrfToken != decryptedToken) return new Response('CSRF validation failed!', { status: 403 }) + } + + const response = await fetch(requestUrl, request) + let html = await response.text() + + // If the response doesn't contain a CSRF token, we don't need to do anything + if(!html.includes('