{"id":3983,"date":"2026-07-30T03:42:38","date_gmt":"2026-07-30T03:42:38","guid":{"rendered":"https:\/\/tucumandevelopers.com\/index.php\/2026\/07\/30\/security-notes-for-serving-static-files-with-staypresent\/"},"modified":"2026-07-30T03:42:38","modified_gmt":"2026-07-30T03:42:38","slug":"security-notes-for-serving-static-files-with-staypresent","status":"publish","type":"post","link":"https:\/\/tucumandevelopers.com\/index.php\/2026\/07\/30\/security-notes-for-serving-static-files-with-staypresent\/","title":{"rendered":"Security Notes for Serving Static Files with StayPresent"},"content":{"rendered":"<div>\n<div><\/div>\n<h2> <a name=\"why-this-is-intentional\" href=\"#why-this-is-intentional\"> <\/a> Why This Is Intentional <\/h2>\n<p>This isn&#8217;t an oversight \u2014 it&#8217;s what makes <code>web.html()<\/code>\/<code>web.markdown()<\/code> usable with zero configuration for the overwhelming majority of legitimate cases (a template directory containing only the page and its assets). Requiring an explicit allowlist of every servable file for every deployment would defeat the &#8220;read fresh from disk&#8221; simplicity that makes these two functions useful in the first place. StayPresent&#8217;s own documentation notes that an opt-in allowlist restricting exposure to only <em>referenced<\/em> files is being considered for a future release \u2014 but as things stand, the responsibility for directory contents is on you.<\/p>\n<h2> <a name=\"path-traversal-protection\" href=\"#path-traversal-protection\"> <\/a> Path Traversal Protection <\/h2>\n<p>What StayPresent <em>does<\/em> protect against automatically is path traversal \u2014 a request trying to escape the target directory entirely, via <code>..\/<\/code> sequences, absolute paths, or similar tricks. Static asset lookups use <code>send_from_directory<\/code> internally, which refuses to serve any path that would resolve outside the intended directory. So while every file <em>inside<\/em> <code>templates\/<\/code> is reachable, nothing <em>outside<\/em> it is, regardless of how a request tries to reference it.<\/p>\n<h2> <a name=\"the-onetime-directory-warning\" href=\"#the-onetime-directory-warning\"> <\/a> The One-Time Directory Warning <\/h2>\n<p>StayPresent logs a one-time <code>WARNING<\/code>-level message through the <code>\"staypresent\"<\/code> logger the first time <code>html()<\/code> or <code>markdown()<\/code> exposes a given directory as a static-asset fallback \u2014 specifically as a reminder that this exposure is directory-wide: <\/p>\n<div>\n<pre><code><span>import<\/span> <span>logging<\/span> <span>logging<\/span><span>.<\/span><span>getLogger<\/span><span>(<\/span><span>\"<\/span><span>staypresent<\/span><span>\"<\/span><span>).<\/span><span>setLevel<\/span><span>(<\/span><span>logging<\/span><span>.<\/span><span>WARNING<\/span><span>)<\/span> <span># ... elsewhere ... <\/span><span>staypresent<\/span><span>.<\/span><span>web<\/span><span>.<\/span><span>html<\/span><span>(<\/span><span>\"<\/span><span>templates\/index.html<\/span><span>\"<\/span><span>)<\/span> <span># WARNING logged once: directory 'templates\/' is now servable as static assets <\/span><\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>This warning fires once per directory per process, not once per request \u2014 it&#8217;s meant to catch your attention during development or the first deploy, not spam your logs continuously.<\/p>\n<h2> <a name=\"markdownspecific-protections-escaping\" href=\"#markdownspecific-protections-escaping\"> <\/a> Markdown-Specific Protections: Escaping <\/h2>\n<p>Beyond directory exposure, <code>web.markdown()<\/code> has its own layer of protection specific to rendering user-authored (or at least file-authored) content into HTML. Plain text \u2014 and every recognized Markdown construct \u2014 is HTML-escaped before rendering, applied exactly once per character, including inside link\/image URLs and titles. A <code>.md<\/code> file containing literal <code>&lt;script&gt;<\/code> tags, or stray <code>&lt;<\/code>, <code>&gt;<\/code>, <code>&amp;<\/code> characters, cannot inject markup into the rendered page. Only a fixed list of recognized block-level raw-HTML tags (the kind used for centered logo\/badge headers) is ever passed through unescaped \u2014 arbitrary inline HTML is not.<\/p>\n<h2> <a name=\"markdownspecific-protections-url-scheme-filtering\" href=\"#markdownspecific-protections-url-scheme-filtering\"> <\/a> Markdown-Specific Protections: URL Scheme Filtering <\/h2>\n<p>Escaping alone doesn&#8217;t stop an <em>executable<\/em> destination \u2014 a link that&#8217;s syntactically valid Markdown but points somewhere dangerous. <code>web.markdown()<\/code> checks link\/image URLs against a scheme blocklist independently of escaping:<\/p>\n<ul>\n<li> <code>javascript:<\/code> and <code>vbscript:<\/code> \u2014 rejected for both links and images, since these run arbitrary script directly.<\/li>\n<li> <code>file:<\/code> \u2014 rejected for both, since it enables local filesystem access.<\/li>\n<li> <code>data:<\/code> \u2014 rejected for <strong>links<\/strong> specifically (it can smuggle a full HTML document, including script, into a single click), but still allowed for <strong>images<\/strong>, since inline <code>data:<\/code> images are a common and inert pattern for embedding small icons.<\/li>\n<\/ul>\n<p>A rejected URL falls back to plain, already-escaped text: <\/p>\n<div>\n<pre><code><span>[<\/span><span>click me<\/span><span>](<\/span><span>javascript:alert(1<\/span><span>)<\/span>) <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>renders as the plain text <code>click me<\/code> \u2014 not a working link.<\/p>\n<h2> <a name=\"whats-rejected-vs-whats-allowed\" href=\"#whats-rejected-vs-whats-allowed\"> <\/a> What&#8217;s Rejected vs What&#8217;s Allowed <\/h2>\n<div>\n<table>\n<thead>\n<tr>\n<th>URL type<\/th>\n<th>Links<\/th>\n<th>Images<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><code>http(s):\/\/<\/code><\/td>\n<td>Allowed<\/td>\n<td>Allowed<\/td>\n<\/tr>\n<tr>\n<td>Relative (<code>\/path<\/code>, <code>.\/file<\/code>)<\/td>\n<td>Allowed<\/td>\n<td>Allowed<\/td>\n<\/tr>\n<tr>\n<td>Anchor (<code>#section<\/code>)<\/td>\n<td>Allowed<\/td>\n<td>Allowed<\/td>\n<\/tr>\n<tr>\n<td><code>data:<\/code><\/td>\n<td><strong>Rejected<\/strong><\/td>\n<td>Allowed<\/td>\n<\/tr>\n<tr>\n<td> <code>javascript:<\/code> \/ <code>vbscript:<\/code> <\/td>\n<td><strong>Rejected<\/strong><\/td>\n<td><strong>Rejected<\/strong><\/td>\n<\/tr>\n<tr>\n<td><code>file:<\/code><\/td>\n<td><strong>Rejected<\/strong><\/td>\n<td><strong>Rejected<\/strong><\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<h2> <a name=\"structuring-directories-safely\" href=\"#structuring-directories-safely\"> <\/a> Structuring Directories Safely <\/h2>\n<p>The practical takeaway is directory hygiene: keep the directory passed to <code>web.html()<\/code>\/<code>web.markdown()<\/code> limited to files you&#8217;re genuinely comfortable being publicly reachable. <\/p>\n<div>\n<pre><code># Safer structure public\/ \u251c\u2500\u2500 dashboard.html \u251c\u2500\u2500 style.css \u251c\u2500\u2500 logo.png secrets\/ \u251c\u2500\u2500 .env \u251c\u2500\u2500 credentials.json <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>As long as <code>secrets\/<\/code> isn&#8217;t the directory (or a parent of the directory) passed to <code>web.html()<\/code>\/<code>web.markdown()<\/code>, path traversal protection keeps it unreachable regardless of what&#8217;s inside <code>public\/<\/code>.<\/p>\n<h2> <a name=\"full-example\" href=\"#full-example\"> <\/a> Full Example <\/h2>\n<div>\n<pre><code><span>import<\/span> <span>logging<\/span> <span>import<\/span> <span>staypresent<\/span> <span>logging<\/span><span>.<\/span><span>getLogger<\/span><span>(<\/span><span>\"<\/span><span>staypresent<\/span><span>\"<\/span><span>).<\/span><span>setLevel<\/span><span>(<\/span><span>logging<\/span><span>.<\/span><span>WARNING<\/span><span>)<\/span> <span># 'public\/' contains only dashboard.html, style.css, and logo.png \u2014 # nothing sensitive lives alongside it. <\/span><span>staypresent<\/span><span>.<\/span><span>web<\/span><span>.<\/span><span>html<\/span><span>(<\/span><span>\"<\/span><span>public\/dashboard.html<\/span><span>\"<\/span><span>)<\/span> <span>staypresent<\/span><span>.<\/span><span>run<\/span><span>(<\/span><span>\"<\/span><span>bot.py<\/span><span>\"<\/span><span>)<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<h2> <a name=\"best-practices\" href=\"#best-practices\"> <\/a> Best Practices <\/h2>\n<ul>\n<li>Treat any directory passed to <code>web.html()<\/code>\/<code>web.markdown()<\/code> as fully public \u2014 audit its contents the same way you&#8217;d audit a directory served by a real static file server.<\/li>\n<li>Keep secrets, <code>.env<\/code> files, and source code in a separate directory tree entirely, never alongside a served template or Markdown file.<\/li>\n<li>Leave <code>\"staypresent\"<\/code> logging at <code>WARNING<\/code> or above in production specifically so the one-time directory-exposure notice doesn&#8217;t get lost in <code>INFO<\/code>-level noise.<\/li>\n<\/ul>\n<h2> <a name=\"common-mistakes\" href=\"#common-mistakes\"> <\/a> Common Mistakes <\/h2>\n<ul>\n<li> <strong>Putting a template file directly in a project&#8217;s root directory<\/strong>, which then exposes the entire project \u2014 source files, config, everything \u2014 as static assets.<\/li>\n<li> <strong>Assuming Markdown rendering sanitizes <em>destinations<\/em> the same way it escapes <em>text<\/em>.<\/strong> Escaping and scheme filtering are two separate protections \u2014 both matter, and both are handled, but it&#8217;s worth understanding they&#8217;re not the same mechanism.<\/li>\n<li> <strong>Relying on &#8220;nothing links to it&#8221; as a security boundary.<\/strong> Every file in the directory is reachable by direct request, whether or not the rendered page itself contains a link to it.<\/li>\n<\/ul>\n<h2> <a name=\"faqs\" href=\"#faqs\"> <\/a> FAQs <\/h2>\n<p><strong>Does this affect <code>web.text()<\/code> or <code>web.json()<\/code>?<\/strong><br \/> No \u2014 directory-wide static asset exposure only applies to <code>web.html()<\/code> and <code>web.markdown()<\/code>, since only those two serve files from disk in the first place.<\/p>\n<p><strong>Can I disable static asset serving entirely?<\/strong><br \/> Not currently \u2014 it&#8217;s inherent to how <code>html()<\/code>\/<code>markdown()<\/code> work. The mitigation is directory hygiene, not a configuration flag.<\/p>\n<p><strong>Does the URL scheme blocklist apply to <code>web.html()<\/code> too?<\/strong><br \/> No \u2014 that specific protection is part of the Markdown renderer. <code>web.html()<\/code> serves whatever HTML you&#8217;ve written as-is, so any sanitization of links\/scripts inside a raw <code>.html<\/code> file is your own responsibility.<\/p>\n<h2> <a name=\"conclusion\" href=\"#conclusion\"> <\/a> Conclusion <\/h2>\n<p><strong>Python static file security<\/strong> with StayPresent comes down to two layers: what&#8217;s protected automatically (path traversal, HTML escaping, dangerous URL schemes in rendered Markdown) and what&#8217;s still on you (directory contents). Understanding the difference \u2014 and keeping served directories limited to genuinely public files \u2014 is what keeps a convenient zero-config feature from becoming an accidental information leak. <\/p>\n<div>\n<pre><code>pip <span>install <\/span>staypresent[prod] <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<\/p><\/div>\n<\/div>\n<\/div>\n<\/div>\n<p>Fuente: <a href=\"https:\/\/dev.to\/codenamew\/security-notes-for-serving-static-files-with-staypresent-mae\">Art\u00edculo original<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Why This Is Intentional This isn&#8217;t an oversight \u2014 it&#8217;s what makes web.html()\/web.markdown() usable with zero configuration for the overwhelming majority of legitimate cases (a template directory containing only the page and its assets). Requiring an explicit allowlist of every servable file for every deployment would defeat the &#8220;read fresh from disk&#8221; simplicity that makes [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":3982,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":true,"jetpack_social_options":{"image_generator_settings":{"template":"highway","default_image_id":0,"font":"","enabled":false},"version":2}},"categories":[41],"tags":[],"class_list":["post-3983","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devto"],"jetpack_publicize_connections":[],"_links":{"self":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/3983","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/comments?post=3983"}],"version-history":[{"count":0,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/3983\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/media\/3982"}],"wp:attachment":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/media?parent=3983"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/categories?post=3983"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/tags?post=3983"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}