Skip to content

Commit 3997f8b

Browse files
committed
fix(security): authenticate shutdown endpoints with a per-session token
The /shutdown endpoints on both the backend (port 52123) and sync microservice (port 52124) were unauthenticated, allowing any local process to send a POST request and terminate PictoPy without user interaction. Fix: - Generate a cryptographically random 256-bit token (secrets.token_hex) on every backend startup and write it to a temporary file (pictopy_shutdown.token in the OS temp directory). - Both shutdown endpoints now require an X-Shutdown-Token header whose value is compared against the session token using hmac.compare_digest to prevent timing-based attacks. Requests with a missing or incorrect token receive 403 Forbidden. - The sync microservice reads the same token file written by the backend, so both services share one token without additional coordination. - The Tauri frontend (Windows path) is updated to read the token file at shutdown time and attach it as an X-Shutdown-Token header, preserving the existing Windows close behaviour. Closes #1241
1 parent 144786b commit 3997f8b

5 files changed

Lines changed: 79 additions & 10 deletions

File tree

backend/app/config/settings.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import os
22
import sys
3+
import secrets
4+
import tempfile
35

46
from platformdirs import user_data_dir
57

@@ -35,3 +37,14 @@
3537
DATABASE_PATH = os.path.join(user_data_dir("PictoPy"), "database", "PictoPy.db")
3638
THUMBNAIL_IMAGES_PATH = os.path.join(user_data_dir("PictoPy"), "thumbnails")
3739
IMAGES_PATH = "./images"
40+
41+
# Generate a fresh cryptographic token on every backend startup.
42+
# This token is written to a temporary file so that only the local Tauri
43+
# frontend — which reads the same file — can authenticate shutdown requests.
44+
# Any other process on the machine will not know the token and will be
45+
# rejected with 403 Forbidden.
46+
SHUTDOWN_TOKEN: str = secrets.token_hex(32)
47+
SHUTDOWN_TOKEN_FILE: str = os.path.join(tempfile.gettempdir(), "pictopy_shutdown.token")
48+
49+
with open(SHUTDOWN_TOKEN_FILE, "w") as _f:
50+
_f.write(SHUTDOWN_TOKEN)

backend/app/routes/shutdown.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import asyncio
2+
import hmac
23
import os
34
import platform
45
import signal
5-
from fastapi import APIRouter
6+
from fastapi import APIRouter, Header, HTTPException
67
from pydantic import BaseModel
8+
from app.config.settings import SHUTDOWN_TOKEN
79
from app.logging.setup_logging import get_logger
810

911
logger = get_logger(__name__)
@@ -37,16 +39,23 @@ async def _delayed_shutdown(delay: float = 0.5):
3739

3840

3941
@router.post("/shutdown", response_model=ShutdownResponse)
40-
async def shutdown():
42+
async def shutdown(x_shutdown_token: str = Header(...)):
4143
"""
4244
Gracefully shutdown the PictoPy backend.
4345
44-
This endpoint schedules backend server termination after response is sent.
45-
The frontend is responsible for shutting down the sync service separately.
46+
This endpoint requires the ``X-Shutdown-Token`` header to match the token
47+
generated at startup. The token is shared with the Tauri frontend via a
48+
temporary file, so only the PictoPy application itself can trigger this
49+
endpoint — arbitrary local processes are rejected with 403 Forbidden.
4650
4751
Returns:
4852
ShutdownResponse with status and message
4953
"""
54+
# Use constant-time comparison to prevent timing-based token guessing
55+
if not hmac.compare_digest(x_shutdown_token, SHUTDOWN_TOKEN):
56+
logger.warning("Shutdown attempt rejected: invalid token")
57+
raise HTTPException(status_code=403, detail="Forbidden")
58+
5059
logger.info("Shutdown request received for PictoPy backend")
5160

5261
# Define callback to handle potential exceptions in the background task

frontend/src-tauri/src/main.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,31 @@ fn kill_process(process: &sysinfo::Process) {
6161
#[cfg(windows)]
6262
pub fn kill_process(_process: &sysinfo::Process) -> Result<(), String> {
6363
use reqwest::blocking::Client;
64+
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
65+
use std::str::FromStr;
66+
67+
// Read the per-session shutdown token written by the backend at startup.
68+
// This guarantees that only the PictoPy frontend — which runs alongside
69+
// the backend — can authenticate the shutdown request.
70+
let token_path = std::env::temp_dir().join("pictopy_shutdown.token");
71+
let token = std::fs::read_to_string(&token_path)
72+
.map(|t| t.trim().to_string())
73+
.unwrap_or_default();
74+
75+
let mut headers = HeaderMap::new();
76+
if !token.is_empty() {
77+
if let (Ok(name), Ok(value)) = (
78+
HeaderName::from_str("x-shutdown-token"),
79+
HeaderValue::from_str(&token),
80+
) {
81+
headers.insert(name, value);
82+
}
83+
}
6484

6585
let client = Client::builder().build().map_err(|e| e.to_string())?;
6686

6787
for (name, url, _) in &ENDPOINTS {
68-
match client.post(*url).send() {
88+
match client.post(*url).headers(headers.clone()).send() {
6989
Ok(resp) => {
7090
let status = resp.status();
7191

sync-microservice/app/config/settings.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from platformdirs import user_data_dir
22
import os
3+
import tempfile
34

45
# Model Exports Path
56
MODEL_EXPORTS_PATH = "app/models/ONNX_Exports"
@@ -28,3 +29,16 @@
2829
DATABASE_PATH = os.path.join(user_data_dir("PictoPy"), "database", "PictoPy.db")
2930
THUMBNAIL_IMAGES_PATH = "./images/thumbnails"
3031
IMAGES_PATH = "./images"
32+
33+
# The backend writes a fresh cryptographic token to this temp file on every
34+
# startup. The sync microservice reads the same file so both services share
35+
# a single token without any additional coordination.
36+
SHUTDOWN_TOKEN_FILE: str = os.path.join(tempfile.gettempdir(), "pictopy_shutdown.token")
37+
38+
try:
39+
with open(SHUTDOWN_TOKEN_FILE) as _f:
40+
SHUTDOWN_TOKEN: str = _f.read().strip()
41+
except FileNotFoundError:
42+
# Fallback: generate an independent token if the backend hasn't started yet.
43+
import secrets
44+
SHUTDOWN_TOKEN = secrets.token_hex(32)

sync-microservice/app/routes/shutdown.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import asyncio
2+
import hmac
23
import os
34
import platform
45
import signal
5-
from fastapi import APIRouter
6+
from fastapi import APIRouter, Header, HTTPException
67
from pydantic import BaseModel
8+
from app.config.settings import SHUTDOWN_TOKEN
79
from app.utils.watcher import watcher_util_stop_folder_watcher
810
from app.logging.setup_logging import get_sync_logger
911

@@ -38,18 +40,29 @@ async def _delayed_shutdown(delay: float = 0.1):
3840

3941

4042
@router.post("/shutdown", response_model=ShutdownResponse)
41-
async def shutdown():
43+
async def shutdown(x_shutdown_token: str = Header(...)):
4244
"""
4345
Gracefully shutdown the sync microservice.
4446
47+
This endpoint requires the ``X-Shutdown-Token`` header to match the token
48+
generated by the backend at startup. The token is shared between services
49+
via a temporary file, so only the PictoPy application itself can trigger
50+
shutdown — arbitrary local processes are rejected with 403 Forbidden.
51+
4552
This endpoint:
46-
1. Stops the folder watcher
47-
2. Schedules server termination after response is sent
48-
3. Returns confirmation to the caller
53+
1. Validates the shutdown token
54+
2. Stops the folder watcher
55+
3. Schedules server termination after response is sent
56+
4. Returns confirmation to the caller
4957
5058
Returns:
5159
ShutdownResponse with status and message
5260
"""
61+
# Use constant-time comparison to prevent timing-based token guessing
62+
if not hmac.compare_digest(x_shutdown_token, SHUTDOWN_TOKEN):
63+
logger.warning("Shutdown attempt rejected: invalid token")
64+
raise HTTPException(status_code=403, detail="Forbidden")
65+
5366
logger.info("Shutdown request received for sync microservice")
5467

5568
try:

0 commit comments

Comments
 (0)