# frozen_string_literal: true # # ValidBoard — a fork of Nullboard (https://github.com/apankrat/nullboard) that # keeps boards in SQLite on the server instead of the browser's localStorage. # # The API is deliberately tiny: hydrate the whole keyspace on load, then push # batches of set/delete back. See store.rb for why that's all it needs to be. require 'sinatra/base' require 'json' require_relative 'store' module ValidBoard STORE = Store.new(db_path) # Set VALIDBOARD_SECURE_COOKIE=1 once you're behind https, so the session # cookie is never sent in the clear. Off by default so plain-http local runs # can still log in. SECURE_COOKIE = ENV['VALIDBOARD_SECURE_COOKIE'] == '1' # Failed logins are throttled per IP. bcrypt already makes guessing slow, but # a lockout turns "slow" into "not worth trying". In-memory is fine: there is # one process, and a restart clearing the counters is not a meaningful win for # an attacker who still has to get through bcrypt. class LoginThrottle MAX_FAILURES = 10 WINDOW = 15 * 60 # seconds def initialize @mutex = Mutex.new @failures = Hash.new { |h, k| h[k] = [] } end def locked?(ip) @mutex.synchronize { recent(ip).length >= MAX_FAILURES } end def retry_after(ip) @mutex.synchronize do oldest = recent(ip).first oldest ? (oldest + WINDOW - Time.now.to_i) : 0 end end def record_failure(ip) @mutex.synchronize { @failures[ip] = recent(ip) << Time.now.to_i } end def clear(ip) @mutex.synchronize { @failures.delete(ip) } end private # Caller holds the mutex. def recent(ip) cutoff = Time.now.to_i - WINDOW @failures[ip].select { |t| t > cutoff } end end class App < Sinatra::Base set :root, ROOT set :views, File.join(ROOT, 'views') set :static, false # every path is served by an explicit route below set :show_exceptions, false set :dump_errors, true # Sinatra's default protection stack gives us HttpOrigin (which rejects # cross-site writes) and friends. It is wired up ahead of the session # middleware for us, which is why sessions are configured through Sinatra # rather than a bare `use Rack::Session::Cookie`. set :protection, true set :sessions, key: 'validboard.session', secret: ENV.fetch('VALIDBOARD_SECRET') { STORE.session_secret }, httponly: true, secure: SECURE_COOKIE, same_site: :lax, expire_after: 60 * 60 * 24 * 30 THROTTLE = LoginThrottle.new # # helpers # helpers do def authenticated? session[:authenticated] == true end def json(obj, status_code = 200) content_type :json status status_code obj.to_json end def api_request? request.path_info.start_with?('/api/') end def client_ip request.ip end # Nullboard's storage layer is synchronous, so the board data has to be # in the page before its script runs. Embedding it here rather than # serving it as a .js file means another site can't " — or "