<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Security on Erik Failing</title><link>https://erikfailing.com/tags/security/</link><description>Recent content in Security on Erik Failing</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Mon, 02 Nov 2026 11:12:17 -0600</lastBuildDate><atom:link href="https://erikfailing.com/tags/security/index.xml" rel="self" type="application/rss+xml"/><item><title>Securing a Static Site</title><link>https://erikfailing.com/posts/securing-this-site/</link><pubDate>Fri, 30 Oct 2026 21:10:00 -0500</pubDate><guid>https://erikfailing.com/posts/securing-this-site/</guid><description>The security changes I made to this site, ordered by what they actually stop, from email spoofing down to certificate mis-issuance.</description><content:encoded><![CDATA[<p>A static site on GitHub Pages <span class="accent accent-ward">doesn&rsquo;t have much to attack</span>: no logins, no forms, no cookies, no
database. But &ldquo;not much&rdquo; isn&rsquo;t nothing, so I went through the whole setup (the domain, the build
pipeline and the browser side) and fixed what was worth fixing. Below are the changes in severity
order, what attack each one stops, and the couple of things I decided weren&rsquo;t worth it.</p>
<table>
	<thead>
			<tr>
					<th>#</th>
					<th>Change</th>
					<th>Attack it stops</th>
					<th>Severity before</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>SPF + DMARC records</td>
					<td>Anyone sending email &ldquo;from&rdquo; my domain</td>
					<td>Medium</td>
			</tr>
			<tr>
					<td>2</td>
					<td>Hardened build pipeline</td>
					<td>A tampered tool or compromised step changing the live site</td>
					<td>Medium (unlikely, high impact)</td>
			</tr>
			<tr>
					<td>3</td>
					<td>Content-Security-Policy</td>
					<td>Injected scripts running on my pages</td>
					<td>Low to Medium</td>
			</tr>
			<tr>
					<td>4</td>
					<td>HSTS</td>
					<td>First-visit interception on hostile Wi-Fi</td>
					<td>Low</td>
			</tr>
			<tr>
					<td>5</td>
					<td>CAA records</td>
					<td>A certificate for my domain issued by the wrong authority</td>
					<td>Low</td>
			</tr>
	</tbody>
</table>
<h2 id="email-spoofing-spf-and-dmarc">Email Spoofing: SPF And DMARC</h2>
<p>This was the biggest open gap, and honestly not one I expected going in. I don&rsquo;t send any email
from erikfailing.com, so I never thought about it, but that&rsquo;s exactly the problem. With no email
records at all, nothing told receiving mail servers that mail &ldquo;from&rdquo; my domain was fake. <span class="accent accent-point">Anyone
could send a message as <code>erik@erikfailing.com</code></span> to a recruiter or a colleague, and the receiving
server had no published policy saying to reject it.</p>
<p>For a domain that never sends mail, the fix is <span class="accent accent-ward">two DNS records</span> that say &ldquo;nothing is allowed to
send as me, and reject anything that tries&rdquo;:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">erikfailing.com         TXT  &#34;v=spf1 -all&#34;
</span></span><span class="line"><span class="cl">_dmarc.erikfailing.com  TXT  &#34;v=DMARC1; p=reject; sp=reject; adkim=s; aspf=s;&#34;
</span></span></code></pre></div><p>SPF with <code>-all</code> lists zero allowed senders, and DMARC with <code>p=reject</code> tells every receiver that
checks (Gmail, Outlook, Yahoo, etc&hellip;) to reject mail that fails. Since I don&rsquo;t send mail from
this domain, there&rsquo;s nothing for these records to break. Gmail already spam-folders a lot of
unauthenticated mail on its own, but that&rsquo;s Gmail&rsquo;s call; these records make it <span class="accent accent-ward">my explicit
policy everywhere</span>.</p>
<h2 id="the-build-pipeline">The Build Pipeline</h2>
<p>I think this is the most interesting one, because it&rsquo;s <span class="accent accent-point">the only path to something actually bad</span>.
Anyone who can change what the build produces controls the live site, and no browser header can
stop that, since they&rsquo;d just remove the header too. <span class="accent accent-iron">Supply chain attacks on CI</span> are real: in March
2025, the popular <code>tj-actions/changed-files</code> GitHub Action had its version tags repointed to
malicious code, and every workflow using it ran that code on its next build.</p>
<p>So I tightened the <span class="accent accent-flow">GitHub Actions</span> workflow in a few ways:</p>
<ul>
<li><strong>Removed tools I wasn&rsquo;t using.</strong> The workflow I started from (Hugo&rsquo;s official one) downloaded
Dart Sass on every build and put it on the PATH, without verifying it. My site doesn&rsquo;t use Sass
at all, so that was a free download of <span class="accent accent-crimson">attack surface</span>. Go and Node steps went too.</li>
<li><strong>Verified the Hugo download.</strong> The workflow now checks Hugo&rsquo;s tarball against a <span class="accent accent-arcane">SHA-256</span> hash
from the release&rsquo;s checksums file, written into the workflow itself. If the download ever
doesn&rsquo;t match, the build fails before Hugo is even unpacked:</li>
</ul>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="l">echo &#34;${HUGO_SHA256}  ${{ runner.temp }}/hugo_${HUGO_VERSION}_linux-amd64.tar.gz&#34; | sha256sum --check --strict</span><span class="w">
</span></span></span></code></pre></div><ul>
<li><strong><span class="accent accent-ward">Split permissions by job.</span></strong> Before, the whole workflow had the right to publish the site,
including the build job where Hugo and the theme&rsquo;s code run. Now the build job can only read the
repo, and only the small deploy job (which just runs GitHub&rsquo;s own deploy action) can publish.</li>
<li><strong>Don&rsquo;t leave the token lying around.</strong> <code>actions/checkout</code> keeps the job&rsquo;s GitHub token on disk
by default, <span class="accent accent-crimson">where every later step can read it</span>. Nothing after checkout needs it, so
<code>persist-credentials: false</code> removes it.</li>
<li><strong><span class="accent accent-flow">Dependabot</span> for updates.</strong> It opens a pull request when the actions or the theme have updates,
so nothing goes stale and nothing changes without me reviewing it.</li>
</ul>
<p>I didn&rsquo;t pin actions to <span class="accent accent-iron">commit SHAs</span>. All of them are GitHub&rsquo;s own, and pinning would mean an
update pull request for every minor release. That&rsquo;s a trade-off I&rsquo;m fine with for now.</p>
<h2 id="content-security-policy">Content-Security-Policy</h2>
<p>A <span class="accent accent-ward">CSP</span> tells the browser where scripts, styles, fonts and images are allowed to come from. Mine
only allows the site itself:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">default-src &#39;none&#39;; script-src &#39;self&#39;; style-src &#39;self&#39;; img-src &#39;self&#39; data:; font-src &#39;self&#39;;
</span></span><span class="line"><span class="cl">connect-src &#39;self&#39;; manifest-src &#39;self&#39;; base-uri &#39;self&#39;; form-action &#39;none&#39;
</span></span></code></pre></div><p>With no user input, there&rsquo;s no classic <span class="accent accent-toxic accent-grad">XSS</span> path here. What the CSP covers is content-level
injection: a bad embed pasted into a post, or a malicious contribution that adds a script tag.
Those get blocked, along with any inline script or third-party resource.</p>
<p>Two catches made this more work than a one-liner:</p>
<ul>
<li><strong>GitHub Pages can&rsquo;t send custom headers,</strong> so the CSP lives in a <code>&lt;meta&gt;</code> tag. That works for
most directives, but not <code>frame-ancestors</code> (<span class="accent accent-ghostly accent-grad">clickjacking</span> protection), and not reporting.</li>
<li><strong>No <code>'unsafe-inline'</code></strong> meant cleaning up the theme first. PaperMod had three inline scripts,
which I moved into one self-hosted JS file with a <span class="accent accent-arcane">subresource integrity</span> hash. Hugo&rsquo;s syntax
highlighting wrote colors as inline <code>style=&quot;&quot;</code> attributes (75 of them on one post), so I
switched it to CSS classes.</li>
</ul>
<p>To be honest about its limits: anyone who controls the build (point 2) can just edit the meta tag,
so <span class="accent accent-point">the CSP is defense in depth, not a wall</span>. I tested it by loading every page in headless Chrome
with zero violations, and with a positive control page to make sure violations were actually
being caught.</p>
<h2 id="hsts">HSTS</h2>
<p><span class="accent accent-ward">HSTS</span> tells browsers &ldquo;only ever use HTTPS for this domain.&rdquo; Without it, the very first request to
<code>http://erikfailing.com</code> (say, someone typing the address on hotel Wi-Fi) goes out unencrypted
before GitHub&rsquo;s redirect to HTTPS. An attacker on that network can answer it themselves with a
fake copy of the site, a <span class="accent accent-crimson">phishing</span> link, or a cryptominer. Hotspot and ISP script injection has
happened for real (Comcast&rsquo;s Wi-Fi hotspots injected ads into pages back in 2014). Browsers trying
HTTPS first by default shrinks this gap, but doesn&rsquo;t close it.</p>
<p>GitHub Pages can&rsquo;t send custom headers, and HSTS can&rsquo;t be set from a meta tag. My first support
ticket asking GitHub to enable it got <span class="accent accent-glitch accent-grad">auto-closed</span> because I was on the free plan, so I upgraded to
Pro and reopened it lol.</p>
<p><strong>Update (November 2, 2026):</strong> GitHub got back to me after I reopened the ticket and turned it on.
Every page now sends this header:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Strict-Transport-Security: max-age=31556952
</span></span></code></pre></div><p>That <code>max-age</code> is one year in seconds (31,556,952 / 86,400 = 365.2425 days, the average
Gregorian year). So once a browser has seen the site over HTTPS, it&rsquo;ll <span class="accent accent-ward">refuse plain HTTP for
erikfailing.com</span> for the next year, and the hotel Wi-Fi trick above stops working on repeat
visitors. It doesn&rsquo;t include <code>includeSubDomains</code> or <code>preload</code>, so the domain isn&rsquo;t on the browsers'
built-in preload list, which means the very first visit from a brand new browser still isn&rsquo;t
covered.</p>
<h2 id="caa-records">CAA Records</h2>
<p>Any of the hundreds of certificate authorities browsers trust can issue an HTTPS certificate for
any domain, as long as the requester passes that authority&rsquo;s check. <span class="accent accent-point">A CAA record limits it to the
ones I name</span>. GitHub Pages gets its certificates from <span class="accent accent-letsencrypt">Let&rsquo;s Encrypt</span>, so that&rsquo;s the only one:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">erikfailing.com  CAA  0 issue &#34;letsencrypt.org&#34;
</span></span><span class="line"><span class="cl">erikfailing.com  CAA  0 issuewild &#34;;&#34;
</span></span></code></pre></div><p>The second line blocks <span class="accent accent-crimson">wildcard certificates</span> entirely, since I don&rsquo;t need one. This stops someone
getting a valid certificate for my domain from a sloppier authority, which they could use to
impersonate the site to someone on their network. It&rsquo;s rare, but it&rsquo;s two DNS records. The one
thing to remember: if GitHub ever switches certificate authorities, renewals would fail until I
update this.</p>
<h2 id="what-i-didnt-bother-with">What I Didn&rsquo;t Bother With</h2>
<p>The scanner (<span class="accent accent-trophy">Mozilla HTTP Observatory</span>) took the site from a D (30) to a C (55), which is the
ceiling for meta tags on GitHub Pages, and then to a B (75) once GitHub turned on HSTS. Getting to
an A would mean putting <span class="accent accent-cloudflare">Cloudflare</span> in front of GitHub Pages or moving hosts, and I think that&rsquo;s
not worth it, because the remaining headers protect almost nothing on this site:</p>
<ul>
<li><strong>Clickjacking protection:</strong> there are no buttons that do anything sensitive to trick someone
into clicking.</li>
<li><strong><code>X-Content-Type-Options</code>:</strong> all content is mine, so there&rsquo;s nothing to sniff.</li>
<li><strong><code>Permissions-Policy</code>, COOP/CORP:</strong> no camera, mic, location, secrets, or third-party content
to protect.</li>
</ul>
<p>A few things were already in good shape too: <span class="accent accent-ward">DNSSEC</span> was on, GitHub&rsquo;s domain verification prevents
someone else claiming the domain on Pages, and there are no wildcard DNS records that could leave
a subdomain open to takeover.</p>
<p>So overall, I think the honest summary is that <span class="accent accent-point">the browser headers were the least important part
of this</span>. The real risks for a static site are the accounts and pipeline around it, and the email
records I didn&rsquo;t know were missing.</p>
]]></content:encoded></item></channel></rss>