How csoh.org Is Secured

A security community should be auditable. This page lays out - with specifics you can verify yourself - exactly how this site is secured: a strict Content-Security-Policy, Subresource Integrity on every asset, HSTS and Cross-Origin isolation, keyless deploys with no stored cloud secrets, and a Trivy-scanned pipeline. Every claim links to the control that enforces it.

· · Security engineering · View source on GitHub

Why this page exists. csoh.org is a community for cloud security practitioners, so the site itself ought to hold up to the same scrutiny we apply to everyone else. This page documents how it's secured and, more usefully, tells you how to check each claim without taking our word for it. If you find something wrong, the disclosure section at the bottom tells you how to report it.

The short version. csoh.org is a static site - no server-side code, no database, no user accounts, no third-party JavaScript that can execute. That erases whole classes of vulnerability by construction. On top of that we run a deliberately strict set of browser and transport controls, sign every asset, and deploy through a pipeline that holds no long-lived cloud credentials.

On this page

  1. The biggest control: it's a static site
  2. Content-Security-Policy and no inline scripts
  3. The rest of the HTTP security headers
  4. Subresource Integrity on every asset
  5. Analytics without tracking
  6. Supply chain: dependencies and the build
  7. Keyless deploys: no stored cloud secrets
  8. The CI security gates
  9. Reporting a vulnerability
  10. Verify all of this yourself

The biggest control: it's a static site

The single most important security decision here was made before any header was set: csoh.org is static HTML. There is no application server, no database, no login, no session, no user-supplied input that reaches a backend. A visitor's browser downloads files and renders them; nothing they send is executed anywhere.

That one property removes most of the OWASP Top 10 by construction - there is no SQL to inject, no server-side deserialization, no authentication to bypass, no session to hijack, no SSRF surface. What's left to defend is narrower and well understood: the integrity of the files we ship, the browser's handling of them, and the pipeline that publishes them. The rest of this page is about those three things.

Content-Security-Policy and no inline scripts

The attack this stops: cross-site scripting (XSS) - getting the browser to run JavaScript it shouldn't, whether injected into the page or loaded from a hostile third party.

Every response carries a strict Content-Security-Policy. Scripts, styles, and fonts may load only from our own origin; there is no 'unsafe-inline' and no 'unsafe-eval'. Objects and plugins are banned outright, framing of this site is denied, and the base URI and form targets are pinned to ourselves. Here is the policy in full, exactly as served, rather than the tidy excerpt it is tempting to publish:

default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' https://csoh.org https://img.youtube.com
  https://i.ytimg.com https://csoh.goatcounter.com/user/new data:;
font-src 'self';
connect-src 'self' https://csoh.goatcounter.com/user/new;
frame-src https://www.youtube.com https://web.archive.org
  https://accounts.google.com/v3/signin/identifier?continue=https://docs.google.com/&emr=1&followup=https://docs.google.com/&osid=1&passive=1209600&flowName=GlifWebSignIn&flowEntry=ServiceLogin&dsh=S-552302446:1786299366457131 https://accounts.google.com/v3/signin/identifier?continue=https%3A%2F%2Fdrive.google.com%2F&dsh=S-988765713%3A1786299366457817&emr=1&followup=https%3A%2F%2Fdrive.google.com%2F&osid=1&passive=1209600&service=wise&flowName=GlifWebSignIn&flowEntry=ServiceLogin&ifkv=Ac50bxtsMsCeI_SN3dU2ohCuDwleJ-zBndx1YRUQD2iOE62cVLaX8aCHoD-xJgLLLDVWU-DHkIg4uw;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none'

The three directives that allow anything beyond our own origin are the honest part of this section, and an abridged quote that dropped them would have been a nicer-looking policy than the one your browser is enforcing. (The rest are 'self', except frame-ancestors and object-src, which are 'none' and so are stricter than 'self', not looser.) img-src allows YouTube's two thumbnail hosts and the analytics host, plus data: for inlined icons. connect-src allows the analytics endpoint, which is the only host this site ever talks to after load. frame-src allows four embed sources: YouTube for session recordings, the Wayback Machine for archived snapshots of dead links, and Google Docs and Drive for the slide decks on the presentations page. Note that frame-src and frame-ancestors point in opposite directions: frame-src is what this site may embed, frame-ancestors 'none' is who may embed this site, and the answer to the second is still nobody. Check the whole thing yourself with curl -sI https://csoh.org/ | grep -i content-security-policy; if it does not match the block above, this page is the thing that is out of date.

Because script-src is 'self' with no inline allowance, an injected <script> simply does not run - the browser refuses it. To keep that guarantee honest, a CI gate (below) rejects any inline <script> block in the source, so we can never accidentally come to rely on one. And every script the site does load is same-origin and pinned with a Subresource Integrity hash - our own main.js and its per-page companions, plus two third-party libraries we vendor into /vendor/ and serve ourselves rather than pulling from a CDN. There is no <script src> anywhere in the site's HTML that points at another host, and none without an integrity attribute. That is the stronger claim, and the checkable one: grep the repository for <script src and every hit should satisfy both halves.

The rest of the HTTP security headers

Alongside the CSP, every response sets a full complement of hardening headers. Each one closes a specific hole:

Where the headers come from matters as much as what they say. The site is served active/active from three origins behind one edge. The Cloudflare edge sets this whole set on every response, whichever origin answered, and two of the three origins also set them for themselves: the GCP origin's nginx image carries them in nginx-security-headers.conf, and the AWS origin attaches a CloudFront response headers policy to its default cache behavior. So a request that reaches either of those distributions directly, bypassing the edge, still gets the full CSP, HSTS, and X-Frame-Options rather than an unprotected copy of the site. Azure Blob static websites cannot emit custom response headers at all, so that origin genuinely does depend on the edge - a gap we would rather document than pretend away.

The cost of that redundancy is three places that have to agree (the Cloudflare ruleset, the CloudFront response headers policy, and the nginx config), which is exactly the sort of thing that drifts quietly. It is worth being precise about how much of that a machine catches. The CI gate (below) parses the header values out of the Cloudflare Terraform and asserts them against what the live site actually returns, so the edge cannot silently fall out of step with the repository. It does not read the CloudFront policy or the nginx config: those two are kept in step by hand, and that is the honest weak point in this arrangement.

The gate has one wrinkle worth repeating, because it is the kind of thing that makes a check look green while testing nothing. The apex is a load balancer over three origins, and two of them now set these headers themselves, so only an Azure-served response actually exercises the Cloudflare ruleset. Sampling the site a handful of times can miss Azure entirely - at 25 requests we watched two consecutive runs land on it zero times. The checker now makes 40 cache-busted requests, reports which origins answered, and warns when it never reached Azure, because a run that never reached Azure did not test the thing it claims to test.

These are the same headers a scanner grades - see the verify-it-yourself section to run one.

Subresource Integrity on every asset

The attack this stops: a tampered or swapped asset - if our stylesheet or script were ever modified in transit or at an origin, the browser should refuse it rather than run it.

Every shared asset (style.css, main.js, and both vendored third-party scripts) is referenced with a Subresource Integrity hash - a SHA-384 fingerprint in the integrity attribute. If the delivered bytes don't match the fingerprint the page declares, the browser will not use the file. A tooling script recomputes those hashes whenever an asset changes and stamps them across every page, and a mismatch fails loudly rather than silently.

Analytics without tracking

Two third-party scripts run on the site, and neither is loaded from a third party: the GoatCounter analytics counter, on every page, and MiniSearch, on the search page only. Both are vendored into /vendor/, served from our own origin, and pinned by SRI hash. The distinction matters more than it sounds: a library pulled from a vendor's CDN can change under you between one page load and the next, with no commit and no deploy on our side, whereas a vendored copy can only change in a reviewed commit that also changes the hash on every page referencing it.

The counting still leaves our origin, and it would be dishonest to imply otherwise. The counter posts each page view to csoh.goatcounter.com, which is the only host besides ourselves that the CSP's connect-src allows. GoatCounter is cookieless and assigns no persistent identifier; what a page view carries is the path, the page title, the referrer, the screen width, and - derived at the far end from the request's own User-Agent - the browser and operating system. Enough to count traffic, not enough to build a profile. Before the counter can even load, our own main.js checks for a Do Not Track or Global Privacy Control signal and opts the visitor out entirely if one is set. No ad networks, no tag managers, no session recorders - none of the usual third-party JavaScript that turns a "simple" site into a supply-chain liability.

That counter is also a local fork, not the stock upstream file. Upstream sends the visitor's full query string along with each page view, both as its own field and appended to the recorded path. On this site /search.html?q=... is a real, deep-linkable URL, so that would mean a shared or bookmarked search link handing over whatever someone typed into the site search. Our copy sends an empty query field and records the path only, so search terms never reach the analytics host. Both edits are labeled CSOH LOCAL MODIFICATION in vendor/goatcounter-count.js, and vendor/README.md warns that re-vendoring the upstream file would silently undo them. Our privacy policy spells out exactly what is recorded, and the point of stripping the query string is that the policy stays true.

Supply chain: dependencies and the build

The attack this stops: a compromised dependency or build step slipping malicious code into what we publish.

The runtime dependency surface is close to zero - the site ships hand-written HTML, one stylesheet, and one script, with no client-side framework and no npm build. The automation that maintains the site (link checking, news updates, SEO audits) runs as GitHub Actions, and every third-party action is pinned to a full commit SHA, not a moving tag, so a hijacked tag can't swap in new code. That is no longer a convention we ask reviewers to police: the repository has GitHub's sha_pinning_required policy turned on, so a workflow that references an action by tag or branch is rejected by GitHub rather than by whoever happened to read the diff. Stated plainly, because it affects how you'd verify it: that setting is not anonymously readable (its API endpoint answers 401 without an admin token), so the checkable half is the other direction - every uses: line in the public workflow files carries a 40-character SHA with the release it corresponds to in a trailing comment, and a bare tag would not be permitted to run. The one place we build a container image (for the GCP origin) runs a Trivy vulnerability scan as a gate - the deploy to that cloud does not proceed if the scan finds an unacceptable issue.

Some of that automation reads the open web, and is scoped accordingly. A scheduled job drafts candidate links for the resources page, which means it consumes pages nobody here controls. Untrusted input plus a broad set of capabilities is how a content job turns into a code-execution job, so the run is given an explicit allowlist of tools. It is exactly this, and you can read it in the workflow file: Read,Edit,Glob,Grep,WebSearch,WebFetch. Every entry is an in-process tool. There is no Bash(...) entry of any kind.

Getting to that list took two rounds of removal, and the second is the more instructive one. The obvious hazard was Bash(python3:*), which reads like one narrow permission and matches python3 -c '<anything>', so it silently re-grants every capability the rest of the list was written to exclude. The less obvious hazard was what remained afterwards: Bash(grep:*) and Bash(wc:*), which this page previously described as narrowly bounded. They are not. grep takes a path like any other command, so Bash(grep:*) is a read primitive over the entire runner filesystem, including /proc/self/environ - the step's own environment, secrets and all. Both were removed. The built-in Grep tool that stayed searches the checked-out workspace and is not a shell, and it serves the prompt's "check what you added" step just as well. The general rule is narrower than "no interpreters": an allowlist containing a shell command that accepts a path is not an allowlist, it is a file-read capability with a misleading name. If a future version of that job genuinely needs Python, it gets a checked-in script allowlisted by exact path.

Step ordering does the rest of the work. The credential that could actually change this repository - the csoh-ci GitHub App installation token that opens the pull request - is minted in a step that runs after the model step has finished, not before it. So while the model is reading pages nobody here controls, that token does not exist anywhere on the runner yet. The credential present during the research step is the Claude OAuth token, which buys model usage and grants nothing in this repository or in any cloud account, and which is rotatable. We would rather write that down than imply the research step is running with nothing worth stealing.

The same job checks out the repository with persist-credentials: false, so no token is left sitting in .git/config where a later step could read it back out. And it only ever proposes: its output is a pull request against the repo, not a push to the live site.

Keyless deploys: no stored cloud secrets

The attack this stops: theft of long-lived cloud credentials from the repo or CI - the root cause of a long line of real breaches.

csoh.org is served active/active from AWS, GCP, and Azure, and the pipeline deploys to all three without storing a single cloud password. Each deploy uses OIDC: GitHub mints a short-lived, signed token describing the run that asked for it, and each cloud is configured to trust that token for a narrowly-scoped role. There are no access keys, service-account JSON files, or client secrets in the repository or in CI, so a leaked repo yields no cloud access.

What each cloud trusts is deliberately narrow, and identical across all three. Every one of them pins the token's sub claim to a single exact string: this repository, running in the production GitHub environment. AWS enforces it with a StringEquals condition on the OIDC role's trust policy, Azure with the federated credential's subject, and GCP with both a workload-identity attribute condition and an IAM member naming that one subject.

Trusting only the repository would not be enough, and the difference is the interesting part. A repo-wide trust means every workflow in the repo, on any branch, can obtain deploy credentials - including scheduled jobs that read untrusted web pages and have no business anywhere near a cloud account. Pinning the environment inverts that: a workflow that does not enter production gets nothing, no matter what it asks for. Branch enforcement then comes along for free, because the production environment is itself restricted to main. The full architecture is its own write-up: How We Deploy Across AWS, GCP & Azure.

The CI security gates

Several checks run on every change, so the guarantees above can't quietly erode. Five of them are required status checks on main and hard-block a merge: ruff, actionlint, yamllint, validate-html, and check-urls. The rest run on the pull request and report there, and the two live checks at the end run after publish and fail the deploy itself:

Making the last two required took a change to the workflows first, and the reason is a trap worth passing on. Both validate-html and check-urls originally carried a paths: filter on their pull-request trigger, so they only ran when HTML or link-bearing files changed. A workflow that paths: filters out does not start, and a workflow that does not start produces no check run at all - not a skipped one, not a neutral one, nothing. GitHub waits forever for a required check that never reports, so a pull request touching only Python or Terraform would have been unmergeable with no visible failure to fix. The fix is to drop the paths: filter from the pull_request trigger and move the "is there anything to do here?" decision inside the job, so it always starts, always reports, and finishes in seconds when there is nothing to check. Both workflow files say so in a comment at the trigger, because the tempting cleanup is to put the filter back.

You do not have to take the list on faith. The ruleset is public and readable without a token: curl https://api.github.com/repos/CloudSecurityOfficeHours/csoh.org/rulesets/12819332 lists all five contexts under required_status_checks. (What the anonymous view omits is bypass_actors, which is written out in full on the deployment page instead.)

All of these are read-only: they inspect the source and the published site, and none of them can modify the site or post on our behalf. The workflows are in the public repo if you want to read them.

Reporting a vulnerability

We publish a machine-readable security.txt (per RFC 9116) and a human-readable Security Policy. If you find a vulnerability on csoh.org, email admin@csoh.org or raise it at a Friday session. We take reports seriously - it would be a strange thing for a cloud-security community not to.

Verify all of this yourself

None of the above should be taken on faith. Here's how to check it in a few minutes:

If any of it doesn't match what this page claims, that's a bug - please tell us.

Explore how the site is built

This page is one of a set written in the open. See How We Deploy Across AWS, GCP & Azure for the multi-cloud architecture, How We Use GitHub Actions for the automation, and How CSOH Is Funded for the money side.