Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 100 additions & 17 deletions backend/sbs_server/app/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,89 @@
import sbol2 as sb2
import pudu
import subprocess
import contextlib
import io
import traceback



def classify_excel2sbol_error(exc):
if isinstance(exc, FileNotFoundError):
return {
"code": "E_INPUT_FILE",
"message": "The uploaded Excel file could not be read.",
"hint": "Check that the file was uploaded correctly.",
"status": 400,
}

if isinstance(exc, KeyError):
return {
"code": "E_WORKBOOK_STRUCTURE",
"message": "The Excel file is missing a required sheet, column, or SBOL mapping.",
"hint": "Check the Init sheet and column_definitions sheet.",
"status": 400,
}

if isinstance(exc, ValueError):
return {
"code": "E_WORKBOOK_VALUE",
"message": "A value in the Excel file could not be converted to SBOL.",
"hint": "Check the reported sheet, column, row, or pattern requirement.",
"status": 400,
}

return {
"code": "E_CONVERSION_FAILED",
"message": "Excel2SBOL failed during conversion.",
"hint": "Open More details and send the technical report to the developers.",
"status": 500,
}


def redact_sensitive_values(text):
if text is None:
return ""

sensitive_values = [
os.getenv("SBOL_USERNAME"),
os.getenv("SBOL_PASSWORD"),
os.getenv("SBOL_URL"),
os.getenv("SYNBIOHUB_USERNAME"),
os.getenv("SYNBIOHUB_PASSWORD"),
os.getenv("SYNBIOHUB_URL"),
]

for value in sensitive_values:
if value:
text = text.replace(value, "[REDACTED]")

return text


def excel2sbol_error_response(exc, stdout_buffer, stderr_buffer):
app.logger.exception("Excel2SBOL conversion failed")
classified = classify_excel2sbol_error(exc)
terminal_output = "\n".join([
"STDOUT:",
stdout_buffer.getvalue(),
"",
"STDERR:",
stderr_buffer.getvalue(),
])

return jsonify({
"status": "error",
"error": {
"code": classified["code"],
"message": classified["message"],
"hint": classified["hint"],
"details": redact_sensitive_values(str(exc)),
"technical_details": {
"terminal_output": redact_sensitive_values(terminal_output),
"traceback": redact_sensitive_values(traceback.format_exc()),
},
},
}), classified["status"]

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.
Stack trace information
flows to this location and may be exposed to an external user.
Comment on lines +86 to +98

#routes
#check if the app is running
Expand Down Expand Up @@ -129,26 +212,26 @@
os.remove(data_filename)

# instantiate the XDC class using the params_from_request dictionary
stdout_buffer = io.StringIO()
stderr_buffer = io.StringIO()
try:
xdc = tricahue.XDC(input_excel_path = metadata_path, attachments=attachments)
# print(params_from_request['sbh_url'], params_from_request['collection_url'], params_from_request['sbh_overwrite'], params_from_request['sbh_user'],params_from_request['sbh_pass'], params_from_request['sbh_pass'],params_from_request['fj_url'], params_from_request['fj_overwrite'], params_from_request['fj_user'], params_from_request['fj_pass'],params_from_request['fj_token'])
sbh_url, fj_url = xdc.upload_to_existing_collection(sbh_url = params_from_request['sbh_url'],
collection_url = params_from_request['collection_url'],
sbh_overwrite = params_from_request['sbh_overwrite'],
sbh_user = params_from_request['sbh_user'],
sbh_pass = params_from_request['sbh_pass'],
sbh_token = params_from_request['sbh_token'],
fj_url = fj_url,
fj_overwrite = fj_overwrite,
fj_user = fj_user,
fj_pass = fj_pass,
fj_token = fj_token)
except AttributeError as e:
os.remove(metadata_path)
return jsonify({"error": str(e)}), 400
with contextlib.redirect_stdout(stdout_buffer), contextlib.redirect_stderr(stderr_buffer):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid process-wide stdout redirection during uploads

When the backend runs with a threaded Flask/Wsgi worker, contextlib.redirect_stdout/redirect_stderr replaces the process-wide sys.stdout and sys.stderr for the entire duration of this potentially long XDC upload. Any concurrent request or background task that prints/logs to these streams during that window will be captured into this user's technical_details response, which can leak unrelated output and also hide it from server logs. Prefer a conversion-local logging/capture mechanism, or avoid returning global stdout/stderr capture from a request handler.

Useful? React with 👍 / 👎.

xdc = tricahue.XDC(input_excel_path = metadata_path, attachments=attachments)
# print(params_from_request['sbh_url'], params_from_request['collection_url'], params_from_request['sbh_overwrite'], params_from_request['sbh_user'],params_from_request['sbh_pass'], params_from_request['sbh_pass'],params_from_request['fj_url'], params_from_request['fj_overwrite'], params_from_request['fj_user'], params_from_request['fj_pass'],params_from_request['fj_token'])
sbh_url, fj_url = xdc.upload_to_existing_collection(sbh_url = params_from_request['sbh_url'],
collection_url = params_from_request['collection_url'],
sbh_overwrite = params_from_request['sbh_overwrite'],
sbh_user = params_from_request['sbh_user'],
sbh_pass = params_from_request['sbh_pass'],
sbh_token = params_from_request['sbh_token'],
fj_url = fj_url,
fj_overwrite = fj_overwrite,
fj_user = fj_user,
fj_pass = fj_pass,
fj_token = fj_token)
except Exception as e:
os.remove(metadata_path)
return jsonify({"error": str(e)}), 500
return excel2sbol_error_response(e, stdout_buffer, stderr_buffer)

sbs_upload_response_dict ={
"sbh_url": sbh_url,
Expand Down
84 changes: 84 additions & 0 deletions backend/tests/test_excel2sbol_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import json
import sys
import types
from io import BytesIO
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1] / "sbs_server"
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))


class _FakePartShop:
pass


def _install_optional_dependency_stubs():
sys.modules.setdefault("tricahue", types.SimpleNamespace())
sys.modules.setdefault("pudu", types.SimpleNamespace())
sys.modules.setdefault("sbol2", types.SimpleNamespace(Document=object, PartShop=_FakePartShop))

if "flask_swagger_ui" not in sys.modules:
def get_swaggerui_blueprint(*args, **kwargs):
from flask import Blueprint
return Blueprint("swagger_ui_stub", __name__)

sys.modules["flask_swagger_ui"] = types.SimpleNamespace(
get_swaggerui_blueprint=get_swaggerui_blueprint,
)


_install_optional_dependency_stubs()
from app.main import app # noqa: E402
from app import views # noqa: E402


def test_excel2sbol_value_error_returns_friendly_error_with_technical_details(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("SYNBIOHUB_PASSWORD", "super-secret")

class FailingXDC:
def __init__(self, *args, **kwargs):
print("started conversion with super-secret")
raise ValueError("bad value contains super-secret")

monkeypatch.setattr(views.tricahue, "XDC", FailingXDC, raising=False)

params = {
"sbh_url": "https://synbiohub.example",
"sbh_token": "token",
"sbh_user": None,
"sbh_pass": None,
"fj_url": None,
"fj_token": None,
"fj_user": None,
"fj_pass": None,
"collection_url": "https://synbiohub.example/user/collection/1",
"sbh_overwrite": 0,
"fj_overwrite": 1,
"attachments": {},
}

response = app.test_client().post(
"/api/uploadResource",
data={
"Metadata": (BytesIO(b"excel bytes"), "metadata.xlsx"),
"Params": (BytesIO(json.dumps(params).encode("utf-8")), "params.json"),
},
content_type="multipart/form-data",
)

assert response.status_code == 400
payload = response.get_json()
assert payload["status"] == "error"
assert payload["error"]["code"] == "E_WORKBOOK_VALUE"
assert payload["error"]["message"] == "A value in the Excel file could not be converted to SBOL."
assert payload["error"]["hint"] == "Check the reported sheet, column, row, or pattern requirement."
assert payload["error"]["details"] == "bad value contains [REDACTED]"
assert "started conversion with [REDACTED]" in payload["error"]["technical_details"]["terminal_output"]
assert "Traceback" in payload["error"]["technical_details"]["traceback"]
assert "ValueError" in payload["error"]["technical_details"]["traceback"]
assert "super-secret" not in payload["error"]["details"]
assert "super-secret" not in payload["error"]["technical_details"]["terminal_output"]
assert "super-secret" not in payload["error"]["technical_details"]["traceback"]
5 changes: 4 additions & 1 deletion frontend/src/components/activities/explorer/ImportFile.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { writeToFileHandle } from "../../../redux/hooks/workingDirectoryHooks";
import { useOpenPanel } from "../../../redux/hooks/panelsHooks";
import { workingDirectorySlice } from "../../../redux/store";
import { useLocalStorage } from "@mantine/hooks";
import { showErrorNotification } from "../../../modules/util";
import { showErrorNotification, showUploadErrorNotification } from "../../../modules/util";
import { upload_resource } from "../../../API";
import { useUnifiedModal } from "../../../redux/hooks/useUnifiedModal";
import { loadOverlay, closeOverlay } from "../../../redux/slices/loadingOverlay";
Expand Down Expand Up @@ -206,6 +206,9 @@ export default function ImportFile({ onSelect, text, useSubdirectory = false })
onSelect?.(fileMetadata)
} catch (err) {
console.warn("File selection canceled or failed", err)
if (err?.response) {
showUploadErrorNotification("Upload failed", err, "Unable to upload the collection metadata.")
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/panels/xdc/CollectionWizard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useLocalStorage } from '@mantine/hooks'
import { PanelContext } from './CollectionPanel'
import { ObjectTypes } from '../../../objectTypes'
import { uploadExperiment } from '../../../API'
import { showErrorNotification } from '../../../modules/util'
import { showErrorNotification, showUploadErrorNotification } from '../../../modules/util'
import Dropzone from '../../Dropzone'

export default function CollectionWizard() {
Expand Down Expand Up @@ -73,7 +73,7 @@ export default function CollectionWizard() {

setUploads((currentUploads) => [...(currentUploads || []), uploadEntry])
} catch (error) {
showErrorNotification('Upload failed', error?.response?.data?.error || error.message || 'Unable to upload the collection metadata.')
showUploadErrorNotification('Upload failed', error, 'Unable to upload the collection metadata.')
} finally {
setIsSubmitting(false)
}
Expand Down
79 changes: 78 additions & 1 deletion frontend/src/modules/util.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React, { useState } from "react"
import { showNotification } from "@mantine/notifications"

export function titleFromRunFileName(fileName) {
Expand All @@ -16,11 +17,87 @@ export function betterMax(arr) {
return arr.reduce((accum, current) => current > accum ? current : accum, arr[0])
}

function ConversionError({ error }) {
const [showDetails, setShowDetails] = useState(false)

if (!error) {
return null
}

const technicalDetails = [
`Error code: ${error.code || ""}`,
"",
"Details:",
error.details || "",
"",
"Terminal output:",
error.technical_details?.terminal_output || "",
"",
"Python traceback:",
error.technical_details?.traceback || "",
].join("\n")

return React.createElement(
"div",
null,
React.createElement("strong", null, error.message || "Conversion failed"),
error.hint ? React.createElement("p", null, error.hint) : null,
React.createElement(
"button",
{
type: "button",
onClick: () => setShowDetails(!showDetails),
style: {
background: "transparent",
border: "none",
color: "inherit",
cursor: "pointer",
padding: 0,
textDecoration: "underline",
},
},
showDetails ? "Hide details" : "More"
),
showDetails ? React.createElement(
"pre",
{
style: {
marginTop: 8,
maxHeight: 240,
overflow: "auto",
whiteSpace: "pre-wrap",
},
},
technicalDetails
) : null
)
}

export function showErrorNotification(title, message) {
showNotification({
title,
color: "red",
message,
autoClose: false,
});
}
}

export function showConversionErrorNotification(title, error) {
showNotification({
title,
color: "red",
message: React.createElement(ConversionError, { error }),
autoClose: false,
});
}

export function showUploadErrorNotification(title, error, fallbackMessage) {
const responseError = error?.response?.data?.error

if (responseError && typeof responseError === "object") {
showConversionErrorNotification(title, responseError)
return
}

showErrorNotification(title, responseError || error.message || fallbackMessage)
}
Loading