Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CLOUDFLARE_API_TOKEN=cloudflare-token
CLOUDFLARE_ZONE_ID=zone-id
47 changes: 47 additions & 0 deletions app/api/cloudflare.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -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
45 changes: 32 additions & 13 deletions app/controllers/posts_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -8,6 +9,7 @@ def index

# GET /posts/1
def show
@post = Post.find(params[:id])
end

# GET /posts/new
Expand All @@ -17,42 +19,59 @@ def new

# GET /posts/1/edit
def edit
@post = Post.find(params[:id])
end

# POST /posts
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
end

# 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
end

# 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
16 changes: 16 additions & 0 deletions app/models/cached_url.rb
Original file line number Diff line number Diff line change
@@ -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
111 changes: 111 additions & 0 deletions cloudflare-csrf-worker/index.js
Original file line number Diff line number Diff line change
@@ -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('<meta name="csrf-token" content=')) return new Response(html, response)

// Replace CSRF tokens present in <meta> and <input> tags provided by Rails with one generated by the worker
const token = generateRandomToken()
html = html
.replace(/<meta name="csrf-token" content=".*"/, `<meta name="csrf-token" content="${token}"`)
.replace(/<input type="hidden" name="authenticity_token" value=".*"/, `<input type="hidden" name="authenticity_token" value="${token}"`)

// Encrypt the token and set it as a cookie
const encryptedToken = await encryptMessage(secretKey, token)
const modifiedResponse = new Response(html, response)
modifiedResponse.headers.append('Set-Cookie', `csrf_token=${encryptedToken}; path=/; HttpOnly; Secure; SameSite=Lax`)

return modifiedResponse
}
4 changes: 4 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,9 @@ class Application < Rails::Application
config.hosts.clear

config.active_job.queue_adapter = :sidekiq

if ENV["CLOUDFLARE_WORKER_HOST"].present?
config.action_controller.default_url_options = { host: ENV.fetch("CLOUDFLARE_WORKER_HOST") }
end
end
end
13 changes: 13 additions & 0 deletions db/migrate/20240607180302_create_cached_urls.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class CreateCachedUrls < ActiveRecord::Migration[7.1]
def change
create_table :cached_urls do |t|
t.text :url, null: false
t.string :tags, array: true, default: []
t.datetime :expires_at, null: false

t.timestamps
end

add_index :cached_urls, :url, unique: true
end
end
11 changes: 10 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions spec/models/cached_url_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
RSpec.describe CachedUrl do
describe ".expire_by_tags" do
before do

end

it "purges non expired urls at Cloudflare but deletes all of them from the DB" do
all = CachedUrl.create! url: "https://host.com/posts", tags: %w[section:posts posts:all], expires_at: 1.hour.from_now
id1 = CachedUrl.create! url: "https://host.com/posts/1", tags: %w[section:posts posts:1], expires_at: 10.minutes.from_now

# Already expired
CachedUrl.create! url: "https://host.com/posts/2", tags: %w[section:posts posts:2], expires_at: 5.minutes.ago

# Not requested
id3 = CachedUrl.create! url: "https://host.com/posts/3", tags: %w[section:posts posts:3], expires_at: 1.hour.from_now

purge_request = stub_request(:post, "https://api.cloudflare.com/client/v4/zones/zone-id/purge_cache")
.with(body: { files: [all.url, id1.url] }.to_json)

CachedUrl.expire_by_tags(%w[posts:all posts:1 posts:2])

expect(purge_request).to have_been_requested
expect(CachedUrl.pluck(:url)).to eq [id3.url]
end
end
end