From 647f7422762fd0171cb6cc4472fbbc311f9137af Mon Sep 17 00:00:00 2001 From: Nicholas Ngai Date: Tue, 8 Oct 2019 15:51:52 -0700 Subject: [PATCH 1/4] Send JSON data instead of array for /analyze endpoint --- server/gui.py | 6 ++++-- src/App.js | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/server/gui.py b/server/gui.py index c722f71..d3e19d7 100644 --- a/server/gui.py +++ b/server/gui.py @@ -93,8 +93,10 @@ def compute_accuracy(data): typed_text = data.get("typedText", [""])[0] start_time = float(data["startTime"][0]) end_time = float(data["endTime"][0]) - return [typing_test.wpm(typed_text, end_time - start_time), - typing_test.accuracy(typed_text, prompted_text)] + return { + "wpm": typing_test.wpm(typed_text, end_time - start_time), + "accuracy": typing_test.accuracy(typed_text, prompted_text), + } def similar(w, v, n): diff --git a/src/App.js b/src/App.js index 1a9529e..efd175b 100644 --- a/src/App.js +++ b/src/App.js @@ -123,8 +123,8 @@ class App extends Component { endTime: this.getCurrTime(), }).done((data) => { this.setState({ - wpm: data[0].toFixed(1), - accuracy: data[1].toFixed(1), + wpm: data.wpm.toFixed(1), + accuracy: data.accuracy.toFixed(1), currTime: this.getCurrTime(), }); }); From b972a3628267c42ace830e87b34b4c457463dbdd Mon Sep 17 00:00:00 2001 From: Nicholas Ngai Date: Wed, 12 Feb 2020 16:01:29 -0800 Subject: [PATCH 2/4] Track user times in session for leaderboard protection --- server/flask_app.py | 81 +++++++++++++++++++++++++++++++++++++-------- src/App.js | 3 -- src/NamePrompt.js | 1 + 3 files changed, 69 insertions(+), 16 deletions(-) diff --git a/server/flask_app.py b/server/flask_app.py index 6d7a37a..26ac276 100644 --- a/server/flask_app.py +++ b/server/flask_app.py @@ -6,13 +6,14 @@ from random import randrange from urllib.parse import parse_qs -from flask import Flask, jsonify, request, send_from_directory +from flask import Flask, jsonify, request, send_from_directory, session from sqlalchemy import create_engine, text import gui import typing_test app = Flask(__name__, static_url_path="", static_folder="static") +app.config["SECRET_KEY"] = os.urandom(32) MIN_PLAYERS = 2 MAX_PLAYERS = 4 @@ -20,6 +21,9 @@ MAX_WAIT = timedelta(seconds=5) +WPM_THRESHOLD = 30 + + if __name__ == "__main__": engine = create_engine("mysql://localhost/cats") else: @@ -57,12 +61,14 @@ class State: State = State() +def get_from_gui(f, request): + return f(parse_qs(request.get_data().decode("ascii"))) def passthrough(path): def decorator(f): @app.route(path, methods=["POST"], endpoint=f.__name__) def decorated(): - return jsonify(f(parse_qs(request.get_data().decode("ascii")))) + return jsonify(get_from_gui(f, request)) return decorated return decorator @@ -73,8 +79,29 @@ def index(): return send_from_directory("static", "index.html") -passthrough("/request_paragraph")(gui.request_paragraph) -passthrough("/analyze")(gui.compute_accuracy) +@app.route("/request_paragraph", methods=["POST"]) +def request_paragraph(): + paragraph = get_from_gui(gui.request_paragraph, request) + session.clear() + session["paragraph"] = paragraph + return paragraph + + +@app.route("/analyze", methods=["POST"]) +def analyze(): + analysis = get_from_gui(gui.compute_accuracy, request) + claimed = request.form.get("promptedText") + typed = request.form.get("typedText") + + if claimed == session.get("paragraph") and 'start' not in session: + session["start"] = time.time() + + if claimed == session.get("paragraph") and typed == claimed and 'end' not in session: + session["end"] = time.time() + + return analysis + + passthrough("/autocorrect")(gui.autocorrect) passthrough("/fastest_words")(lambda x: gui.fastest_words(x, lambda targets: [State.progress[target] for target in targets["targets[]"]])) @@ -172,12 +199,9 @@ def record_progress(id, progress, updated): prompt = State.game_data[game_id]["text"] wpm = len(prompt) / (time.time() - State.progress[id][0][1]) * 60 / 5 - if wpm > 200: - return "" - with engine.connect() as conn: conn.execute("INSERT INTO leaderboard (username, wpm) VALUES (?, ?)", ["", wpm]) - return "" + return "", 204 @app.route("/request_progress", methods=["POST"]) @@ -197,16 +221,47 @@ def request_all_progress(): @app.route("/record_wpm", methods=["POST"]) def record_name(): + if "username" not in request.form: + return jsonify( + { + "error": "No username present", + } + ), 400 + elif len(request.form["username"]) > 30: + return jsonify( + { + "error": "Username too long", + } + ), 400 + + if "wpm" not in request.form: + return jsonify( + { + "error": "No WPM present", + } + ), 400 + + if "start" not in session or "end" not in session or "paragraph" not in session: + return jsonify( + { + "error": "Unable to calculate real WPM from session", + } + ), 400 + username = request.form.get("username") wpm = float(request.form.get("wpm")) - confirm_string = request.form.get("confirm") - if len(username) > 30 or wpm > 200 or confirm_string != "If you want to mess around, send requests to /record_meme! Leave this endpoint for legit submissions please. Don't be a jerk and ruin this for everyone, thanks!": - return record_meme() + real_wpm = typing_test.wpm(session["paragraph"], session["end"] - session["start"]) + if abs(wpm - real_wpm) > WPM_THRESHOLD: + return jsonify( + { + "error": "Invalid WPM", + } + ), 400 with engine.connect() as conn: conn.execute("INSERT INTO leaderboard (username, wpm) VALUES (%s, %s)", [username, wpm]) - return "" + return "", 204 @app.route("/record_meme", methods=["POST"]) @@ -216,7 +271,7 @@ def record_meme(): with engine.connect() as conn: conn.execute("INSERT INTO memeboard (username, wpm) VALUES (%s, %s)", [username, wpm]) - return "" + return "", 204 @app.route("/wpm_threshold", methods=["POST"]) diff --git a/src/App.js b/src/App.js index efd175b..46c2048 100644 --- a/src/App.js +++ b/src/App.js @@ -287,9 +287,6 @@ class App extends Component { $.post("/record_wpm", { username, wpm: this.state.wpm, - confirm: "If you want to mess around, send requests" - + " to /record_meme! Leave this endpoint for legit submissions please. Don't be a" - + " jerk and ruin this for everyone, thanks!", }); this.hideUsernameEntry(); }; diff --git a/src/NamePrompt.js b/src/NamePrompt.js index ed9008d..7dd0591 100644 --- a/src/NamePrompt.js +++ b/src/NamePrompt.js @@ -30,6 +30,7 @@ export default function NamePrompt({ show, onHide, onSubmit }) { id="exampleInputEmail1" aria-describedby="emailHelp" placeholder="Enter username" + maxlength="30" /> Please don't name yourself anything inappropriate! From a1e0004efa1e3c7f408d3023cb6ec9c01ef3f3c5 Mon Sep 17 00:00:00 2001 From: Nicholas Ngai Date: Wed, 12 Feb 2020 18:23:01 -0800 Subject: [PATCH 3/4] Add nonce to session to prevent replay attacks --- server/flask_app.py | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/server/flask_app.py b/server/flask_app.py index 26ac276..fe73c24 100644 --- a/server/flask_app.py +++ b/server/flask_app.py @@ -34,16 +34,14 @@ statement = text( """CREATE TABLE IF NOT EXISTS leaderboard ( username varchar(128), - wpm integer, - PRIMARY KEY (`username`) + wpm integer );""" ) conn.execute(statement) statement = text( """CREATE TABLE IF NOT EXISTS memeboard ( username varchar(128), - wpm integer, - PRIMARY KEY (`username`) + wpm integer );""" ) conn.execute(statement) @@ -61,6 +59,8 @@ class State: State = State() +used_wpm_nonces = {} + def get_from_gui(f, request): return f(parse_qs(request.get_data().decode("ascii"))) @@ -98,6 +98,10 @@ def analyze(): if claimed == session.get("paragraph") and typed == claimed and 'end' not in session: session["end"] = time.time() + wpm_nonce = os.urandom(32) + while wpm_nonce in used_wpm_nonces: + wpm_nonce = os.urandom(32) + session["wpm_nonce"] = wpm_nonce return analysis @@ -241,17 +245,31 @@ def record_name(): } ), 400 - if "start" not in session or "end" not in session or "paragraph" not in session: + if "start" not in session or "end" not in session or "paragraph" not in session or "wpm_nonce" not in session: return jsonify( { "error": "Unable to calculate real WPM from session", } ), 400 + if session["wpm_nonce"] in used_wpm_nonces: + return jsonify( + { + "error": "Nonce already used" + } + ), 400 + username = request.form.get("username") wpm = float(request.form.get("wpm")) real_wpm = typing_test.wpm(session["paragraph"], session["end"] - session["start"]) + + used_wpm_nonces[session["wpm_nonce"]] = session["wpm_nonce"] + session.pop("paragraph", None) + session.pop("start", None) + session.pop("end", None) + session.pop("wpm_nonce", None) + if abs(wpm - real_wpm) > WPM_THRESHOLD: return jsonify( { From f6886166a73c31046d66780353b0472d17f64b5e Mon Sep 17 00:00:00 2001 From: Nicholas Ngai Date: Wed, 12 Feb 2020 18:23:33 -0800 Subject: [PATCH 4/4] Use session.pop for optional unassignment and decrease WPM threshold --- server/flask_app.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/server/flask_app.py b/server/flask_app.py index fe73c24..6fa7f29 100644 --- a/server/flask_app.py +++ b/server/flask_app.py @@ -21,7 +21,7 @@ MAX_WAIT = timedelta(seconds=5) -WPM_THRESHOLD = 30 +WPM_THRESHOLD = 10 if __name__ == "__main__": @@ -82,7 +82,6 @@ def index(): @app.route("/request_paragraph", methods=["POST"]) def request_paragraph(): paragraph = get_from_gui(gui.request_paragraph, request) - session.clear() session["paragraph"] = paragraph return paragraph @@ -95,6 +94,8 @@ def analyze(): if claimed == session.get("paragraph") and 'start' not in session: session["start"] = time.time() + session.pop("end", None) + session.pop("wpm_nonce", None) if claimed == session.get("paragraph") and typed == claimed and 'end' not in session: session["end"] = time.time() @@ -152,6 +153,7 @@ def request_match(): len(State.queue) >= MIN_PLAYERS: # start game! curr_text = gui.request_paragraph(None) + session["paragraph"] = curr_text game_id = get_id() for player in State.queue: @@ -265,10 +267,6 @@ def record_name(): real_wpm = typing_test.wpm(session["paragraph"], session["end"] - session["start"]) used_wpm_nonces[session["wpm_nonce"]] = session["wpm_nonce"] - session.pop("paragraph", None) - session.pop("start", None) - session.pop("end", None) - session.pop("wpm_nonce", None) if abs(wpm - real_wpm) > WPM_THRESHOLD: return jsonify(