feat(blog): extend app with throttled_path
This commit is contained in:
1 parent
321d3416d4
commit
68a998d640
1 file changed
+145
-2
+145
-2
@@ -1,23 +1,166 @@
|
|||||||
require 'prism'
|
require 'prism'
|
||||||
|
require 'fileutils'
|
||||||
|
|
||||||
APP_ROOT = __dir__
|
APP_ROOT = __dir__
|
||||||
|
|
||||||
Prism::Orm.connect(File.join(APP_ROOT, 'db', 'development.pstore'))
|
# Both overridable by ENV so a second, independent OS process (see
|
||||||
|
# rotation_worker.rb) can boot this exact app against its own scratch
|
||||||
|
# database and its own encryption secret without touching development.pstore
|
||||||
|
# - key rotation genuinely retires the OLD secret from the running process'
|
||||||
|
# memoized, per-model derived keys, so exercising it for real needs a fresh
|
||||||
|
# `ruby` invocation, not just a different in-process config call.
|
||||||
|
DB_DIR = ENV.fetch('PRISM_BLOG_DB_DIR', File.join(APP_ROOT, 'db'))
|
||||||
|
FileUtils.mkdir_p(DB_DIR)
|
||||||
|
|
||||||
|
Prism::Orm.connect(File.join(DB_DIR, 'development.pstore'))
|
||||||
Prism::Orm.migrator(File.join(APP_ROOT, 'db', 'migrate')).migrate!
|
Prism::Orm.migrator(File.join(APP_ROOT, 'db', 'migrate')).migrate!
|
||||||
|
|
||||||
|
Prism::Jobs.default_queue = Prism::Jobs::Queue.new(File.join(DB_DIR, 'jobs.pstore'))
|
||||||
|
Prism::Mailer.delivery_method = :test
|
||||||
|
|
||||||
|
# PStoreStore instead of the default in-memory RateLimiter::MemoryStore -
|
||||||
|
# throttle windows and fail2ban-style bans survive a process restart the
|
||||||
|
# same way development.pstore/jobs.pstore already do. Without this, an
|
||||||
|
# attacker who gets banned right before a deploy (a plain process restart)
|
||||||
|
# would find the ban silently gone the moment the new process comes up,
|
||||||
|
# since MemoryStore keeps every window and ban only in that one process'
|
||||||
|
# RAM. See blog_rate_limit_worker.rb for the two-process proof.
|
||||||
|
Prism::RateLimiter.store = Prism::RateLimiter::PStoreStore.new(File.join(DB_DIR, 'rate_limits.pstore'))
|
||||||
|
|
||||||
|
Prism::I18n.default_locale = :en
|
||||||
|
Prism::I18n.load_path << File.join(APP_ROOT, 'config', 'locales')
|
||||||
|
Prism::I18n.load_translations!
|
||||||
|
|
||||||
|
LOG_DIR = File.join(APP_ROOT, 'log')
|
||||||
|
Dir.mkdir(LOG_DIR) unless Dir.exist?(LOG_DIR)
|
||||||
|
DEVELOPMENT_LOG = File.open(File.join(LOG_DIR, 'development.log'), 'a')
|
||||||
|
DEVELOPMENT_LOG.sync = true
|
||||||
|
Prism::Instrumentation::LogSubscriber.attach(
|
||||||
|
DEVELOPMENT_LOG,
|
||||||
|
patterns: %w[sql.prism render_template.prism process_action.prism perform_job.prism deliver_mail.prism]
|
||||||
|
)
|
||||||
|
|
||||||
Prism::Controller.views_root(File.join(APP_ROOT, 'app', 'views'))
|
Prism::Controller.views_root(File.join(APP_ROOT, 'app', 'views'))
|
||||||
Prism::Controller.layout(:application)
|
Prism::Controller.layout(:application)
|
||||||
|
|
||||||
Dir[File.join(APP_ROOT, 'app', 'models', '*.rb')].sort.each { |file| require file }
|
Dir[File.join(APP_ROOT, 'app', 'models', '*.rb')].sort.each { |file| require file }
|
||||||
|
Dir[File.join(APP_ROOT, 'app', 'mailers', '*.rb')].sort.each { |file| require file }
|
||||||
|
Dir[File.join(APP_ROOT, 'app', 'policies', '*.rb')].sort.each { |file| require file }
|
||||||
|
Dir[File.join(APP_ROOT, 'app', 'channels', '*.rb')].sort.each { |file| require file }
|
||||||
|
# application_controller.rb has to load before every other controller
|
||||||
|
# (they all subclass it) - a plain alphabetical sort no longer guarantees
|
||||||
|
# that on its own now that account_controller.rb sorts ahead of it.
|
||||||
|
require File.join(APP_ROOT, 'app', 'controllers', 'application_controller.rb')
|
||||||
Dir[File.join(APP_ROOT, 'app', 'controllers', '*.rb')].sort.each { |file| require file }
|
Dir[File.join(APP_ROOT, 'app', 'controllers', '*.rb')].sort.each { |file| require file }
|
||||||
|
|
||||||
|
# req.path is the raw PATH_INFO - the router only strips a trailing
|
||||||
|
# ".json" (etc) internally while matching, so without stripping it here
|
||||||
|
# too a POST to /session.json would silently dodge this throttle
|
||||||
|
# entirely (same account, same IP, just a different extension).
|
||||||
|
def throttled_path(req, base)
|
||||||
|
req.path.sub(%r{\.[^./]+\z}, '') == base
|
||||||
|
end
|
||||||
|
|
||||||
|
Prism::RateLimiter.throttle('logins/ip', limit: 5, period: 60) do |req|
|
||||||
|
throttled_path(req, '/session') && req.request_method == 'POST' ? req.env['REMOTE_ADDR'] : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
Prism::RateLimiter.throttle('signups/ip', limit: 3, period: 60) do |req|
|
||||||
|
throttled_path(req, '/users') && req.request_method == 'POST' ? req.env['REMOTE_ADDR'] : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Same shape as signups/ip above, same reason: #create's response is
|
||||||
|
# identical whether or not the email exists (see the comment on
|
||||||
|
# PasswordResetsController#create), but without a limit here that endpoint
|
||||||
|
# would still let an attacker hammer arbitrary addresses with reset emails
|
||||||
|
# for free.
|
||||||
|
Prism::RateLimiter.throttle('password_resets/ip', limit: 3, period: 60) do |req|
|
||||||
|
throttled_path(req, '/password_resets') && req.request_method == 'POST' ? req.env['REMOTE_ADDR'] : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Same shape and reason as password_resets/ip above: #create's response is
|
||||||
|
# identical whether or not the address exists or is already confirmed, but
|
||||||
|
# without a limit here that endpoint would let an attacker hammer arbitrary
|
||||||
|
# addresses with confirmation emails for free.
|
||||||
|
Prism::RateLimiter.throttle('email_confirmations/ip', limit: 3, period: 60) do |req|
|
||||||
|
throttled_path(req, '/email_confirmations') && req.request_method == 'POST' ? req.env['REMOTE_ADDR'] : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Two separate rules on purpose: the .json API is meant to be embeddable
|
||||||
|
# by anyone (a "latest posts" widget on a third-party site), but only the
|
||||||
|
# trusted admin dashboard origin gets to write through it, and only that
|
||||||
|
# origin is allowed to send the session cookie cross-origin at all. The
|
||||||
|
# specific origin is registered FIRST - Prism::Cors.resource_for returns
|
||||||
|
# the first rule whose origin matches, and '*' matches every origin, so a
|
||||||
|
# wildcard rule ahead of this one would shadow it for every request.
|
||||||
|
Prism::Cors.configure do |config|
|
||||||
|
config.allow do
|
||||||
|
origins 'https://admin.blog-demo.internal'
|
||||||
|
resource '/posts', methods: %w[GET POST], headers: %w[Content-Type X-CSRF-Token], credentials: true
|
||||||
|
resource '/posts/*', methods: %w[GET PATCH DELETE], headers: %w[Content-Type X-CSRF-Token], credentials: true
|
||||||
|
end
|
||||||
|
|
||||||
|
config.allow do
|
||||||
|
origins '*'
|
||||||
|
resource '/posts', methods: %w[GET]
|
||||||
|
resource '/posts/*', methods: %w[GET]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
class Blog < Prism::Application
|
class Blog < Prism::Application
|
||||||
set :views, File.join(APP_ROOT, 'app', 'views')
|
set :views, File.join(APP_ROOT, 'app', 'views')
|
||||||
enable_sessions(secret: 'blog-demo-secret')
|
enable_sessions(
|
||||||
|
secret: ENV.fetch('PRISM_BLOG_SESSION_SECRET', 'blog-demo-secret'),
|
||||||
|
previous_secrets: ENV['PRISM_BLOG_SESSION_PREVIOUS_SECRETS'].to_s.split(',')
|
||||||
|
)
|
||||||
|
enable_attribute_encryption(
|
||||||
|
secret: ENV.fetch('PRISM_BLOG_ENCRYPTION_SECRET', 'blog-demo-encryption-secret'),
|
||||||
|
previous_secrets: ENV['PRISM_BLOG_ENCRYPTION_PREVIOUS_SECRETS'].to_s.split(',')
|
||||||
|
)
|
||||||
|
# Backs User.generates_token_for(:password_reset) - a separate secret
|
||||||
|
# (own ENV var, own rotation list) from both session and encryption above
|
||||||
|
# on purpose: retiring this one to kill every outstanding reset link
|
||||||
|
# (say, right after the leak a rotation is usually responding to) should
|
||||||
|
# never force-log-out every signed-in visitor or break decryption of
|
||||||
|
# already-encrypted columns as a side effect.
|
||||||
|
enable_token_signing(
|
||||||
|
secret: ENV.fetch('PRISM_BLOG_TOKEN_SECRET', 'blog-demo-token-secret'),
|
||||||
|
previous_secrets: ENV['PRISM_BLOG_TOKEN_PREVIOUS_SECRETS'].to_s.split(',')
|
||||||
|
)
|
||||||
|
enable_assets(source: File.join(APP_ROOT, 'app', 'assets'), output: File.join(APP_ROOT, 'public', 'assets'))
|
||||||
use Prism::Middleware::Logger
|
use Prism::Middleware::Logger
|
||||||
|
use Prism::Middleware::Compression
|
||||||
|
use Prism::Middleware::Cors
|
||||||
use Prism::Middleware::ParamsParser
|
use Prism::Middleware::ParamsParser
|
||||||
|
use Prism::Middleware::RateLimiting
|
||||||
use Prism::Middleware::Static, root: File.join(APP_ROOT, 'public')
|
use Prism::Middleware::Static, root: File.join(APP_ROOT, 'public')
|
||||||
|
|
||||||
|
# Log-only by default so a detected N+1 shows up in the dev log the same
|
||||||
|
# way Bullet would, without ever turning an otherwise fine response into a
|
||||||
|
# 500 - this app's controllers already lean hard on .includes (see
|
||||||
|
# PostsController#index/#show) specifically to avoid the pattern this
|
||||||
|
# exists to catch, so in steady state this should stay silent; a warning
|
||||||
|
# here is a real regression, not a false positive.
|
||||||
|
#
|
||||||
|
# PRISM_BLOG_STRICT_N_PLUS_ONE=1 flips raise_on_violation on - meant for
|
||||||
|
# CI/test runs of this demo, where a regression should fail the run
|
||||||
|
# instead of scrolling past in a log nobody re-reads. Application#dispatch
|
||||||
|
# routes a violation through the same handle_uncaught_exception path as
|
||||||
|
# any other bug, so this always surfaces as a normal 500 over real HTTP
|
||||||
|
# (see strict_n_plus_one_demo.rb, which proves that end-to-end against a
|
||||||
|
# deliberately reintroduced N+1 through a real socket).
|
||||||
|
enable_n_plus_one_detection(threshold: 2, raise_on_violation: ENV['PRISM_BLOG_STRICT_N_PLUS_ONE'] == '1')
|
||||||
|
enable_mailer(host: 'localhost', port: 4567)
|
||||||
|
mount '/rails/mailers', Prism::Mailer::PreviewApp.new
|
||||||
|
# PostActivityChannel only ever pushes small JSON control/notification
|
||||||
|
# frames (subscribe acks, comment-count updates) and never acts on
|
||||||
|
# anything a client sends (see #on_message) - 2 KiB is generous headroom
|
||||||
|
# over the largest real payload while still being a meaningfully tighter
|
||||||
|
# cap than Connection::DEFAULT_MAX_MESSAGE_SIZE's 16 MiB, so a client
|
||||||
|
# that tries to abuse this read-mostly stream gets cut off quickly
|
||||||
|
# instead of being allowed to buffer up to the library-wide default.
|
||||||
|
websocket '/cable', PostActivityChannel, max_message_size: 2048
|
||||||
|
|
||||||
root(to: 'posts#index')
|
root(to: 'posts#index')
|
||||||
resources :posts, only: %i[index show create]
|
resources :posts, only: %i[index show create]
|
||||||
end
|
end
|
||||||
Reference in new issue
Block a user