Remember nonce to prevent replay attacks

This commit is contained in:
Omar Roth 2018-11-17 13:18:12 -06:00
parent c7f0a6f2e1
commit d185ba84bf
6 changed files with 104 additions and 76 deletions

13
config/sql/nonces.sql Normal file
View file

@ -0,0 +1,13 @@
-- Table: public.nonces
-- DROP TABLE public.nonces;
CREATE TABLE public.nonces
(
nonce text
)
WITH (
OIDS=FALSE
);
GRANT ALL ON TABLE public.nonces TO kemal;

View file

@ -7,3 +7,4 @@ psql invidious < config/sql/channels.sql
psql invidious < config/sql/videos.sql psql invidious < config/sql/videos.sql
psql invidious < config/sql/channel_videos.sql psql invidious < config/sql/channel_videos.sql
psql invidious < config/sql/users.sql psql invidious < config/sql/users.sql
psql invidious < config/sql/nonces.sql

View file

@ -142,7 +142,7 @@ before_all do |env|
user = PG_DB.query_one?("SELECT * FROM users WHERE $1 = ANY(id)", sid, as: User) user = PG_DB.query_one?("SELECT * FROM users WHERE $1 = ANY(id)", sid, as: User)
if user if user
challenge, token = create_response(user.email, "sign_out", HMAC_KEY, 1.week) challenge, token = create_response(user.email, "sign_out", HMAC_KEY, PG_DB, 1.week)
env.set "challenge", challenge env.set "challenge", challenge
env.set "token", token env.set "token", token
@ -155,7 +155,7 @@ before_all do |env|
client = make_client(YT_URL) client = make_client(YT_URL)
user = get_user(sid, client, headers, PG_DB, false) user = get_user(sid, client, headers, PG_DB, false)
challenge, token = create_response(user.email, "sign_out", HMAC_KEY, 1.week) challenge, token = create_response(user.email, "sign_out", HMAC_KEY, PG_DB, 1.week)
env.set "challenge", challenge env.set "challenge", challenge
env.set "token", token env.set "token", token
@ -624,7 +624,7 @@ get "/login" do |env|
account_type ||= "invidious" account_type ||= "invidious"
if account_type == "invidious" if account_type == "invidious"
captcha = generate_captcha(HMAC_KEY) captcha = generate_captcha(HMAC_KEY, PG_DB)
end end
tfa = env.params.query["tfa"]? tfa = env.params.query["tfa"]?
@ -815,9 +815,26 @@ post "/login" do |env|
next templated "error" next templated "error"
end end
elsif account_type == "invidious" elsif account_type == "invidious"
challenge_response = env.params.body["challenge_response"]? answer = env.params.body["answer"]?
if !answer
error_message = "CAPTCHA is a required field"
next templated "error"
end
answer = answer.lstrip('0')
answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer)
challenge = env.params.body["challenge"]?
token = env.params.body["token"]? token = env.params.body["token"]?
begin
validate_response(challenge, token, answer, "sign_in", HMAC_KEY, PG_DB)
rescue ex
error_message = ex.message
next templated "error"
end
action = env.params.body["action"]? action = env.params.body["action"]?
action ||= "signin" action ||= "signin"
@ -831,18 +848,6 @@ post "/login" do |env|
next templated "error" next templated "error"
end end
if !challenge_response || !token
error_message = "CAPTCHA is a required field"
next templated "error"
end
challenge_response = challenge_response.lstrip('0')
if OpenSSL::HMAC.digest(:sha256, HMAC_KEY, challenge_response) == Base64.decode(token)
else
error_message = "Invalid CAPTCHA response"
next templated "error"
end
if action == "signin" if action == "signin"
user = PG_DB.query_one?("SELECT * FROM users WHERE LOWER(email) = LOWER($1) AND password IS NOT NULL", email, as: User) user = PG_DB.query_one?("SELECT * FROM users WHERE LOWER(email) = LOWER($1) AND password IS NOT NULL", email, as: User)
@ -940,7 +945,7 @@ get "/signout" do |env|
token = env.params.query["token"]? token = env.params.query["token"]?
begin begin
validate_response(challenge, token, user.email, "sign_out", HMAC_KEY) validate_response(challenge, token, user.email, "sign_out", HMAC_KEY, PG_DB)
rescue ex rescue ex
error_message = ex.message error_message = ex.message
next templated "error" next templated "error"
@ -1461,7 +1466,7 @@ get "/delete_account" do |env|
if user if user
user = user.as(User) user = user.as(User)
challenge, token = create_response(user.email, "delete_account", HMAC_KEY) challenge, token = create_response(user.email, "delete_account", HMAC_KEY, PG_DB)
templated "delete_account" templated "delete_account"
else else
@ -1480,7 +1485,7 @@ post "/delete_account" do |env|
token = env.params.body["token"]? token = env.params.body["token"]?
begin begin
validate_response(challenge, token, user.email, "delete_account", HMAC_KEY) validate_response(challenge, token, user.email, "delete_account", HMAC_KEY, PG_DB)
rescue ex rescue ex
error_message = ex.message error_message = ex.message
next templated "error" next templated "error"
@ -1506,7 +1511,7 @@ get "/clear_watch_history" do |env|
if user if user
user = user.as(User) user = user.as(User)
challenge, token = create_response(user.email, "clear_watch_history", HMAC_KEY) challenge, token = create_response(user.email, "clear_watch_history", HMAC_KEY, PG_DB)
templated "clear_watch_history" templated "clear_watch_history"
else else
@ -1525,7 +1530,7 @@ post "/clear_watch_history" do |env|
token = env.params.body["token"]? token = env.params.body["token"]?
begin begin
validate_response(challenge, token, user.email, "clear_watch_history", HMAC_KEY) validate_response(challenge, token, user.email, "clear_watch_history", HMAC_KEY, PG_DB)
rescue ex rescue ex
error_message = ex.message error_message = ex.message
next templated "error" next templated "error"

View file

@ -130,55 +130,6 @@ def login_req(login_form, f_req)
return HTTP::Params.encode(data) return HTTP::Params.encode(data)
end end
def generate_captcha(key)
minute = Random::Secure.rand(12)
minute_angle = minute * 30
minute = minute * 5
hour = Random::Secure.rand(12)
hour_angle = hour * 30 + minute_angle.to_f / 12
if hour == 0
hour = 12
end
clock_svg = <<-END_SVG
<svg viewBox="0 0 100 100" width="200px">
<circle cx="50" cy="50" r="45" fill="#eee" stroke="black" stroke-width="2"></circle>
<text x="69" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 1</text>
<text x="82.909" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 2</text>
<text x="88" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 3</text>
<text x="82.909" y="72" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 4</text>
<text x="69" y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 5</text>
<text x="50" y="91" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 6</text>
<text x="31" y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 7</text>
<text x="17.091" y="72" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 8</text>
<text x="12" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 9</text>
<text x="17.091" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">10</text>
<text x="31" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">11</text>
<text x="50" y="15" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">12</text>
<circle cx="50" cy="50" r="3" fill="black"></circle>
<line id="minute" transform="rotate(#{minute_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="16" fill="black" stroke="black" stroke-width="2"></line>
<line id="hour" transform="rotate(#{hour_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="24" fill="black" stroke="black" stroke-width="2"></line>
</svg>
END_SVG
challenge = ""
convert = Process.run(%(convert -density 1200 -resize 400x400 -background none svg:- png:-), shell: true,
input: IO::Memory.new(clock_svg), output: Process::Redirect::Pipe) do |proc|
challenge = proc.output.gets_to_end
challenge = Base64.strict_encode(challenge)
challenge = "data:image/png;base64,#{challenge}"
end
answer = "#{hour}:#{minute.to_s.rjust(2, '0')}"
token = OpenSSL::HMAC.digest(:sha256, key, answer)
token = Base64.urlsafe_encode(token)
return {challenge: challenge, token: token}
end
def html_to_content(description_html) def html_to_content(description_html)
if !description_html if !description_html
description = "" description = ""

View file

@ -200,9 +200,10 @@ def create_user(sid, email, password)
return user return user
end end
def create_response(user_id, operation, key, expire = 6.hours) def create_response(user_id, operation, key, db, expire = 6.hours)
expire = Time.now + expire expire = Time.now + expire
nonce = Random::Secure.hex(4) nonce = Random::Secure.hex(16)
db.exec("INSERT INTO nonces VALUES ($1) ON CONFLICT DO NOTHING", nonce)
challenge = "#{expire.to_unix}-#{nonce}-#{user_id}-#{operation}" challenge = "#{expire.to_unix}-#{nonce}-#{user_id}-#{operation}"
token = OpenSSL::HMAC.digest(:sha256, key, challenge) token = OpenSSL::HMAC.digest(:sha256, key, challenge)
@ -213,7 +214,7 @@ def create_response(user_id, operation, key, expire = 6.hours)
return challenge, token return challenge, token
end end
def validate_response(challenge, token, user_id, operation, key) def validate_response(challenge, token, user_id, operation, key, db)
if !challenge if !challenge
raise "Hidden field \"challenge\" is a required field" raise "Hidden field \"challenge\" is a required field"
end end
@ -235,6 +236,12 @@ def validate_response(challenge, token, user_id, operation, key)
challenge = OpenSSL::HMAC.digest(:sha256, HMAC_KEY, challenge) challenge = OpenSSL::HMAC.digest(:sha256, HMAC_KEY, challenge)
challenge = Base64.urlsafe_encode(challenge) challenge = Base64.urlsafe_encode(challenge)
if db.query_one?("SELECT EXISTS (SELECT true FROM nonces WHERE nonce = $1)", nonce, as: Bool)
db.exec("DELETE FROM nonces * WHERE nonce = $1", nonce)
else
raise "Invalid token"
end
if challenge != token if challenge != token
raise "Invalid token" raise "Invalid token"
end end
@ -251,3 +258,53 @@ def validate_response(challenge, token, user_id, operation, key)
raise "Token is expired, please try again" raise "Token is expired, please try again"
end end
end end
def generate_captcha(key, db)
minute = Random::Secure.rand(12)
minute_angle = minute * 30
minute = minute * 5
hour = Random::Secure.rand(12)
hour_angle = hour * 30 + minute_angle.to_f / 12
if hour == 0
hour = 12
end
clock_svg = <<-END_SVG
<svg viewBox="0 0 100 100" width="200px">
<circle cx="50" cy="50" r="45" fill="#eee" stroke="black" stroke-width="2"></circle>
<text x="69" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 1</text>
<text x="82.909" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 2</text>
<text x="88" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 3</text>
<text x="82.909" y="72" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 4</text>
<text x="69" y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 5</text>
<text x="50" y="91" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 6</text>
<text x="31" y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 7</text>
<text x="17.091" y="72" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 8</text>
<text x="12" y="53" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 9</text>
<text x="17.091" y="34" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">10</text>
<text x="31" y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">11</text>
<text x="50" y="15" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">12</text>
<circle cx="50" cy="50" r="3" fill="black"></circle>
<line id="minute" transform="rotate(#{minute_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="16" fill="black" stroke="black" stroke-width="2"></line>
<line id="hour" transform="rotate(#{hour_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="24" fill="black" stroke="black" stroke-width="2"></line>
</svg>
END_SVG
image = ""
convert = Process.run(%(convert -density 1200 -resize 400x400 -background none svg:- png:-), shell: true,
input: IO::Memory.new(clock_svg), output: Process::Redirect::Pipe) do |proc|
image = proc.output.gets_to_end
image = Base64.strict_encode(image)
image = "data:image/png;base64,#{image}"
end
answer = "#{hour}:#{minute.to_s.rjust(2, '0')}"
answer = OpenSSL::HMAC.hexdigest(:sha256, key, answer)
challenge, token = create_response(answer, "sign_in", key, db)
return {image: image, challenge: challenge, token: token}
end

View file

@ -24,10 +24,11 @@
<label for="password">Password:</label> <label for="password">Password:</label>
<input required class="pure-input-1" name="password" type="password" placeholder="Password"> <input required class="pure-input-1" name="password" type="password" placeholder="Password">
<img style="width:100%" src='<%= captcha.not_nil![:challenge] %>'/> <img style="width:100%" src='<%= captcha.not_nil![:image] %>'/>
<input type="hidden" name="token" value="<%= captcha.not_nil![:token] %>"> <input type="hidden" name="token" value="<%= captcha.not_nil![:token] %>">
<label for="challenge_response">Time (h:mm):</label> <input type="hidden" name="challenge" value="<%= captcha.not_nil![:challenge] %>">
<input required type="text" name="challenge_response" type="text>" placeholder="hh:mm"> <label for="answer">Time (h:mm):</label>
<input required type="text" name="answer" type="text>" placeholder="hh:mm">
<button type="submit" name="action" value="signin" class="pure-button pure-button-primary">Sign In</button> <button type="submit" name="action" value="signin" class="pure-button pure-button-primary">Sign In</button>
<button type="submit" name="action" value="register" class="pure-button pure-button-primary">Register</button> <button type="submit" name="action" value="register" class="pure-button pure-button-primary">Register</button>