🔒 Fixed some security issues. Please upgrade!

This commit is contained in:
Mawoka
2024-01-21 19:22:56 +01:00
parent e8e330e385
commit 188a935012
12 changed files with 86 additions and 48 deletions
+2
View File
@@ -102,4 +102,6 @@ meilisearch = MeiliSearch.Client(settings().meilisearch_url)
ALLOWED_TAGS_FOR_QUIZ = ["b", "strong", "i", "em", "small", "mark", "del", "sub", "sup"] ALLOWED_TAGS_FOR_QUIZ = ["b", "strong", "i", "em", "small", "mark", "del", "sub", "sup"]
ALLOWED_MIME_TYPES = ["image/png", "video/mp4", "image/jpeg", "image/gif", "image/webp"]
server_regex = rf"^{re.escape(settings().root_address)}/api/v1/storage/download/.{{36}}--.{{36}}$" server_regex = rf"^{re.escape(settings().root_address)}/api/v1/storage/download/.{{36}}--.{{36}}$"
+3 -3
View File
@@ -72,10 +72,10 @@ async def finish_edit(edit_id: str, quiz_input: QuizInput):
if session_data is None: if session_data is None:
raise HTTPException(status_code=401, detail="Edit ID not found!") raise HTTPException(status_code=401, detail="Edit ID not found!")
session_data = EditSessionData.parse_raw(session_data) session_data = EditSessionData.parse_raw(session_data)
quiz_input.title = html.unescape(bleach.clean(quiz_input.title, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True)) quiz_input.title = bleach.clean(quiz_input.title, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True)
quiz_input.description = html.unescape(bleach.clean(quiz_input.description, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True)) quiz_input.description = bleach.clean(quiz_input.description, tags=ALLOWED_TAGS_FOR_QUIZ, strip=True)
if quiz_input.background_color is not None: if quiz_input.background_color is not None:
quiz_input.background_color = html.unescape(bleach.clean(quiz_input.background_color, tags=[], strip=True)) quiz_input.background_color = bleach.clean(quiz_input.background_color, tags=[], strip=True)
for i, question in enumerate(quiz_input.questions): for i, question in enumerate(quiz_input.questions):
if question.type == QuizQuestionType.ABCD: if question.type == QuizQuestionType.ABCD:
+3 -3
View File
@@ -17,13 +17,13 @@ router = APIRouter()
router.include_router(shares_router, prefix="/shares") router.include_router(shares_router, prefix="/shares")
@router.post("/create") @router.post("/create", response_model_exclude={"user": ...})
async def create_quiztivity(data: QuizTivityInput, user: User = Depends(get_current_user)) -> QuizTivity: async def create_quiztivity(data: QuizTivityInput, user: User = Depends(get_current_user)) -> QuizTivity:
quiztivity = QuizTivity.parse_obj({**data.dict(), "user": user, "id": uuid4(), "created_at": datetime.now()}) quiztivity = QuizTivity.parse_obj({**data.dict(), "user": user, "id": uuid4(), "created_at": datetime.now()})
return await quiztivity.save() return await quiztivity.save()
@router.get("/{uuid}") @router.get("/{uuid}", response_model_exclude={"user": ...})
async def get_quiztivity(uuid: UUID) -> QuizTivity: async def get_quiztivity(uuid: UUID) -> QuizTivity:
quiztivity = await QuizTivity.objects.get_or_none(id=uuid) quiztivity = await QuizTivity.objects.get_or_none(id=uuid)
if quiztivity is None: if quiztivity is None:
@@ -31,7 +31,7 @@ async def get_quiztivity(uuid: UUID) -> QuizTivity:
return quiztivity return quiztivity
@router.put("/{uuid}") @router.put("/{uuid}", response_model_exclude={"user": ...})
async def put_quiztivity(data: QuizTivityInput, uuid: UUID, user: User = Depends(get_current_user)) -> QuizTivity: async def put_quiztivity(data: QuizTivityInput, uuid: UUID, user: User = Depends(get_current_user)) -> QuizTivity:
quiztivity = await QuizTivity.objects.get_or_none(id=uuid, user=user) quiztivity = await QuizTivity.objects.get_or_none(id=uuid, user=user)
if quiztivity is None: if quiztivity is None:
+3 -2
View File
@@ -10,7 +10,7 @@ from fastapi.responses import StreamingResponse, RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
from classquiz.auth import get_current_user from classquiz.auth import get_current_user
from classquiz.config import settings, storage, arq from classquiz.config import settings, storage, arq, ALLOWED_MIME_TYPES
from classquiz.db.models import User, StorageItem, PublicStorageItem, UpdateStorageItem, PrivateStorageItem from classquiz.db.models import User, StorageItem, PublicStorageItem, UpdateStorageItem, PrivateStorageItem
from classquiz.helpers import check_image_string from classquiz.helpers import check_image_string
from classquiz.storage.errors import DownloadingFailedError from classquiz.storage.errors import DownloadingFailedError
@@ -119,10 +119,11 @@ async def download_file_head(file_name: str) -> Response:
@router.post("/") @router.post("/")
async def upload_file(file: UploadFile = File(), user: User = Depends(get_current_user)) -> PublicStorageItem: async def upload_file(file: UploadFile = File(), user: User = Depends(get_current_user)) -> PublicStorageItem:
if file.content_type not in ALLOWED_MIME_TYPES:
raise HTTPException(status_code=422, detail="Unsupported")
if user.storage_used > settings.free_storage_limit: if user.storage_used > settings.free_storage_limit:
raise HTTPException(status_code=409, detail="Storage limit reached") raise HTTPException(status_code=409, detail="Storage limit reached")
file_id = uuid4() file_id = uuid4()
size = 0 size = 0
file_obj = StorageItem( file_obj = StorageItem(
id=file_id, id=file_id,
+1 -2
View File
@@ -14,7 +14,6 @@ from fastapi import APIRouter, Response, HTTPException, Request, Depends, status
from fastapi.background import BackgroundTasks from fastapi.background import BackgroundTasks
from fastapi.responses import JSONResponse, RedirectResponse, PlainTextResponse from fastapi.responses import JSONResponse, RedirectResponse, PlainTextResponse
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
import html
from jose import jwt, JWTError from jose import jwt, JWTError
@@ -78,7 +77,7 @@ async def create_user(user: RouteUser, background_task: BackgroundTasks) -> User
raise HTTPException(status_code=409, detail="User already exists") raise HTTPException(status_code=409, detail="User already exists")
user.password = get_password_hash(user.password) user.password = get_password_hash(user.password)
user.username = html.unescape(bleach.clean(user.username, tags=[], strip=True)) user.username = bleach.clean(user.username, tags=[], strip=True)
if len(user.username) == 32: if len(user.username) == 32:
return JSONResponse({"details": "Username mustn't be 32 characters long"}, 400) return JSONResponse({"details": "Username mustn't be 32 characters long"}, 400)
await user.save() await user.save()
+19 -6
View File
@@ -7,10 +7,10 @@ import os
import urllib.parse import urllib.parse
import pyotp import pyotp
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from classquiz.auth import get_current_user from classquiz.auth import get_current_user, verify_password
from classquiz.db.models import User from classquiz.db.models import User
router = APIRouter() router = APIRouter()
@@ -20,10 +20,16 @@ class GetBackupCodeResponse(BaseModel):
code: str code: str
@router.get("/backup_code", response_model=GetBackupCodeResponse) class RequirePasswordForAction(BaseModel):
async def get_backup_code(user: User = Depends(get_current_user)): password: str
@router.post("/backup_code", response_model=GetBackupCodeResponse)
async def get_backup_code(data: RequirePasswordForAction, user: User = Depends(get_current_user)):
backup_code = os.urandom(32).hex() backup_code = os.urandom(32).hex()
user = await User.objects.get(id=user.id) user = await User.objects.get(id=user.id)
if not verify_password(data.password, user.password):
raise HTTPException(status_code=401, detail="Invalid")
user.backup_code = backup_code user.backup_code = backup_code
await user.update() await user.update()
return GetBackupCodeResponse(code=backup_code) return GetBackupCodeResponse(code=backup_code)
@@ -31,11 +37,14 @@ async def get_backup_code(user: User = Depends(get_current_user)):
class SetRequirePassword(BaseModel): class SetRequirePassword(BaseModel):
require_password: bool require_password: bool
password: str
@router.post("/require_password", response_model=SetRequirePassword) @router.post("/require_password", response_model=SetRequirePassword)
async def set_require_password(data: SetRequirePassword, user: User = Depends(get_current_user)): async def set_require_password(data: SetRequirePassword, user: User = Depends(get_current_user)):
user = await User.objects.get(id=user.id) user = await User.objects.get(id=user.id)
if not verify_password(data.password, user.password):
raise HTTPException(status_code=401, detail="Invalid")
user.require_password = data.require_password user.require_password = data.require_password
await user.update() await user.update()
return data return data
@@ -47,8 +56,10 @@ class SetTotpUpResponse(BaseModel):
@router.post("/totp", response_model=SetTotpUpResponse) @router.post("/totp", response_model=SetTotpUpResponse)
async def set_totp_up(user: User = Depends(get_current_user)): async def set_totp_up(data: RequirePasswordForAction, user: User = Depends(get_current_user)):
user = await User.objects.get(id=user.id) user = await User.objects.get(id=user.id)
if not verify_password(data.password, user.password):
raise HTTPException(status_code=401, detail="Invalid")
user.totp_secret = pyotp.random_base32() user.totp_secret = pyotp.random_base32()
url = pyotp.totp.TOTP(user.totp_secret).provisioning_uri( url = pyotp.totp.TOTP(user.totp_secret).provisioning_uri(
name=urllib.parse.quote(user.username), issuer_name="ClassQuiz" name=urllib.parse.quote(user.username), issuer_name="ClassQuiz"
@@ -71,7 +82,9 @@ async def get_totp_status(user: User = Depends(get_current_user)):
@router.delete("/totp") @router.delete("/totp")
async def disable_totp(user: User = Depends(get_current_user)): async def disable_totp(data: RequirePasswordForAction, user: User = Depends(get_current_user)):
user = await User.objects.get(id=user.id) user = await User.objects.get(id=user.id)
if not verify_password(data.password, user.password):
raise HTTPException(status_code=401, detail="Invalid")
user.totp_secret = None user.totp_secret = None
await user.update() await user.update()
+12 -4
View File
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from webauthn.helpers.cose import COSEAlgorithmIdentifier from webauthn.helpers.cose import COSEAlgorithmIdentifier
from classquiz.auth import get_current_user from classquiz.auth import get_current_user, verify_password
from classquiz.db.models import User, FidoCredentials from classquiz.db.models import User, FidoCredentials
from classquiz.config import redis, settings from classquiz.config import redis, settings
@@ -28,9 +28,15 @@ settings = settings()
router = APIRouter() router = APIRouter()
@router.get("/add_key", response_model=PublicKeyCredentialCreationOptions) class RequirePasswordForAction(BaseModel):
async def request_add_key_data(user: User = Depends(get_current_user)): password: str
@router.post("/add_key_init", response_model=PublicKeyCredentialCreationOptions)
async def request_add_key_data(data: RequirePasswordForAction, user: User = Depends(get_current_user)):
user = await User.objects.select_related("fidocredentialss").get(id=user.id) user = await User.objects.select_related("fidocredentialss").get(id=user.id)
if not verify_password(data.password, user.password):
raise HTTPException(status_code=401, detail="Invalid")
options = generate_registration_options( options = generate_registration_options(
rp_id=urllib.parse.urlparse(settings.root_address).hostname, rp_id=urllib.parse.urlparse(settings.root_address).hostname,
rp_name="ClassQuiz", rp_name="ClassQuiz",
@@ -86,7 +92,9 @@ async def list_security_keys(user: User = Depends(get_current_user)):
@router.delete("/key/{key_id}") @router.delete("/key/{key_id}")
async def delete_security_key(key_id: int, user: User = Depends(get_current_user)): async def delete_security_key(data: RequirePasswordForAction, key_id: int, user: User = Depends(get_current_user)):
if not verify_password(data.password, user.password):
raise HTTPException(status_code=401, detail="Invalid")
key = await FidoCredentials.objects.get_or_none(pk=key_id, user=user.id) key = await FidoCredentials.objects.get_or_none(pk=key_id, user=user.id)
if key is None: if key is None:
raise HTTPException(status_code=404, detail="Key not found") raise HTTPException(status_code=404, detail="Key not found")
+2 -4
View File
@@ -68,6 +68,7 @@
"felte": "^1.2.7", "felte": "^1.2.7",
"fuse.js": "^6.6.2", "fuse.js": "^6.6.2",
"highlight.js": "^11.7.0", "highlight.js": "^11.7.0",
"i18next": "^22.4.15",
"i18next-browser-languagedetector": "^7.0.1", "i18next-browser-languagedetector": "^7.0.1",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"jws": "^4.0.0", "jws": "^4.0.0",
@@ -104,8 +105,5 @@
"vite-plugin-iso-import": "^1.0.0", "vite-plugin-iso-import": "^1.0.0",
"yup": "^1.1.1" "yup": "^1.1.1"
}, },
"type": "module", "type": "module"
"dependencies": {
"i18next": "^22.4.15"
}
} }
+6 -6
View File
@@ -4,11 +4,6 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
dependencies:
i18next:
specifier: ^22.4.15
version: 22.4.15
devDependencies: devDependencies:
'@beyonk/svelte-mapbox': '@beyonk/svelte-mapbox':
specifier: ^9.0.5 specifier: ^9.0.5
@@ -169,6 +164,9 @@ devDependencies:
highlight.js: highlight.js:
specifier: ^11.7.0 specifier: ^11.7.0
version: 11.7.0 version: 11.7.0
i18next:
specifier: ^22.4.15
version: 22.4.15
i18next-browser-languagedetector: i18next-browser-languagedetector:
specifier: ^7.0.1 specifier: ^7.0.1
version: 7.0.1 version: 7.0.1
@@ -284,6 +282,7 @@ packages:
engines: { node: '>=6.9.0' } engines: { node: '>=6.9.0' }
dependencies: dependencies:
regenerator-runtime: 0.13.11 regenerator-runtime: 0.13.11
dev: true
/@beyonk/svelte-mapbox@9.0.5(svelte@3.58.0): /@beyonk/svelte-mapbox@9.0.5(svelte@3.58.0):
resolution: resolution:
@@ -3490,7 +3489,7 @@ packages:
} }
dependencies: dependencies:
'@babel/runtime': 7.21.0 '@babel/runtime': 7.21.0
dev: false dev: true
/ieee754@1.2.1: /ieee754@1.2.1:
resolution: resolution:
@@ -5065,6 +5064,7 @@ packages:
{ {
integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==
} }
dev: true
/require-directory@2.1.1: /require-directory@2.1.1:
resolution: resolution:
+1 -1
View File
@@ -223,7 +223,7 @@
"abcd_description": "Nur eine Antwort kann ausgewählt werden", "abcd_description": "Nur eine Antwort kann ausgewählt werden",
"voting_description": "Antworten geben keine Punkte", "voting_description": "Antworten geben keine Punkte",
"order_description": "Antworten müssen in die richtige Reihenfolge gebracht werden", "order_description": "Antworten müssen in die richtige Reihenfolge gebracht werden",
"text_description": "Spieler können text eingeben", "text_description": "Spieler können Text eingeben",
"range_description": "Ein Zahlenbereich kann mit einem Schieberegler ausgewählt werden", "range_description": "Ein Zahlenbereich kann mit einem Schieberegler ausgewählt werden",
"check_choice_description": "Alle richtigen Antworten müssen für Punkte ausgewählt werden", "check_choice_description": "Alle richtigen Antworten müssen für Punkte ausgewählt werden",
"need_more_help": "Benötigst du mehr Hilfe?", "need_more_help": "Benötigst du mehr Hilfe?",
@@ -36,18 +36,25 @@ SPDX-License-Identifier: MPL-2.0
if (!browser || user_data?.require_password === undefined) { if (!browser || user_data?.require_password === undefined) {
return; return;
} }
const pw = require_password()
const res = await fetch('/api/v1/users/2fa/require_password', { const res = await fetch('/api/v1/users/2fa/require_password', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ require_password: user_data?.require_password }) body: JSON.stringify({ require_password: user_data?.require_password, password: pw })
}); });
user_data.require_password = (await res.json()).require_password; user_data.require_password = (await res.json()).require_password;
}; };
const require_password = (): string => {
return prompt("Please enter your password to continue")
}
const add_security_key = async () => { const add_security_key = async () => {
const res1 = await fetch('/api/v1/users/webauthn/add_key'); const pw = require_password()
const res1 = await fetch('/api/v1/users/webauthn/add_key_init', {method: "POST", body: JSON.stringify({password: pw}), headers: { 'Content-Type': 'application/json' }});
if (res1.status === 401) {alert("Password probably wrong"); return}
if (!res1.ok) { if (!res1.ok) {
throw Error('Response not ok'); throw Error('Response not ok');
} }
@@ -74,29 +81,36 @@ SPDX-License-Identifier: MPL-2.0
}; };
const remove_security_key = async (key_id: number) => { const remove_security_key = async (key_id: number) => {
await fetch(`/api/v1/users/webauthn/key/${key_id}`, { method: 'DELETE' }); const pw = require_password()
const res = await fetch(`/api/v1/users/webauthn/key/${key_id}`, { method: 'DELETE', body: JSON.stringify({password: pw}), headers: { 'Content-Type': 'application/json' } });
if (res.status === 401) {alert("Password probably wrong"); return}
data = get_data(); data = get_data();
}; };
const disable_totp = async () => { const disable_totp = async () => {
await fetch(`/api/v1/users/2fa/totp`, { method: 'DELETE' }); const pw = require_password()
const res = await fetch(`/api/v1/users/2fa/totp`, { method: 'DELETE', body: JSON.stringify({password: pw}), headers: { 'Content-Type': 'application/json' } });
if (res.status === 401) {alert("Password probably wrong"); return}
data = get_data(); data = get_data();
}; };
const enable_totp = async () => { const enable_totp = async () => {
const res = await fetch('/api/v1/users/2fa/totp', { method: 'POST' }); const pw = require_password()
const res = await fetch('/api/v1/users/2fa/totp', { method: 'POST', body: JSON.stringify({password: pw}), headers: { 'Content-Type': 'application/json' } });
if (res.status === 401) {alert("Password probably wrong"); return}
data = get_data(); data = get_data();
totp_data = await res.json(); totp_data = await res.json();
}; };
const get_backup_code = async () => { const get_backup_code = async () => {
const pw = require_password()
if (!confirm('If you continue, your old backup-code will be removed.')) { if (!confirm('If you continue, your old backup-code will be removed.')) {
return; return;
} }
const res = await fetch('/api/v1/users/2fa/backup_code'); const res = await fetch('/api/v1/users/2fa/backup_code', { method: 'POST', body: JSON.stringify({password: pw}), headers: { 'Content-Type': 'application/json' } });
if (res.status === 401) {alert("Password probably wrong"); return}
backup_code = (await res.json()).code; backup_code = (await res.json()).code;
}; };
$: console.log(user_data?.require_password, 'hello');
</script> </script>
{#await data} {#await data}
@@ -116,11 +130,12 @@ SPDX-License-Identifier: MPL-2.0
</div> </div>
<div class="h-full w-full"> <div class="h-full w-full">
<h2 class="text-center text-2xl">{$t('security_settings.activate_2fa')}</h2> <h2 class="text-center text-2xl">{$t('security_settings.activate_2fa')}</h2>
<div class="flex h-full w-full justify-center flex-col"> <div class="flex h-full w-full justify-center flex-col" class:pointer-events-none={!totp_activated} class:grayscale={!totp_activated} class:opacity-50={!totp_activated}>
<div class="m-auto"> <div class="m-auto">
{#if user_data.require_password} {#if user_data.require_password}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<button <button
disabled={!totp_activated}
on:click={() => { on:click={() => {
user_data.require_password = !user_data.require_password; user_data.require_password = !user_data.require_password;
save_password_required(); save_password_required();
@@ -142,6 +157,7 @@ SPDX-License-Identifier: MPL-2.0
{:else} {:else}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<button <button
disabled={!totp_activated}
type="button" type="button"
on:click={() => { on:click={() => {
user_data.require_password = !user_data.require_password; user_data.require_password = !user_data.require_password;
+10 -9
View File
@@ -2,23 +2,24 @@
# #
# SPDX-License-Identifier: MPL-2.0 # SPDX-License-Identifier: MPL-2.0
CONTAINER_BIN=podman
run_tests() { run_tests() {
pipenv run coverage run -m pytest -s -v --asyncio-mode=strict classquiz/tests pipenv run coverage run -m pytest -s -v --asyncio-mode=strict classquiz/tests
} }
stop() { stop() {
docker container stop classquiz_db $CONTAINER_BIN container stop classquiz_db
docker container stop test_redis $CONTAINER_BIN container stop test_redis
docker container stop test_meili $CONTAINER_BIN container stop test_meili
} }
init() { init() {
mkdir /tmp/storage mkdir /tmp/storage
docker run --rm -d -p 6379:6379 --name test_redis redis:alpine $CONTAINER_BIN run --rm -d -p 6379:6379 --name test_redis redis:alpine
docker run -it --rm -d -p 7700:7700 --name test_meili getmeili/meilisearch:v0.28.0 $CONTAINER_BIN run -it --rm -d -p 7700:7700 --name test_meili getmeili/meilisearch:v0.28.0
docker volume create classquiz_db_data $CONTAINER_BIN volume create classquiz_db_data
docker run --name classquiz_db -p 5432:5432 --rm -d -e POSTGRES_PASSWORD=mysecretpassword -v classquiz_db_data:/var/lib/postgresql/data -e POSTGRES_DB=classquiz postgres $CONTAINER_BIN run --name classquiz_db -p 5432:5432 --rm -d -e POSTGRES_PASSWORD=mysecretpassword -v classquiz_db_data:/var/lib/postgresql/data -e POSTGRES_DB=classquiz postgres
sleep 1 sleep 1
pipenv run alembic upgrade head pipenv run alembic upgrade head
} }
@@ -27,14 +28,14 @@ case $1 in
+) init ;; +) init ;;
-) stop ;; -) stop ;;
a) a)
docker volume rm classquiz_db_data $CONTAINER_BIN volume rm classquiz_db_data
init init
run_tests run_tests
stop stop
;; ;;
prepare) prepare)
stop stop
docker volume rm classquiz_db_data $CONTAINER_BIN volume rm classquiz_db_data
init init
;; ;;
*) *)