Skip to content

Commit bc4968a

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 bc4968a

5 files changed

Lines changed: 116 additions & 11 deletions

File tree

backend/app/config/settings.py

Lines changed: 16 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,17 @@
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+
# Write with owner-only permissions (0o600) so other local users on
50+
# multi-user systems cannot read the token and trigger a shutdown.
51+
_fd = os.open(SHUTDOWN_TOKEN_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
52+
with os.fdopen(_fd, "w") as _f:
53+
_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: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,41 @@ 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 = match std::fs::read_to_string(&token_path) {
72+
Ok(t) => {
73+
let trimmed = t.trim().to_string();
74+
if trimmed.is_empty() {
75+
eprintln!("[PictoPy] Warning: shutdown token file is empty — shutdown request will be rejected by the backend.");
76+
}
77+
trimmed
78+
}
79+
Err(e) => {
80+
eprintln!("[PictoPy] Warning: could not read shutdown token file ({token_path:?}): {e} — shutdown request will be rejected by the backend.");
81+
String::new()
82+
}
83+
};
84+
85+
let mut headers = HeaderMap::new();
86+
if !token.is_empty() {
87+
if let (Ok(name), Ok(value)) = (
88+
HeaderName::from_str("x-shutdown-token"),
89+
HeaderValue::from_str(&token),
90+
) {
91+
headers.insert(name, value);
92+
}
93+
}
6494

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

6797
for (name, url, _) in &ENDPOINTS {
68-
match client.post(*url).send() {
98+
match client.post(*url).headers(headers.clone()).send() {
6999
Ok(resp) => {
70100
let status = resp.status();
71101

sync-microservice/app/config/settings.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1-
from platformdirs import user_data_dir
21
import os
2+
import secrets
3+
import tempfile
4+
import time as _time
5+
import warnings
6+
7+
from platformdirs import user_data_dir
38

49
# Model Exports Path
510
MODEL_EXPORTS_PATH = "app/models/ONNX_Exports"
@@ -28,3 +33,35 @@
2833
DATABASE_PATH = os.path.join(user_data_dir("PictoPy"), "database", "PictoPy.db")
2934
THUMBNAIL_IMAGES_PATH = "./images/thumbnails"
3035
IMAGES_PATH = "./images"
36+
37+
# The backend writes a fresh cryptographic token to this temp file on every
38+
# startup. The sync microservice reads the same file so both services share
39+
# a single token without any additional coordination.
40+
SHUTDOWN_TOKEN_FILE: str = os.path.join(tempfile.gettempdir(), "pictopy_shutdown.token")
41+
42+
# Retry for up to 5 seconds to handle the race where the sync microservice
43+
# starts before the backend has had a chance to write the token file.
44+
_deadline = _time.monotonic() + 5.0
45+
SHUTDOWN_TOKEN: str = ""
46+
while _time.monotonic() < _deadline:
47+
try:
48+
with open(SHUTDOWN_TOKEN_FILE) as _f:
49+
_token = _f.read().strip()
50+
if _token:
51+
SHUTDOWN_TOKEN = _token
52+
break
53+
except FileNotFoundError:
54+
pass
55+
_time.sleep(0.1)
56+
57+
if not SHUTDOWN_TOKEN:
58+
# Backend token unavailable after timeout — generate a fallback so the
59+
# service can still start, but log a clear warning so the issue is visible.
60+
SHUTDOWN_TOKEN = secrets.token_hex(32)
61+
warnings.warn(
62+
"pictopy_shutdown.token not found after 5 s; using an independent "
63+
"shutdown token. The sync /shutdown endpoint may reject requests from "
64+
"the Tauri frontend. Ensure the backend starts before the sync service.",
65+
RuntimeWarning,
66+
stacklevel=1,
67+
)

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)