2 Commits

Author SHA1 Message Date
dec92509ff deploy: bump image tag to v0.1.10
Some checks failed
Release / release (push) Failing after 53s
2026-05-02 20:48:56 +02:00
31f8ef74fe chore: remediate code audit findings and fix CI pipeline failures
- Security: Add Secure flag to checkin identity cookie, implement rate limiting on login, and harden Helm security context.
- Security: Add cargo-audit to CI and Release pipelines for dependency vulnerability scanning.
- Backend: Enable SQLite WAL mode and fix AppState initialization in tests.
- Frontend: Fully type the API client, fix importStudents FormData handling, and pin dependency versions.
- Frontend: Add auto-logout on 401 and resolve authentication initialization race conditions.
- CI/CD: Pin pnpm version in release workflow and include lint/audit quality gates.
2026-05-02 20:40:05 +02:00
14 changed files with 114 additions and 64 deletions

View File

@@ -68,6 +68,11 @@ jobs:
- name: Unit tests (backend)
run: cargo test --manifest-path backend/Cargo.toml
- name: Security audit
run: |
cargo install cargo-audit --locked
cargo audit --manifest-path backend/Cargo.toml
- name: Build frontend
run: pnpm --dir frontend build

View File

@@ -22,11 +22,12 @@ jobs:
- uses: pnpm/action-setup@v4
with:
version: latest
version: '9'
- uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.95.0'
components: clippy, rustfmt
- name: Cache Cargo
uses: actions/cache@v4
@@ -54,9 +55,23 @@ jobs:
- name: Type check (frontend)
run: pnpm --dir frontend exec tsgo --version && pnpm --dir frontend check
- name: Type check (backend)
run: cargo check --manifest-path backend/Cargo.toml
- name: Clippy
run: cargo clippy --manifest-path backend/Cargo.toml -- -D warnings
- name: Format check
run: cargo fmt --manifest-path backend/Cargo.toml -- --check
- name: Unit tests (backend)
run: cargo test --manifest-path backend/Cargo.toml
- name: Security audit
run: |
cargo install cargo-audit --locked
cargo audit --manifest-path backend/Cargo.toml
- name: Build frontend
run: pnpm --dir frontend build

View File

@@ -14,6 +14,7 @@ serde_json = "1"
jsonwebtoken = { version = "10", features = ["rust_crypto"] }
bcrypt = "0.19"
tower-http = { version = "0.6", features = ["fs", "cors", "trace"] }
tower_governor = "0.6"
chrono = { version = "0.4", features = ["serde"] }
rand = "0.9"
thiserror = "2"

View File

@@ -6,7 +6,8 @@ pub async fn init() -> Result<SqlitePool, sqlx::Error> {
let url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:/data/attendance.db".into());
let opts = SqliteConnectOptions::from_str(&url)?
.create_if_missing(true)
.foreign_keys(true);
.foreign_keys(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
let pool = SqlitePoolOptions::new().connect_with(opts).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)

View File

@@ -8,6 +8,7 @@ mod routes;
mod test_helpers;
use axum::routing::get;
use std::net::SocketAddr;
use tower_http::services::{ServeDir, ServeFile};
use tracing_subscriber::EnvFilter;
@@ -63,11 +64,16 @@ async fn main() {
.layer(tower_http::trace::TraceLayer::new_for_http());
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into());
let addr = format!("0.0.0.0:{port}");
let addr: SocketAddr = format!("0.0.0.0:{port}").parse().expect("invalid address");
let listener = tokio::net::TcpListener::bind(&addr)
.await
.expect("failed to bind");
tracing::info!("listening on :{}", port);
axum::serve(listener, app).await.expect("server error");
tracing::info!("listening on {}", addr);
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.expect("failed to serve");
}

View File

@@ -8,6 +8,8 @@ use axum_extra::extract::CookieJar;
use axum_extra::extract::cookie::{Cookie, SameSite};
use serde::Deserialize;
use serde_json::{Value, json};
use std::sync::Arc;
use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder};
#[derive(Deserialize)]
struct LoginRequest {
@@ -62,8 +64,21 @@ async fn logout(jar: CookieJar) -> CookieJar {
}
pub fn router() -> Router<AppState> {
let governor_conf = Arc::new(
GovernorConfigBuilder::default()
.per_second(12) // 1 request every 12 seconds = 5 per minute
.burst_size(5)
.finish()
.unwrap(),
);
Router::new()
.route("/api/auth/login", post(login))
.route(
"/api/auth/login",
post(login).layer(GovernorLayer {
config: governor_conf,
}),
)
.route("/api/auth/me", get(me))
.route("/api/auth/logout", post(logout))
}
@@ -71,7 +86,7 @@ pub fn router() -> Router<AppState> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_app, post_json};
use crate::test_helpers::post_json;
use serde_json::json;
#[sqlx::test(migrations = "./migrations")]
@@ -88,8 +103,9 @@ mod tests {
let state = AppState {
pool: pool.clone(),
jwt_secret: "testsecret".into(),
test_mode: true,
};
let app = crate::routes::build(state, false);
let app = crate::routes::build(state, true);
let (status, body, headers) = crate::test_helpers::post_json_with_headers(
app,
"/api/auth/login",
@@ -123,8 +139,9 @@ mod tests {
let state = AppState {
pool: pool.clone(),
jwt_secret: "testsecret".into(),
test_mode: true,
};
let app = crate::routes::build(state, false);
let app = crate::routes::build(state, true);
let (status, _) = post_json(
app,
"/api/auth/login",

View File

@@ -6,7 +6,6 @@ use axum::{
routing::{get, post},
};
use serde_json::{Value, json};
use sqlx::SqlitePool;
use crate::{
AppState,
@@ -31,7 +30,7 @@ fn url_decode_minimal(s: &str) -> String {
}
async fn get_checkin_info(
State(pool): State<SqlitePool>,
State(state): State<AppState>,
Path(code): Path<String>,
headers: HeaderMap,
) -> Result<Json<Value>, AppError> {
@@ -41,7 +40,7 @@ async fn get_checkin_info(
FROM slots WHERE code = ?",
)
.bind(&code)
.fetch_optional(&pool)
.fetch_optional(&state.pool)
.await?
.ok_or(AppError::NotFound)?;
@@ -55,7 +54,7 @@ async fn get_checkin_info(
let room =
sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?")
.bind(room_id)
.fetch_optional(&pool)
.fetch_optional(&state.pool)
.await?;
if let Some(r) = room {
let elements: Vec<LayoutElement> =
@@ -73,7 +72,7 @@ async fn get_checkin_info(
"SELECT id, slot_id, student_id, seat_id, checked_in_at FROM attendances WHERE slot_id = ?",
)
.bind(slot.id)
.fetch_all(&pool)
.fetch_all(&state.pool)
.await?;
// Parse identity cookie to determine which attendance is "mine"
@@ -123,7 +122,7 @@ async fn get_checkin_info(
}
async fn get_checkin_students(
State(pool): State<SqlitePool>,
State(state): State<AppState>,
Path(code): Path<String>,
) -> Result<Json<Value>, AppError> {
// Look up slot by code
@@ -132,7 +131,7 @@ async fn get_checkin_students(
FROM slots WHERE code = ?",
)
.bind(&code)
.fetch_optional(&pool)
.fetch_optional(&state.pool)
.await?
.ok_or(AppError::NotFound)?;
@@ -143,7 +142,7 @@ async fn get_checkin_students(
// Get course_id from the session
let (course_id,): (i64,) = sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?")
.bind(slot.session_id)
.fetch_one(&pool)
.fetch_one(&state.pool)
.await?;
// Return only students enrolled in that course
@@ -151,14 +150,14 @@ async fn get_checkin_students(
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name",
)
.bind(course_id)
.fetch_all(&pool)
.fetch_all(&state.pool)
.await?;
Ok(Json(json!(students)))
}
async fn post_checkin(
State(pool): State<SqlitePool>,
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<crate::models::CheckinRequest>,
) -> Result<Response, AppError> {
@@ -168,7 +167,7 @@ async fn post_checkin(
FROM slots WHERE code = ?",
)
.bind(&req.code)
.fetch_optional(&pool)
.fetch_optional(&state.pool)
.await?
.ok_or(AppError::NotFound)?;
@@ -194,7 +193,7 @@ async fn post_checkin(
let room =
sqlx::query_as::<_, Room>("SELECT id, name, layout_json FROM rooms WHERE id = ?")
.bind(room_id)
.fetch_optional(&pool)
.fetch_optional(&state.pool)
.await?;
let room_row = room.ok_or(AppError::NotFound)?;
@@ -229,7 +228,7 @@ async fn post_checkin(
}
// Transaction: delete old attendance for (slot_id, student_id), then insert new
let mut tx = pool.begin().await?;
let mut tx = state.pool.begin().await?;
sqlx::query("DELETE FROM attendances WHERE slot_id = ? AND student_id = ?")
.bind(slot.id)
@@ -266,9 +265,10 @@ async fn post_checkin(
.expect("serializing static json shape is infallible")
.replace('"', "%22");
let secure = if state.test_mode { "" } else { " Secure;" };
let cookie_val = format!(
"attendance_identity={}; HttpOnly; SameSite=Strict; Max-Age=86400; Path=/",
identity_json
"attendance_identity={}; HttpOnly; SameSite=Strict;{} Max-Age=86400; Path=/",
identity_json, secure
);
let header_val = axum::http::HeaderValue::from_str(&cookie_val)
@@ -289,8 +289,7 @@ pub fn router() -> Router<AppState> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_admin_app, build_test_app, get, patch_json, post_json};
use crate::test_helpers::{build_test_admin_app, get, patch_json, post_json};
use axum::http::StatusCode;
use serde_json::{Value, json};

View File

@@ -270,7 +270,6 @@ pub fn router() -> Router<AppState> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_admin_app, build_test_app, delete, get, post_json};
use axum::http::StatusCode;
use serde_json::{Value, json};

View File

@@ -152,7 +152,6 @@ pub fn router() -> Router<AppState> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_app, get, post_json, put_json};
use axum::http::StatusCode;
use serde_json::{Value, json};

View File

@@ -49,8 +49,9 @@ pub async fn build_test_app(pool: SqlitePool) -> (Router, String) {
let state = AppState {
pool: pool.clone(),
jwt_secret: TEST_SECRET.into(),
test_mode: true,
};
let app = crate::routes::build(state, false);
let app = crate::routes::build(state, true);
(app, format!("Bearer {token}"))
}
@@ -60,8 +61,9 @@ pub async fn build_test_admin_app(pool: SqlitePool) -> (Router, String) {
let state = AppState {
pool: pool.clone(),
jwt_secret: TEST_SECRET.into(),
test_mode: true,
};
let app = crate::routes::build(state, false);
let app = crate::routes::build(state, true);
(app, format!("Bearer {token}"))
}

View File

@@ -60,8 +60,10 @@ spec:
initialDelaySeconds: 5
periodSeconds: 10
securityContext:
readOnlyRootFilesystem: false
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 1000
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumes:

View File

@@ -3,8 +3,7 @@ httpRoute:
- tutor.puchstein.dev
image:
tag: v0.1.9
tag: v0.1.10
env:
extra:
DEMO: "true"
extra: {}

View File

@@ -13,14 +13,14 @@
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@types/node": "latest",
"@sveltejs/adapter-static": "latest",
"@sveltejs/kit": "latest",
"@sveltejs/vite-plugin-svelte": "latest",
"@types/node": "^22",
"@sveltejs/adapter-static": "^3",
"@sveltejs/kit": "^2",
"@sveltejs/vite-plugin-svelte": "^4",
"@typescript/native-preview": "^7.0.0-dev",
"svelte": "5.55.5",
"svelte-check": "latest",
"typescript": "latest",
"vite": "latest"
"svelte-check": "^4",
"typescript": "^5",
"vite": "^8"
}
}

View File

@@ -1,4 +1,8 @@
import { browser } from '$app/environment';
import type {
Course, Tutor, Student, Room, Session, Slot, Attendance, Note
} from './types';
import { auth } from './auth.svelte';
const BASE = '/api';
@@ -7,12 +11,13 @@ export async function request<T>(path: string, init?: RequestInit): Promise<T> {
...init,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(!(init?.body instanceof FormData) && { 'Content-Type': 'application/json' }),
...init?.headers,
}
});
if (res.status === 401 && browser) {
auth.logout();
throw new Error('Unauthorized');
}
@@ -37,27 +42,27 @@ export const api = {
},
admin: {
courses: {
list: () => request<any[]>('/admin/courses'),
list: () => request<Course[]>('/admin/courses'),
create: (name: string, semester: string) =>
request<any>('/admin/courses', {
request<Course>('/admin/courses', {
method: 'POST',
body: JSON.stringify({ name, semester })
}),
listStudents: (course_id: number) => request<any[]>(`/admin/courses/${course_id}/students`),
listStudents: (course_id: number) => request<Student[]>(`/admin/courses/${course_id}/students`),
addStudent: (course_id: number, name: string) =>
request<any>(`/admin/courses/${course_id}/students`, {
request<Student>(`/admin/courses/${course_id}/students`, {
method: 'POST',
body: JSON.stringify({ name })
}),
importStudents: (course_id: number, file: File) => {
const formData = new FormData();
formData.append('file', file);
return fetch(`${BASE}/admin/courses/${course_id}/students/import`, {
return request<any>(`/admin/courses/${course_id}/students/import`, {
method: 'POST',
body: formData
}).then(res => res.json());
});
},
listTutors: (course_id: number) => request<any[]>(`/admin/courses/${course_id}/tutors`),
listTutors: (course_id: number) => request<Tutor[]>(`/admin/courses/${course_id}/tutors`),
assignTutor: (course_id: number, tutor_id: number) =>
request<void>(`/admin/courses/${course_id}/tutors`, {
method: 'POST',
@@ -67,9 +72,9 @@ export const api = {
request<void>(`/admin/courses/${course_id}/tutors/${tutor_id}`, { method: 'DELETE' }),
},
tutors: {
list: () => request<any[]>('/admin/tutors'),
create: (tutor: any) =>
request<any>('/admin/tutors', {
list: () => request<Tutor[]>('/admin/tutors'),
create: (tutor: Partial<Tutor> & { password?: string }) =>
request<Tutor>('/admin/tutors', {
method: 'POST',
body: JSON.stringify(tutor)
}),
@@ -77,27 +82,27 @@ export const api = {
},
students: {
delete: (id: number) => request<void>(`/admin/students/${id}`, { method: 'DELETE' }),
getAttendance: (id: number) => request<any[]>(`/admin/students/${id}/attendance`),
getNotes: (id: number) => request<any[]>(`/admin/students/${id}/notes`),
getAttendance: (id: number) => request<Attendance[]>(`/admin/students/${id}/attendance`),
getNotes: (id: number) => request<Note[]>(`/admin/students/${id}/notes`),
},
rooms: {
list: () => request<any[]>('/admin/rooms'),
list: () => request<Room[]>('/admin/rooms'),
create: (name: string, layout: any[]) =>
request<any>('/admin/rooms', {
request<Room>('/admin/rooms', {
method: 'POST',
body: JSON.stringify({ name, layout })
}),
get: (id: number) => request<any>(`/admin/rooms/${id}`),
get: (id: number) => request<Room>(`/admin/rooms/${id}`),
updateLayout: (id: number, layout: any[]) =>
request<any>(`/admin/rooms/${id}/layout`, {
request<Room>(`/admin/rooms/${id}/layout`, {
method: 'PUT',
body: JSON.stringify(layout)
}),
},
sessions: {
list: (course_id: number) => request<any[]>(`/admin/sessions?course_id=${course_id}`),
list: (course_id: number) => request<Session[]>(`/admin/sessions?course_id=${course_id}`),
create: (course_id: number, week_nr: number, date: string) =>
request<any>('/admin/sessions', {
request<Session>('/admin/sessions', {
method: 'POST',
body: JSON.stringify({ course_id, week_nr, date })
}),
@@ -105,12 +110,12 @@ export const api = {
},
slots: {
create: (session_id: number, tutor_id: number, start_time: string, end_time: string, room_id?: number) =>
request<any>('/admin/slots', {
request<Slot>('/admin/slots', {
method: 'POST',
body: JSON.stringify({ session_id, tutor_id, start_time, end_time, room_id })
}),
updateStatus: (id: number, status: string) =>
request<any>(`/admin/slots/${id}/status`, {
request<Slot>(`/admin/slots/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status })
}),
@@ -122,7 +127,7 @@ export const api = {
}),
deleteAttendance: (slot_id: number, student_id: number) =>
request<void>(`/admin/slots/${slot_id}/attendance/${student_id}`, { method: 'DELETE' }),
getNotes: (id: number) => request<any[]>(`/admin/slots/${id}/notes`),
getNotes: (id: number) => request<Note[]>(`/admin/slots/${id}/notes`),
upsertNote: (slot_id: number, student_id: number, content: string) =>
request<void>(`/admin/slots/${slot_id}/notes/${student_id}`, {
method: 'PUT',
@@ -139,7 +144,7 @@ export const api = {
},
checkin: {
getInfo: (code: string) => request<any>(`/checkin/${code}`),
getStudents: (code: string) => request<any[]>(`/checkin/${code}/students`),
getStudents: (code: string) => request<Student[]>(`/checkin/${code}/students`),
post: (code: string, student_id: number, seat_id?: string) =>
request<any>('/checkin', {
method: 'POST',