diff --git a/tests/test_security.py b/tests/test_security.py
new file mode 100644
index 0000000..efcdfd7
--- /dev/null
+++ b/tests/test_security.py
@@ -0,0 +1,151 @@
+import pytest
+from unittest.mock import patch, MagicMock
+from werkzeug.security import generate_password_hash
+from wine import app
+
+
+@pytest.fixture
+def client():
+ app.config['TESTING'] = True
+ with app.test_client() as client:
+ yield client
+
+
+class TestDebugMode:
+ def test_debug_mode_disabled(self):
+ # Debug mode must be off to prevent Werkzeug interactive debugger exposure
+ assert app.debug is False
+
+
+class TestSecretKey:
+ def test_secret_key_is_not_hardcoded_default(self):
+ # Secret key must not be the known-bad default value
+ assert app.secret_key != b'your_secret_key'
+ assert app.secret_key != 'your_secret_key'
+
+ def test_secret_key_has_sufficient_length(self):
+ # Secret key should be at least 16 bytes
+ key = app.secret_key
+ key_bytes = key if isinstance(key, bytes) else key.encode()
+ assert len(key_bytes) >= 16
+
+
+class TestLoginHashedPassword:
+ def _make_mock_account(self, username='testuser', password='TestPass1',
+ email='test@example.com'):
+ hashed = generate_password_hash(password)
+ return (1, username, hashed, email)
+
+ def test_login_succeeds_with_correct_hashed_password(self, client):
+ # Login must succeed when the submitted password matches the stored hash
+ account = self._make_mock_account()
+ mock_cursor = MagicMock()
+ mock_cursor.fetchone.return_value = account
+
+ with patch('sqlite3.connect') as mock_connect:
+ mock_connect.return_value.cursor.return_value = mock_cursor
+ response = client.post('/login', data={
+ 'username': 'testuser',
+ 'password': 'TestPass1'
+ })
+
+ assert b'Logged in successfully!' in response.data
+
+ def test_login_fails_with_wrong_password(self, client):
+ # Login must fail when password does not match the stored hash
+ account = self._make_mock_account()
+ mock_cursor = MagicMock()
+ mock_cursor.fetchone.return_value = account
+
+ with patch('sqlite3.connect') as mock_connect:
+ mock_connect.return_value.cursor.return_value = mock_cursor
+ response = client.post('/login', data={
+ 'username': 'testuser',
+ 'password': 'WrongPassword'
+ })
+
+ assert b'Incorrect username/password!' in response.data
+
+ def test_login_fails_with_plaintext_password_in_db(self, client):
+ # If the DB somehow contains plaintext, login must still fail (not bypass hash check)
+ mock_cursor = MagicMock()
+ mock_cursor.fetchone.return_value = (1, 'testuser', 'TestPass1', 'test@example.com')
+
+ with patch('sqlite3.connect') as mock_connect:
+ mock_connect.return_value.cursor.return_value = mock_cursor
+ response = client.post('/login', data={
+ 'username': 'testuser',
+ 'password': 'TestPass1'
+ })
+
+ assert b'Logged in successfully!' not in response.data
+
+
+class TestRegisterPasswordHash:
+ def test_register_stores_hashed_password(self, client):
+ # Registration must hash the password before storing it
+ mock_cursor = MagicMock()
+ mock_cursor.fetchone.return_value = None # user does not exist
+
+ inserted_values = {}
+
+ def capture_execute(sql, params=None):
+ if params and 'INSERT' in sql.upper():
+ inserted_values['password'] = params[1]
+
+ mock_cursor.execute.side_effect = capture_execute
+ mock_cursor.connection.commit.return_value = None
+
+ with patch('sqlite3.connect') as mock_connect:
+ mock_connect.return_value.cursor.return_value = mock_cursor
+ client.post('/register', data={
+ 'username': 'newuser',
+ 'password': 'MySecret1',
+ 'confirmpw': 'MySecret1',
+ 'email': 'new@example.com'
+ })
+
+ assert 'password' in inserted_values
+ stored_pw = inserted_values['password']
+ # Stored value must not be the plaintext password
+ assert stored_pw != 'MySecret1'
+ # Stored value must be a valid Werkzeug hash
+ from werkzeug.security import check_password_hash
+ assert check_password_hash(stored_pw, 'MySecret1')
+
+ def test_register_rejects_mismatched_passwords(self, client):
+ # Server must reject registration when password and confirmation differ
+ mock_cursor = MagicMock()
+ mock_cursor.fetchone.return_value = None
+
+ with patch('sqlite3.connect') as mock_connect:
+ mock_connect.return_value.cursor.return_value = mock_cursor
+ response = client.post('/register', data={
+ 'username': 'newuser',
+ 'password': 'MySecret1',
+ 'confirmpw': 'DifferentPassword',
+ 'email': 'new@example.com'
+ })
+
+ assert b'Passwords do not match!' in response.data
+
+
+class TestPredictAuthGuard:
+ def test_predict_get_unauthenticated_redirects_to_login(self, client):
+ # Unauthenticated GET /predict must redirect to login
+ response = client.get('/predict')
+ assert response.status_code == 302
+ assert '/login' in response.headers['Location']
+
+ def test_predict_post_unauthenticated_redirects_to_login(self, client):
+ # Unauthenticated POST /predict must redirect to login
+ response = client.post('/predict', data={
+ 'alcohol': '13.0', 'malic_acid': '2.0', 'ash': '2.3',
+ 'alcalinity_of_ash': '15.0', 'magnesium': '100.0',
+ 'total_phenols': '2.5', 'flavanoids': '2.5',
+ 'nonflavanoid_phenols': '0.3', 'proanthocyanins': '1.5',
+ 'color_intensity': '5.0', 'hue': '1.0',
+ 'od280_od315_of_diluted_wines': '3.0', 'proline': '700.0'
+ })
+ assert response.status_code == 302
+ assert '/login' in response.headers['Location']
diff --git a/wine.py b/wine.py
index 0e356f0..9c02514 100644
--- a/wine.py
+++ b/wine.py
@@ -15,6 +15,8 @@
import pickle
import signal
import re
+import os
+from werkzeug.security import generate_password_hash, check_password_hash
# Create a signal handler to shut down the server gracefully on shutdown
def shutdown(signal_number, frame):
@@ -26,7 +28,7 @@ def shutdown(signal_number, frame):
# Load the model from the pickle file
app = Flask(__name__)
model = pickle.load(open('model.pkl', 'rb'))
-app.secret_key = 'your_secret_key'
+app.secret_key = os.environ.get('FLASK_SECRET_KEY', os.urandom(32))
# # Configure MySQL service
# app.config['MYSQL_HOST'] = 'localhost'
@@ -37,7 +39,7 @@ def shutdown(signal_number, frame):
# mysql = MySQL(app)
# Turn debugging mode off for production
-app.debug = True
+app.debug = False
# create a routine to notify test scipts that the server is up and running
@app.route("/health")
@@ -60,10 +62,10 @@ def login():
password = request.form['password']
cursor = sqlite3.connect('wineusers.db').cursor()
if cursor:
- cursor.execute('SELECT * FROM accounts WHERE username = ? AND password = ?', (username, password))
+ cursor.execute('SELECT * FROM accounts WHERE username = ?', (username,))
account = cursor.fetchone()
cursor.close()
- if account:
+ if account and check_password_hash(account[2], password):
session['loggedin'] = True
session['id'] = account[0]
session['username'] = account[1]
@@ -101,13 +103,11 @@ def register():
if request.method == 'POST' and 'username' in request.form and 'password' in request.form and 'email' in request.form :
username = request.form['username']
password = request.form['password']
+ confirmpw = request.form.get('confirmpw', '')
email = request.form['email']
cursor = sqlite3.connect('wineusers.db').cursor()
cursor.execute('SELECT * FROM accounts WHERE username = ?', (username, ))
account = cursor.fetchone()
- # cursor = mysql.connection.cursor()
- # cursor.execute('SELECT * FROM accounts WHERE username = % s', (username, ))
- # account = cursor.fetchone()
if account:
msg = 'Account already exists!'
elif not re.match(r'[^@]+@[^@]+\.[^@]+', email):
@@ -116,12 +116,13 @@ def register():
msg = 'Username must contain only characters and numbers!'
elif not username or not password or not email:
msg = 'Please fill out the form!'
+ elif password != confirmpw:
+ msg = 'Passwords do not match!'
else:
- cursor.execute('INSERT INTO accounts VALUES (NULL, ?, ?, ?)', (username, password, email, ))
+ hashed_password = generate_password_hash(password)
+ cursor.execute('INSERT INTO accounts VALUES (NULL, ?, ?, ?)', (username, hashed_password, email, ))
cursor.connection.commit()
cursor.close()
- # cursor.execute('INSERT INTO accounts VALUES (NULL, % s, % s, % s)', (username, password, email, ))
- # mysql.connection.commit()
msg = 'You have successfully registered!'
elif request.method == 'POST':
msg = 'Please fill out the form!'
@@ -143,6 +144,8 @@ def contact():
# predict uses the machine learning model to predict the wine type based on the user's input
@app.route("/predict", methods=['GET', 'POST'])
def predict():
+ if 'username' not in session:
+ return redirect(url_for('login'))
if request.method == 'GET':
return render_template('wine.html')
else:
@@ -177,14 +180,12 @@ def change_password():
return render_template('profile.html', username_text='Username: {}'.format(session['username']), email_text='Email: {}'.format(session['email']), profile_text='New passwords do not match!')
cursor = sqlite3.connect('wineusers.db').cursor()
- cursor.execute('SELECT * FROM accounts WHERE username = ? AND password = ?', (session['username'], current_password))
+ cursor.execute('SELECT * FROM accounts WHERE username = ?', (session['username'],))
account = cursor.fetchone()
- # cursor = mysql.connection.cursor()
- # cursor.execute('SELECT * FROM accounts WHERE username = %s AND password = %s', (session['username'], current_password))
- # account = cursor.fetchone()
- if account:
- cursor.execute('UPDATE accounts SET password = ? WHERE username = ?', (new_password, session['username']))
+ if account and check_password_hash(account[2], current_password):
+ hashed_new_password = generate_password_hash(new_password)
+ cursor.execute('UPDATE accounts SET password = ? WHERE username = ?', (hashed_new_password, session['username']))
cursor.connection.commit()
cursor.close()
# cursor.execute('UPDATE accounts SET password = %s WHERE username = %s', (new_password, session['username']))
@@ -192,7 +193,7 @@ def change_password():
return render_template('profile.html', username_text='Username: {}'.format(session['username']), email_text='Email: {}'.format(session['email']), profile_text='Password changed successfully!')
else:
cursor.close()
- return render_template('profile.html', username_text='Username: {}'.format(session['username']), email_text='Email: {}'.format(session['email']), current_password=current_password, profile_text='Current password is incorrect!')
+ return render_template('profile.html', username_text='Username: {}'.format(session['username']), email_text='Email: {}'.format(session['email']), profile_text='Current password is incorrect!')
else:
return redirect(url_for('login'))
diff --git a/wineusers.db b/wineusers.db
index 9eb82801f5e74ebf35a0a6460767ad58c25a3ef8..c881ca75ad269c3edf4356e481f38845097c197b 100644
GIT binary patch
delta 239
zcmWN^y-Gtd6ae7ViknS4IViXkdMC;6$;ovRaT7`x1*e`QCxVKWD&lpqy16MnN*ABT
z-M7$h`&RSS{A0J*|J*ws^~J&C`?e5bLpDM_%Vs?qeh
2vm)FzYmYBTF
z4zIXYUUs~o?w0TKvRgc~)&7TRlFzJD;kPr|WKZ<1UW6%dvOP2hbUkg}|t4RS{Du
zJ`-lNF|fg8xR4DHiZz;J(K#z$bwHD_BKQh9TA<>Ilmef8i6(kQW@GwWu<+Ah9wpH6*pT1cW!glouBO06a|>5C8xG
From a7c8df905d42c55233bc6ef1b09b749398704e21 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 11 Jun 2026 22:34:38 +0000
Subject: [PATCH 3/3] security: address code review - close db connections,
document secret key, fix template indentation
---
templates/login.html | 6 +++---
wine.py | 8 ++++++++
2 files changed, 11 insertions(+), 3 deletions(-)
diff --git a/templates/login.html b/templates/login.html
index 15cb77b..765c41a 100644
--- a/templates/login.html
+++ b/templates/login.html
@@ -94,9 +94,9 @@ Please Login:
-
-
-