From 0c2a8f3d98d50418e953d5ac961c60d01b106840 Mon Sep 17 00:00:00 2001 From: Syed Daanish Date: Wed, 3 Sep 2025 15:36:28 +0100 Subject: [PATCH] Add CSRF token support --- .rubocop.yml | 9 +++++++++ index.erb | 1 + logman.rb | 4 ++-- main.rb | 37 +++++++++++++++++++++++++------------ players.rb | 9 ++++++--- public/src/js/accounts.js | 8 ++++++-- session.rb | 20 +++++++++++++++++--- 7 files changed, 66 insertions(+), 22 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index b15522c..f8c0693 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -37,6 +37,15 @@ Metrics/MethodLength: Metrics/ClassLength: Enabled: false +Metrics/BlockLength: + Enabled: false + +Layout/FirstHashElementIndentation: + Enabled: false + +Layout/FirstArrayElementIndentation: + Enabled: false + Layout/EmptyLineAfterGuardClause: Enabled: false diff --git a/index.erb b/index.erb index c808ada..33febfe 100644 --- a/index.erb +++ b/index.erb @@ -8,6 +8,7 @@ content="initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" /> + "Unauthorized (invalid CSRF token)!" }.to_json + end + session["csrf_token"] = Array.new(32) { ALPHANUM.sample }.join if data["email"].nil? || data["pass"].nil? || data["username"].nil? status 400 - return { "message" => "Bad request made!" }.to_json + return { "message" => "Bad request made!", "csrf_token" => session["csrf_token"] }.to_json end signup_status = Players.mk_player(data["username"], data["email"], data["pass"]) if signup_status[0] == 200 login_status = session.login(data["username"], data["pass"]) if login_status[0] == 200 status 200 - return { "message" => login_status[1], "success" => "true" }.to_json + return { "message" => login_status[1], "success" => "true", "csrf_token" => session["csrf_token"] }.to_json else status login_status[0] - return { "message" => login_status[1] }.to_json + return { "message" => login_status[1], "csrf_token" => session["csrf_token"] }.to_json end end status signup_status[0] - return { "message" => signup_status[1] }.to_json + return { "message" => signup_status[1], "csrf_token" => session["csrf_token"] }.to_json end get "/verify/:code" do @@ -82,21 +86,30 @@ post "/login" do data = JSON.parse(request.body.read) session = Sessions.new request, response uid = session["user"] + Logman.log(request.env["HTTP_X_CSRF_TOKEN"].to_s + " " + session["csrf_token"].to_s) + unless session.csrf_auth? + status 401 + return { "message" => "Unauthorized (invalid CSRF token)!" }.to_json + end + session["csrf_token"] = Array.new(32) { ALPHANUM.sample }.join if $active_users[uid] && !session.logout status 500 - return { "message" => "Internal server error when signing the existing session out!" }.to_json + return { + "message" => "Internal server error when signing the existing session out!", + "csrf_token" => session["csrf_token"], + }.to_json end if data["username"].nil? || data["pass"].nil? status 400 - return { "message" => "Bad request made!" }.to_json + return { "message" => "Bad request made!", "csrf_token" => session["csrf_token"] }.to_json end login_status = session.login(data["username"], data["pass"]) if login_status[0] == 200 status 200 - return { "message" => login_status[1], "success" => "true" }.to_json + return { "message" => login_status[1], "success" => "true", "csrf_token" => session["csrf_token"] }.to_json else status login_status[0] - return { "message" => login_status[1] }.to_json + return { "message" => login_status[1], "csrf_token" => session["csrf_token"] }.to_json end end diff --git a/players.rb b/players.rb index cfc0e80..94222e0 100644 --- a/players.rb +++ b/players.rb @@ -9,21 +9,24 @@ module Players end def self.mk_player(username, email, pass) - raise ArgumentError, "Email format is wrong!" unless email.match?(/\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/) + # rubocop:disable Layout/LineLength + raise ArgumentError, "Email format is wrong!" unless + email.match?(%r[(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])]) + # rubocop:enable Layout/LineLength raise ArgumentError, "Username must be at least 4 characters long and of valid format." unless username.match?(/\A[a-zA-Z][a-zA-Z0-9_.-]+\z/) && username.length >= 4 raise ArgumentError, "Password must be at least 8 characters and of valid format." unless pass.match?(/\A[a-zA-Z0-9_.!?@#$%^&*()+=-]+\z/) && pass.length >= 8 digest = XXhash.xxh32(pass, ENV_HASH["SALT"]) - code = CODE_ENV == :prod ? Array.new(24) { ALPHANUM.sample }.join : "!" + code = ENV_HASH["ENV"] == "prod" ? Array.new(24) { ALPHANUM.sample }.join : "!" DB[ "insert into Players (username, digest, email, activation_code) values (?, ?, ?, ?)", username, digest, email, code ].insert - send_email(:new, email, username, code) if CODE_ENV == :prod + send_email(:new, email, username, code) if ENV_HASH["ENV"] == "prod" [200, "Successfully signed up!"] rescue ArgumentError => e diff --git a/public/src/js/accounts.js b/public/src/js/accounts.js index e8c6a0f..ef40944 100644 --- a/public/src/js/accounts.js +++ b/public/src/js/accounts.js @@ -1,3 +1,5 @@ +const csrfMeta = document.querySelector('meta[name="csrf"]'); +var csrf = csrfMeta?.content; window.onload = async () => { const popup = document.getElementById("popup"); const loginSection = document.getElementById("login"); @@ -71,11 +73,12 @@ window.onload = async () => { const { username, pass } = loginForm; const res = await fetch("/login", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf }, body: JSON.stringify({ username: username.value, pass: pass.value }), }); const data = await res.json(); loginInfo.innerText = data.message; + csrf = data.csrf_token; if (data.success == "true") { loginButton.style.display = "none"; signupButton.style.display = "none"; @@ -91,7 +94,7 @@ window.onload = async () => { const { username, email, pass } = signupForm; const res = await fetch("/signup", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf }, body: JSON.stringify({ username: username.value, email: email.value, @@ -100,6 +103,7 @@ window.onload = async () => { }); const data = await res.json(); signupInfo.innerText = data.message; + csrf = data.csrf_token; if (data.success == "true") { loginButton.style.display = "none"; signupButton.style.display = "none"; diff --git a/session.rb b/session.rb index 34510c9..6489c36 100644 --- a/session.rb +++ b/session.rb @@ -40,17 +40,22 @@ class Sessions end end + # TODO: Use .all here def []=(key, val) session = @request.cookies["session"] session = session.nil? ? "{}" : Zlib::Inflate.inflate(Base64.decode64(session)) session = JSON.parse(session) session[key] = val + Logman.log "Updated: #{key} to #{val}" compressed = Zlib::Deflate.deflate(JSON.generate(session)) encoded = Base64.encode64(compressed) @response.set_cookie("session", value: encoded, path: "/", - expires: Time.now + 360 * 24 * 60 * 60) + expires: Time.now + 360 * 24 * 60 * 60, + httponly: true, + secure: ENV_HASH["ENV"] == "prod", + samesite: :strict) uid = session["user"] DB["UPDATE SignedInUsers SET last_used_at = CURRENT_TIMESTAMP WHERE code = ?", uid].update if uid rescue JSON::ParserError, Zlib::Error @@ -79,13 +84,19 @@ class Sessions @response.set_cookie("message", value: val, path: "/", - expires: Time.now + 360 * 24 * 60 * 60) + expires: Time.now + 360 * 24 * 60 * 60, + secure: ENV_HASH["ENV"] == "prod", + samesite: :strict) end def message @request.cookies["message"] end + def csrf_auth? + @request.env["HTTP_X_CSRF_TOKEN"] == self["csrf_token"] + end + def all session = @request.cookies["session"] session = session.nil? ? "{}" : Zlib::Inflate.inflate(Base64.decode64(session)) @@ -107,7 +118,10 @@ class Sessions @response.set_cookie("session", value: encoded, path: "/", - expires: Time.now + 360 * 24 * 60 * 60) + expires: Time.now + 360 * 24 * 60 * 60, + httponly: true, + secure: ENV_HASH["ENV"] == "prod", + samesite: :strict) rescue JSON::ParserError, Zlib::Error @response.delete_cookie("session") end