Spaces:
Sleeping
Sleeping
Fix: eliminate all iframes - use onerror JS injection for protection + beforeunload
Browse files- interface/components/image_protection.py +73 -133
- interface/main.py +10 -14
interface/components/image_protection.py
CHANGED
|
@@ -3,44 +3,30 @@
|
|
| 3 |
Injects CSS and JavaScript into the Streamlit page to prevent users from
|
| 4 |
downloading, dragging, or otherwise saving the confidential medical images.
|
| 5 |
|
| 6 |
-
|
| 7 |
Streamlit's st.markdown(unsafe_allow_html=True) renders <style> tags but
|
| 8 |
-
STRIPS <script> tags
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
the main Streamlit page via window.parent.document (same-origin).
|
| 13 |
|
| 14 |
Protection layers (defence-in-depth):
|
| 15 |
-
1. CSS: pointer-events:none, user-select:none
|
| 16 |
-
2. CSS: transparent ::after overlay on stImage containers
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
6. JS: dragstart blocked for all images.
|
| 23 |
-
7. JS: MutationObserver re-applies draggable=false to dynamically added
|
| 24 |
-
images (Streamlit re-renders on every interaction).
|
| 25 |
-
8. JS: Blob/URL revocation — monkey-patches URL.createObjectURL and
|
| 26 |
-
document.createElement to block programmatic image extraction.
|
| 27 |
-
|
| 28 |
-
IMPORTANT LIMITATION:
|
| 29 |
-
No client-side measure can guarantee absolute prevention. A technically
|
| 30 |
-
sophisticated user could still extract images through OS-level screenshots,
|
| 31 |
-
network packet inspection, or browser extensions that bypass JS hooks.
|
| 32 |
-
These protections eliminate ALL standard browser download paths and raise
|
| 33 |
-
the bar significantly.
|
| 34 |
"""
|
| 35 |
|
| 36 |
import streamlit as st
|
| 37 |
-
import streamlit.components.v1 as components
|
| 38 |
|
| 39 |
-
# ── CSS injected via st.markdown
|
| 40 |
-
|
| 41 |
<style>
|
| 42 |
/* Layer 1: Disable ALL interaction on <img> tags */
|
| 43 |
-
img {
|
| 44 |
pointer-events: none !important;
|
| 45 |
user-select: none !important;
|
| 46 |
-webkit-user-select: none !important;
|
|
@@ -69,117 +55,71 @@ img {
|
|
| 69 |
-webkit-user-drag: none !important;
|
| 70 |
user-drag: none !important;
|
| 71 |
}
|
| 72 |
-
</style>
|
| 73 |
-
"""
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
(function () {
|
| 81 |
-
// The parent document is the real Streamlit page
|
| 82 |
-
var doc;
|
| 83 |
-
try { doc = window.parent.document; } catch(e) { doc = document; }
|
| 84 |
-
|
| 85 |
-
// Guard: only inject once per page lifecycle
|
| 86 |
-
if (doc.__ophthalmo_protection__) return;
|
| 87 |
-
doc.__ophthalmo_protection__ = true;
|
| 88 |
-
|
| 89 |
-
function block(e) {
|
| 90 |
-
e.preventDefault();
|
| 91 |
-
e.stopPropagation();
|
| 92 |
-
e.stopImmediatePropagation();
|
| 93 |
-
return false;
|
| 94 |
-
}
|
| 95 |
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
if
|
| 111 |
-
|
| 112 |
-
if
|
| 113 |
-
|
| 114 |
-
if
|
| 115 |
-
|
| 116 |
-
if
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
// ── Layer 7: MutationObserver — lock new images as they appear ──────
|
| 131 |
-
function lockImages(root) {
|
| 132 |
-
var imgs = (root.querySelectorAll) ? root.querySelectorAll('img') : [];
|
| 133 |
-
for (var i = 0; i < imgs.length; i++) {
|
| 134 |
-
imgs[i].setAttribute('draggable', 'false');
|
| 135 |
-
imgs[i].ondragstart = function() { return false; };
|
| 136 |
-
imgs[i].oncontextmenu = function() { return false; };
|
| 137 |
}
|
| 138 |
}
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
}
|
| 153 |
-
}, 100); // debounce 100ms
|
| 154 |
-
});
|
| 155 |
-
obs.observe(doc.body, { childList: true, subtree: true });
|
| 156 |
-
|
| 157 |
-
// ── Layer 8: Neuter Blob URL creation for images ────────────────────
|
| 158 |
-
// Prevents programmatic extraction via createObjectURL
|
| 159 |
-
var origCreateObjectURL = URL.createObjectURL;
|
| 160 |
-
URL.createObjectURL = function(obj) {
|
| 161 |
-
if (obj instanceof Blob && obj.type && obj.type.startsWith('image/')) {
|
| 162 |
-
console.warn('[OphthalmoCapture] Blob URL creation blocked for images');
|
| 163 |
-
return '';
|
| 164 |
-
}
|
| 165 |
-
return origCreateObjectURL.call(URL, obj);
|
| 166 |
-
};
|
| 167 |
-
|
| 168 |
-
})();
|
| 169 |
-
</script>
|
| 170 |
"""
|
| 171 |
|
| 172 |
|
| 173 |
def inject_image_protection():
|
| 174 |
-
"""Inject
|
| 175 |
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
|
|
|
| 179 |
"""
|
| 180 |
-
|
| 181 |
-
st.markdown(_PROTECTION_CSS, unsafe_allow_html=True)
|
| 182 |
-
|
| 183 |
-
# JS — uses components.html; internal guard prevents duplicate listeners.
|
| 184 |
-
# height=0, width=0 makes the iframe invisible and avoids layout shifts.
|
| 185 |
-
components.html(_PROTECTION_JS_HTML, height=0, width=0, scrolling=False)
|
|
|
|
| 3 |
Injects CSS and JavaScript into the Streamlit page to prevent users from
|
| 4 |
downloading, dragging, or otherwise saving the confidential medical images.
|
| 5 |
|
| 6 |
+
APPROACH:
|
| 7 |
Streamlit's st.markdown(unsafe_allow_html=True) renders <style> tags but
|
| 8 |
+
STRIPS <script> tags. To run JS without components.html (which creates
|
| 9 |
+
iframes that cause layout shifts on HF Spaces), we use the classic
|
| 10 |
+
<img src=x onerror="…"> trick — the onerror handler executes inline JS
|
| 11 |
+
directly in the main Streamlit DOM, with no iframe.
|
|
|
|
| 12 |
|
| 13 |
Protection layers (defence-in-depth):
|
| 14 |
+
1. CSS: pointer-events:none, user-select:none on <img>.
|
| 15 |
+
2. CSS: transparent ::after overlay on stImage containers.
|
| 16 |
+
3. CSS: -webkit-touch-callout:none for mobile.
|
| 17 |
+
4. JS: contextmenu blocked on entire document.
|
| 18 |
+
5. JS: Ctrl+S / Ctrl+U / Ctrl+P / F12 / DevTools shortcuts blocked.
|
| 19 |
+
6. JS: dragstart blocked for images.
|
| 20 |
+
7. JS: MutationObserver re-applies draggable=false to new images.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
"""
|
| 22 |
|
| 23 |
import streamlit as st
|
|
|
|
| 24 |
|
| 25 |
+
# ── CSS + JS injected via st.markdown ────────────────────────────────────────
|
| 26 |
+
_PROTECTION_HTML = """
|
| 27 |
<style>
|
| 28 |
/* Layer 1: Disable ALL interaction on <img> tags */
|
| 29 |
+
img:not([data-protection-trigger]) {
|
| 30 |
pointer-events: none !important;
|
| 31 |
user-select: none !important;
|
| 32 |
-webkit-user-select: none !important;
|
|
|
|
| 55 |
-webkit-user-drag: none !important;
|
| 56 |
user-drag: none !important;
|
| 57 |
}
|
|
|
|
|
|
|
| 58 |
|
| 59 |
+
/* Hide the trigger pixel completely */
|
| 60 |
+
img[data-protection-trigger] {
|
| 61 |
+
display: none !important;
|
| 62 |
+
}
|
| 63 |
+
</style>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
+
<!-- JS via onerror trick — runs directly in Streamlit DOM, no iframe -->
|
| 66 |
+
<img data-protection-trigger src="x" onerror="
|
| 67 |
+
(function(doc){
|
| 68 |
+
if(doc.__ophthalmo_protection__) return;
|
| 69 |
+
doc.__ophthalmo_protection__=true;
|
| 70 |
+
|
| 71 |
+
function block(e){e.preventDefault();e.stopPropagation();e.stopImmediatePropagation();return false;}
|
| 72 |
+
|
| 73 |
+
doc.addEventListener('contextmenu',function(e){return block(e);},true);
|
| 74 |
+
|
| 75 |
+
doc.addEventListener('keydown',function(e){
|
| 76 |
+
var dominated=false;
|
| 77 |
+
var ctrl=e.ctrlKey||e.metaKey;
|
| 78 |
+
var key=e.key?e.key.toLowerCase():'';
|
| 79 |
+
if(ctrl&&key==='s')dominated=true;
|
| 80 |
+
if(ctrl&&key==='u')dominated=true;
|
| 81 |
+
if(ctrl&&key==='p')dominated=true;
|
| 82 |
+
if(e.keyCode===123)dominated=true;
|
| 83 |
+
if(ctrl&&e.shiftKey&&key==='i')dominated=true;
|
| 84 |
+
if(ctrl&&e.shiftKey&&key==='j')dominated=true;
|
| 85 |
+
if(ctrl&&e.shiftKey&&key==='c')dominated=true;
|
| 86 |
+
if(dominated)return block(e);
|
| 87 |
+
},true);
|
| 88 |
+
|
| 89 |
+
doc.addEventListener('dragstart',function(e){
|
| 90 |
+
if(e.target&&e.target.tagName==='IMG')return block(e);
|
| 91 |
+
},true);
|
| 92 |
+
|
| 93 |
+
function lockImgs(root){
|
| 94 |
+
var imgs=root.querySelectorAll?root.querySelectorAll('img:not([data-protection-trigger])'):[];
|
| 95 |
+
for(var i=0;i<imgs.length;i++){
|
| 96 |
+
imgs[i].setAttribute('draggable','false');
|
| 97 |
+
imgs[i].ondragstart=function(){return false;};
|
| 98 |
+
imgs[i].oncontextmenu=function(){return false;};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
}
|
| 100 |
}
|
| 101 |
+
lockImgs(doc);
|
| 102 |
+
|
| 103 |
+
var t=null;
|
| 104 |
+
new MutationObserver(function(muts){
|
| 105 |
+
if(t)return;
|
| 106 |
+
t=setTimeout(function(){
|
| 107 |
+
t=null;
|
| 108 |
+
lockImgs(doc);
|
| 109 |
+
},200);
|
| 110 |
+
}).observe(doc.body,{childList:true,subtree:true});
|
| 111 |
+
|
| 112 |
+
})(document);
|
| 113 |
+
" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
"""
|
| 115 |
|
| 116 |
|
| 117 |
def inject_image_protection():
|
| 118 |
+
"""Inject CSS + JS image-protection layers into the page.
|
| 119 |
|
| 120 |
+
Uses st.markdown only — NO components.html iframes — so there are
|
| 121 |
+
zero layout shifts. The JS guard (doc.__ophthalmo_protection__)
|
| 122 |
+
ensures listeners are attached only once per page lifecycle even
|
| 123 |
+
though st.markdown re-renders on every Streamlit rerun.
|
| 124 |
"""
|
| 125 |
+
st.markdown(_PROTECTION_HTML, unsafe_allow_html=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
interface/main.py
CHANGED
|
@@ -250,23 +250,19 @@ with st.spinner(t("loading_whisper", model=selected_model)):
|
|
| 250 |
model = load_whisper_model(selected_model)
|
| 251 |
# ── BROWSER CLOSE GUARD (beforeunload) ───────────────────────────────────
|
| 252 |
# Warn the user when they try to close/reload the tab with data in session.
|
|
|
|
| 253 |
if sm.has_undownloaded_data() and not st.session_state.get("session_downloaded", False):
|
| 254 |
-
st.
|
| 255 |
-
"""
|
| 256 |
-
<script>
|
| 257 |
(function(){
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
});
|
| 264 |
-
} catch(err) {}
|
| 265 |
})();
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
height=0,
|
| 269 |
-
width=0,
|
| 270 |
)
|
| 271 |
# ── MAIN CONTENT ─────────────────────────────────────────────────────────────
|
| 272 |
st.title(f"{config.APP_ICON} {config.APP_TITLE}")
|
|
|
|
| 250 |
model = load_whisper_model(selected_model)
|
| 251 |
# ── BROWSER CLOSE GUARD (beforeunload) ───────────────────────────────────
|
| 252 |
# Warn the user when they try to close/reload the tab with data in session.
|
| 253 |
+
# Uses img onerror trick to avoid creating an iframe (which causes layout shifts).
|
| 254 |
if sm.has_undownloaded_data() and not st.session_state.get("session_downloaded", False):
|
| 255 |
+
st.markdown(
|
| 256 |
+
"""<img src="x" style="display:none!important" onerror="
|
|
|
|
| 257 |
(function(){
|
| 258 |
+
if(window.__beforeunload_set__)return;
|
| 259 |
+
window.__beforeunload_set__=true;
|
| 260 |
+
window.addEventListener('beforeunload',function(e){
|
| 261 |
+
e.preventDefault();e.returnValue='';
|
| 262 |
+
});
|
|
|
|
|
|
|
| 263 |
})();
|
| 264 |
+
" />""",
|
| 265 |
+
unsafe_allow_html=True,
|
|
|
|
|
|
|
| 266 |
)
|
| 267 |
# ── MAIN CONTENT ─────────────────────────────────────────────────────────────
|
| 268 |
st.title(f"{config.APP_ICON} {config.APP_TITLE}")
|