mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-10-25 10:24:38 +08:00
Merge remote-tracking branch 'upstream/release_24.2' into dev
This commit is contained in:
@@ -242,10 +242,9 @@ function resetModal() {
|
||||
:busy="(fromSelection && isFetchingItems) || creatingCollection"
|
||||
modal-class="ui-modal collection-creator-modal"
|
||||
:hide-footer="!createdCollection && !createCollectionError"
|
||||
:ok-disabled="!!createdCollection || !!createCollectionError"
|
||||
:cancel-title="localize('Exit')"
|
||||
footer-class="d-flex justify-content-between"
|
||||
:ok-title="localize('Create Collection')"
|
||||
ok-only
|
||||
:ok-title="localize('Exit')"
|
||||
ok-variant="secondary"
|
||||
@hidden="resetModal">
|
||||
<template v-slot:modal-header>
|
||||
<Heading class="w-100" size="sm">
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Store } from "pinia";
|
||||
|
||||
const referenceCountByStore = new WeakMap<Store, number>();
|
||||
|
||||
/**
|
||||
* Reference counts a store, and provides a function to safely dispose it after a timeout.
|
||||
* @param store store to reference count and dispose
|
||||
* @param timeout how long to wait before reference counting and attempting a dispose.
|
||||
* @returns dispose function
|
||||
*/
|
||||
export function useTimeoutStoreDispose(store: Store, timeout = 1000) {
|
||||
const currentReferenceCount = referenceCountByStore.get(store) ?? 0;
|
||||
referenceCountByStore.set(store, currentReferenceCount + 1);
|
||||
|
||||
const disposeIfReferenceFree = () => {
|
||||
const referenceCount = referenceCountByStore.get(store) ?? 0;
|
||||
|
||||
if (referenceCount <= 0) {
|
||||
store.$dispose();
|
||||
}
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
const referenceCount = referenceCountByStore.get(store) ?? 1;
|
||||
referenceCountByStore.set(store, referenceCount - 1);
|
||||
setTimeout(disposeIfReferenceFree, timeout);
|
||||
};
|
||||
|
||||
return dispose;
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { useWorkflowStateStore } from "@/stores/workflowEditorStateStore";
|
||||
import { useWorkflowEditorToolbarStore } from "@/stores/workflowEditorToolbarStore";
|
||||
import { useWorkflowStepStore } from "@/stores/workflowStepStore";
|
||||
|
||||
import { useTimeoutStoreDispose } from "./timeoutStoreDispose";
|
||||
|
||||
/**
|
||||
* Creates stores scoped to a specific workflowId, and manages their lifetime.
|
||||
* In child components, use `useWorkflowStores` instead.
|
||||
@@ -30,13 +32,20 @@ export function provideScopedWorkflowStores(workflowId: Ref<string> | string) {
|
||||
const toolbarStore = useWorkflowEditorToolbarStore(workflowId.value);
|
||||
const undoRedoStore = useUndoRedoStore(workflowId.value);
|
||||
|
||||
const disposeConnectionStore = useTimeoutStoreDispose(connectionStore);
|
||||
const disposeStateStore = useTimeoutStoreDispose(stateStore);
|
||||
const disposeStepStore = useTimeoutStoreDispose(stepStore);
|
||||
const disposeCommentStore = useTimeoutStoreDispose(commentStore);
|
||||
const disposeToolbarStore = useTimeoutStoreDispose(toolbarStore);
|
||||
const disposeUndoRedoStore = useTimeoutStoreDispose(undoRedoStore);
|
||||
|
||||
onScopeDispose(() => {
|
||||
connectionStore.$dispose();
|
||||
stateStore.$dispose();
|
||||
stepStore.$dispose();
|
||||
commentStore.$dispose();
|
||||
toolbarStore.$dispose();
|
||||
undoRedoStore.$dispose();
|
||||
disposeConnectionStore();
|
||||
disposeStateStore();
|
||||
disposeStepStore();
|
||||
disposeCommentStore();
|
||||
disposeToolbarStore();
|
||||
disposeUndoRedoStore();
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,46 +1,99 @@
|
||||
<template>
|
||||
<iframe
|
||||
:id="id"
|
||||
:name="id"
|
||||
:src="srcWithRoot"
|
||||
class="center-frame"
|
||||
frameborder="0"
|
||||
title="galaxy frame"
|
||||
width="100%"
|
||||
height="100%"
|
||||
@load="onLoad" />
|
||||
</template>
|
||||
<script>
|
||||
import { withPrefix } from "utils/redirect";
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
default: "frame",
|
||||
},
|
||||
src: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
srcWithRoot() {
|
||||
return withPrefix(this.src);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onLoad(ev) {
|
||||
const iframe = ev.currentTarget;
|
||||
const location = iframe.contentWindow && iframe.contentWindow.location;
|
||||
import { useUserStore } from "@/stores/userStore";
|
||||
import { withPrefix } from "@/utils/redirect";
|
||||
|
||||
import Alert from "@/components/Alert.vue";
|
||||
import LoadingSpan from "@/components/LoadingSpan.vue";
|
||||
|
||||
const emit = defineEmits(["load"]);
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string;
|
||||
src?: string;
|
||||
isPreview?: boolean;
|
||||
}>(),
|
||||
{
|
||||
id: "frame",
|
||||
src: "",
|
||||
isPreview: false,
|
||||
}
|
||||
);
|
||||
|
||||
const { isAdmin } = storeToRefs(useUserStore());
|
||||
const srcWithRoot = computed(() => withPrefix(props.src));
|
||||
const sanitizedImport = ref(false);
|
||||
const sanitizedToolId = ref<String | false>(false);
|
||||
const isLoading = ref(true);
|
||||
watch(
|
||||
() => srcWithRoot.value,
|
||||
async () => {
|
||||
sanitizedImport.value = false;
|
||||
sanitizedToolId.value = false;
|
||||
if (props.isPreview) {
|
||||
try {
|
||||
if (location && location.host && location.pathname != "/") {
|
||||
this.$emit("load");
|
||||
const response = await fetch(srcWithRoot.value, { method: "HEAD" });
|
||||
const isImported = response.headers.get("x-sanitized-job-imported");
|
||||
const toolId = response.headers.get("x-sanitized-tool-id");
|
||||
if (isImported !== null) {
|
||||
sanitizedImport.value = true;
|
||||
} else if (toolId !== null) {
|
||||
sanitizedToolId.value = toolId;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("CenterFrame - onLoad location access forbidden.", ev, location);
|
||||
} catch (e) {
|
||||
// I guess that's fine and the center panel will show something
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const plainText = "Contents are shown as plain text.";
|
||||
const sanitizedMessage = computed(() => {
|
||||
if (sanitizedImport.value) {
|
||||
return `Dataset has been imported. ${plainText}`;
|
||||
} else if (sanitizedToolId.value) {
|
||||
return `Dataset created by a tool that is not known to create safe HTML. ${plainText}`;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
function onLoad(ev: Event) {
|
||||
isLoading.value = false;
|
||||
const iframe = ev.currentTarget as HTMLIFrameElement;
|
||||
const location = iframe?.contentWindow && iframe.contentWindow.location;
|
||||
try {
|
||||
if (location && location.host && location.pathname != "/") {
|
||||
emit("load");
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("CenterFrame - onLoad location access forbidden.", ev, location);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="h-100">
|
||||
<Alert v-if="sanitizedMessage" :dismissible="true" variant="warning" data-description="sanitization warning">
|
||||
{{ sanitizedMessage }}
|
||||
<span v-if="isAdmin && sanitizedToolId">
|
||||
<br />
|
||||
<router-link data-description="allowlist link" to="/admin/sanitize_allow">Review Allowlist</router-link>
|
||||
if outputs of {{ sanitizedToolId }} are trusted and should be shown as HTML.
|
||||
</span>
|
||||
</Alert>
|
||||
<LoadingSpan v-if="isLoading">Loading ...</LoadingSpan>
|
||||
<iframe
|
||||
:id="id"
|
||||
:name="id"
|
||||
:src="srcWithRoot"
|
||||
class="center-frame"
|
||||
frameborder="0"
|
||||
title="galaxy frame"
|
||||
width="100%"
|
||||
height="100%"
|
||||
@load="onLoad" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -238,6 +238,7 @@ export function getRouter(Galaxy) {
|
||||
component: CenterFrame,
|
||||
props: (route) => ({
|
||||
src: `/datasets/${route.params.datasetId}/display/?preview=True`,
|
||||
isPreview: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -304,19 +304,6 @@ class Strategy(BaseStrategy):
|
||||
def render_html(self, tpl=None, html=None, context=None):
|
||||
raise NotImplementedError("Not implemented.")
|
||||
|
||||
def start(self):
|
||||
self.clean_partial_pipeline()
|
||||
if self.backend.uses_redirect():
|
||||
return self.redirect(self.backend.auth_url())
|
||||
else:
|
||||
return self.html(self.backend.auth_html())
|
||||
|
||||
def complete(self, *args, **kwargs):
|
||||
return self.backend.auth_complete(*args, **kwargs)
|
||||
|
||||
def continue_pipeline(self, *args, **kwargs):
|
||||
return self.backend.continue_pipeline(*args, **kwargs)
|
||||
|
||||
|
||||
class Storage:
|
||||
user = UserAuthnzToken
|
||||
|
||||
@@ -60,7 +60,6 @@ from galaxy.util.markdown import (
|
||||
indicate_data_truncated,
|
||||
literal_via_fence,
|
||||
)
|
||||
from galaxy.util.sanitize_html import sanitize_html
|
||||
from galaxy.util.zipstream import ZipstreamWrapper
|
||||
from . import (
|
||||
dataproviders as p_dataproviders,
|
||||
@@ -634,21 +633,18 @@ class Data(metaclass=DataMeta):
|
||||
return result
|
||||
|
||||
def _yield_user_file_content(self, trans, from_dataset: HasCreatingJob, filename: str, headers: Headers) -> IO:
|
||||
"""This method is responsible for sanitizing the HTML if needed."""
|
||||
"""This method sets the content type header to text/plain if we don't trust html content."""
|
||||
if trans.app.config.sanitize_all_html and headers.get("content-type", None) == "text/html":
|
||||
# Sanitize anytime we respond with plain text/html content.
|
||||
# Check to see if this dataset's parent job is allowlisted
|
||||
# We cannot currently trust imported datasets for rendering.
|
||||
if not from_dataset.creating_job.imported and from_dataset.creating_job.tool_id.startswith(
|
||||
tuple(trans.app.config.sanitize_allowlist)
|
||||
):
|
||||
return open(filename, mode="rb")
|
||||
|
||||
# This is returning to the browser, it needs to be encoded.
|
||||
# TODO Ideally this happens a layer higher, but this is a bad
|
||||
# issue affecting many tools
|
||||
with open(filename) as f:
|
||||
return sanitize_html(f.read()).encode("utf-8")
|
||||
content_type = "text/html"
|
||||
if from_dataset.creating_job.imported:
|
||||
content_type = "text/plain"
|
||||
headers["x-sanitized-job-imported"] = True
|
||||
if not from_dataset.creating_job.tool_id.startswith(tuple(trans.app.config.sanitize_allowlist)):
|
||||
content_type = "text/plain"
|
||||
headers["x-sanitized-tool-id"] = from_dataset.creating_job.tool_id
|
||||
headers["content-type"] = content_type
|
||||
|
||||
return open(filename, mode="rb")
|
||||
|
||||
|
||||
@@ -116,6 +116,9 @@ class HasDriver:
|
||||
def element_absent(self, selector_template: Target) -> bool:
|
||||
return len(self.find_elements(selector_template)) == 0
|
||||
|
||||
def switch_to_frame(self, name: str = "frame"):
|
||||
return self._wait_on(ec.frame_to_be_available_and_switch_to_it((By.NAME, name)))
|
||||
|
||||
def wait_for_xpath(self, xpath: str, **kwds) -> WebElement:
|
||||
element = self._wait_on(
|
||||
ec.presence_of_element_located((By.XPATH, xpath)), f"XPATH selector [{xpath}] to become present", **kwds
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<tool id="html_output" name="html_output" version="1.0.0">
|
||||
<command><![CDATA[
|
||||
cp '$html_file' '$output'
|
||||
]]></command>
|
||||
<configfiles>
|
||||
<configfile name="html_file"><![CDATA[
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title data-description="hello-world">Hello World</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Hello, World!</h1>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
]]></configfile>
|
||||
</configfiles>
|
||||
<outputs>
|
||||
<data name="output" format="html" />
|
||||
</outputs>
|
||||
</tool>
|
||||
@@ -244,6 +244,7 @@
|
||||
|
||||
<tool file="multiple_versions_changes_v01.xml" />
|
||||
<tool file="multiple_versions_changes_v02.xml" />
|
||||
<tool file="html_output.xml" />
|
||||
|
||||
<tool file="interactivetool_simple.xml" />
|
||||
<tool file="interactivetool_two_entry_points.xml" />
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
from .framework import (
|
||||
selenium_test,
|
||||
SeleniumIntegrationTestCase,
|
||||
)
|
||||
|
||||
|
||||
class TestAllowListSanitization(SeleniumIntegrationTestCase):
|
||||
run_as_admin = True
|
||||
axe_skip = True # skip testing iframe contents
|
||||
|
||||
@classmethod
|
||||
def handle_galaxy_config_kwds(cls, config):
|
||||
super().handle_galaxy_config_kwds(config)
|
||||
config["sanitize_all_html"] = True
|
||||
|
||||
def run_html_output(self):
|
||||
history_id = self.dataset_populator.new_history()
|
||||
run_response = self.dataset_populator.run_tool("html_output", {}, history_id=history_id)
|
||||
hda_id = run_response["outputs"][0]["id"]
|
||||
self.dataset_populator.wait_for_dataset(history_id, dataset_id=hda_id)
|
||||
return hda_id
|
||||
|
||||
@selenium_test
|
||||
def test_html_output_sanitized(self):
|
||||
self.login()
|
||||
hda_id = self.run_html_output()
|
||||
self.get(f"datasets/{hda_id}/preview")
|
||||
self.wait_for_selector_visible("[data-description='sanitization warning']")
|
||||
self.assert_selector_absent("[data-description='allowlist link']")
|
||||
self.screenshot("sanitization warning")
|
||||
assert self.switch_to_frame()
|
||||
self.assert_selector_absent("[data-description='hello-world']")
|
||||
|
||||
@selenium_test
|
||||
def test_html_output_sanitized_admin(self):
|
||||
self.admin_login()
|
||||
hda_id = self.run_html_output()
|
||||
self.get(f"datasets/{hda_id}/preview")
|
||||
self.wait_for_selector_visible("[data-description='sanitization warning']")
|
||||
self.wait_for_selector_visible("[data-description='allowlist link']")
|
||||
self.screenshot("sanitization warning admin")
|
||||
try:
|
||||
self._put(
|
||||
"/api/sanitize_allow?tool_id=html_output", data={"params": {"tool_id": "html_output"}}, admin=True
|
||||
).raise_for_status()
|
||||
self.driver.refresh()
|
||||
self.assert_selector_absent("[data-description='sanitization warning']")
|
||||
self.assert_selector_absent("[data-description='allowlist link']")
|
||||
assert self.switch_to_frame()
|
||||
self.wait_for_selector("[data-description='hello-world']")
|
||||
finally:
|
||||
self._delete("/api/sanitize_allow?tool_id=html_output", admin=True)
|
||||
Reference in New Issue
Block a user