mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-22 18:12:18 -05:00
Two lower-severity findings from the audit (todo.md tasks 6 and 7). A spreadsheet executes a cell that begins with =, +, -, @, tab or CR, so a vendor named `=cmd|' /C calc'!A0` runs when someone opens the CSV export (CWE-1236). Such cells are now prefixed with a single quote, which spreadsheets read as "this is literal text" and do not display. Only strings are escaped -- numbers reach the writer as numeric types, so a negative number cannot be mistaken for a formula and must not grow a stray quote. Header names need no escaping: they are fixed attribute names or extra-field keys, which are already constrained to ^[a-z0-9_]+$. Exports also gained Content-Disposition, so they download under a sensible name rather than rendering in the browser. Extra-field values had no size limit and the columns are Text(), so a single request could store megabytes: unbounded database growth, and the resulting websocket event overflowed the 1 MiB default frame limit, which killed live updates for every connected client. Values are now capped at the same 65535 characters as settings. The number of extra fields per entity is capped too -- the per-entity key count was already bounded, since unknown keys are rejected, but the registry itself could be grown without limit, and it is cached in memory and embedded in every entity response. Finally, a setting larger than the column limit returned 500; the ValueError is now caught and returned as a 400. Verified end-to-end: a 2 MB extra value is refused on both create and update, an oversized setting returns 400 and leaves the setting unset, normal values still work, and a vendor named `=cmd|' /C calc'!A0` exports as `'=cmd|' /C calc'!A0` while the JSON export keeps the raw name.
239 lines
7.9 KiB
Python
239 lines
7.9 KiB
Python
"""Vendor related endpoints."""
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
from fastapi.responses import JSONResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from spoolman.api.v1.models import Message, SettingEvent, SettingResponse
|
|
from spoolman.database import setting
|
|
from spoolman.database.database import get_db_session
|
|
from spoolman.exceptions import ItemNotFoundError
|
|
from spoolman.extra_field_registry import invalidate_extra_field_cache, validate_extra_field_setting
|
|
from spoolman.settings import SETTINGS, parse_setting
|
|
from spoolman.ws import websocket_manager
|
|
|
|
router = APIRouter(
|
|
prefix="/setting",
|
|
tags=["setting"],
|
|
)
|
|
|
|
# ruff: noqa: D103
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def require_json_content_type(request: Request) -> None:
|
|
"""Reject a request body that is not declared as JSON.
|
|
|
|
This endpoint takes a bare ``str`` body, and FastAPI only JSON-parses ``application/*json``;
|
|
anything else arrives as raw bytes that lax-mode ``str`` happily coerces. That makes a
|
|
``text/plain`` body acceptable, which is exactly what ``<form enctype="text/plain">`` sends --
|
|
so any website could silently rewrite settings through the visitor's browser. HTML forms can
|
|
only send ``text/plain``, ``application/x-www-form-urlencoded`` or ``multipart/form-data``, so
|
|
insisting on JSON here removes the whole class of form-driven writes.
|
|
|
|
Note that ``Body(media_type=...)`` does *not* do this: it only annotates the OpenAPI schema
|
|
and is not enforced at runtime.
|
|
|
|
Args:
|
|
request: The incoming request.
|
|
|
|
Raises:
|
|
HTTPException: 415 if the content type is not JSON.
|
|
|
|
"""
|
|
media_type = request.headers.get("content-type", "").split(";")[0].strip().lower()
|
|
if media_type != "application/json" and not media_type.endswith("+json"):
|
|
raise HTTPException(
|
|
status_code=415,
|
|
detail=(
|
|
f"Unsupported content type {media_type!r}. The body of this endpoint must be sent as application/json."
|
|
),
|
|
)
|
|
|
|
|
|
@router.websocket(
|
|
"",
|
|
name="Listen to setting changes",
|
|
)
|
|
async def notify_any(
|
|
websocket: WebSocket,
|
|
) -> None:
|
|
await websocket.accept()
|
|
websocket_manager.connect(("setting",), websocket)
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(0.5)
|
|
if await websocket.receive_text():
|
|
await websocket.send_json({"status": "healthy"})
|
|
except WebSocketDisconnect:
|
|
websocket_manager.disconnect(("setting",), websocket)
|
|
|
|
|
|
@router.get(
|
|
"/{key}",
|
|
name="Get setting",
|
|
description=(
|
|
"Get a specific setting. If the setting has not been set, the default value will be returned."
|
|
"A websocket is served on the same path to listen for changes to the setting. "
|
|
"See the HTTP Response code 299 for the content of the websocket messages."
|
|
),
|
|
response_model_exclude_none=True,
|
|
response_model=SettingResponse,
|
|
responses={404: {"model": Message}, 299: {"model": SettingEvent, "description": "Websocket message"}},
|
|
)
|
|
async def get(
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
key: str,
|
|
) -> SettingResponse | JSONResponse:
|
|
try:
|
|
definition = parse_setting(key)
|
|
except ValueError as e:
|
|
return JSONResponse(status_code=404, content=Message(message=str(e)).dict())
|
|
|
|
try:
|
|
db_item = await setting.get(db, definition)
|
|
value = db_item.value
|
|
is_set = True
|
|
except ItemNotFoundError:
|
|
value = definition.default
|
|
is_set = False
|
|
|
|
return SettingResponse(
|
|
value=value,
|
|
is_set=is_set,
|
|
type=definition.type,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/",
|
|
name="Get all settings",
|
|
description=("Get all settings, set or not. If the setting has not been set, 'value' will be the default value."),
|
|
response_model_exclude_none=True,
|
|
response_model=dict[str, SettingResponse],
|
|
)
|
|
async def find(
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
) -> dict[str, SettingResponse] | JSONResponse:
|
|
settings: dict[str, SettingResponse] = {}
|
|
|
|
# First get all settings that have been set.
|
|
db_items = await setting.get_all(db)
|
|
for db_item in db_items:
|
|
try:
|
|
definition = parse_setting(db_item.key)
|
|
except ValueError:
|
|
continue # Ignore settings that have been removed.
|
|
|
|
settings[db_item.key] = SettingResponse(
|
|
value=db_item.value,
|
|
is_set=True,
|
|
type=definition.type,
|
|
)
|
|
|
|
# Then get all settings that have not been set.
|
|
for settingdef in SETTINGS.values():
|
|
if settingdef.key not in settings:
|
|
settings[settingdef.key] = SettingResponse(
|
|
value=settingdef.default,
|
|
is_set=False,
|
|
type=settingdef.type,
|
|
)
|
|
|
|
return settings
|
|
|
|
|
|
@router.websocket(
|
|
"/{key}",
|
|
name="Listen to setting changes",
|
|
)
|
|
async def notify(
|
|
websocket: WebSocket,
|
|
key: str,
|
|
) -> None:
|
|
try:
|
|
parse_setting(key)
|
|
except ValueError as e:
|
|
await websocket.close(code=4040, reason=str(e))
|
|
return
|
|
|
|
await websocket.accept()
|
|
websocket_manager.connect(("setting", str(key)), websocket)
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(0.5)
|
|
if await websocket.receive_text():
|
|
await websocket.send_json({"status": "healthy"})
|
|
except WebSocketDisconnect:
|
|
websocket_manager.disconnect(("setting", str(key)), websocket)
|
|
|
|
|
|
@router.post(
|
|
"/{key}",
|
|
name="Set setting",
|
|
description=(
|
|
"Set the value of a setting. The body must match the JSON type of the setting. "
|
|
"An empty body or a body containing only 'null' will reset the setting to its default value. "
|
|
"The new value will be returned."
|
|
),
|
|
response_model_exclude_none=True,
|
|
response_model=SettingResponse,
|
|
responses={404: {"model": Message}, 415: {"model": Message}},
|
|
dependencies=[Depends(require_json_content_type)],
|
|
)
|
|
async def update(
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
key: str,
|
|
body: Annotated[str, Body(media_type="application/json")],
|
|
) -> SettingResponse | JSONResponse:
|
|
try:
|
|
definition = parse_setting(key)
|
|
except ValueError as e:
|
|
return JSONResponse(status_code=404, content=Message(message=str(e)).dict())
|
|
|
|
if body and body != "null":
|
|
try:
|
|
definition.validate_type(body)
|
|
# The extra-field settings feed a registry that the /field endpoints and every entity
|
|
# response depend on. Writing a malformed one through here used to be accepted and then
|
|
# wedged GET /field/{entity} into a permanent 500, so validate the shape up front.
|
|
validate_extra_field_setting(key, body)
|
|
except ValueError as e:
|
|
return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
|
|
|
|
try:
|
|
# Raised when the value exceeds the column limit; a client error, not a server one.
|
|
await setting.update(db=db, definition=definition, value=body)
|
|
except ValueError as e:
|
|
return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
|
|
logger.info('Setting "%s" has been set to "%s".', key, body)
|
|
else:
|
|
await setting.delete(db=db, definition=definition)
|
|
logger.info('Setting "%s" has been unset.', key)
|
|
|
|
await db.commit()
|
|
|
|
# The registry caches extra fields in memory and is otherwise only refreshed by the /field
|
|
# endpoints, so without this the two paths disagree until the next restart.
|
|
invalidate_extra_field_cache(key)
|
|
|
|
# Get the new value of the setting.
|
|
try:
|
|
db_item = await setting.get(db, definition)
|
|
value = db_item.value
|
|
is_set = True
|
|
except ItemNotFoundError:
|
|
value = definition.default
|
|
is_set = False
|
|
|
|
return SettingResponse(
|
|
value=value,
|
|
is_set=is_set,
|
|
type=definition.type,
|
|
)
|