Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,16 @@ AMA_FORM_PLACEHOLDER=What would you like to know?
AMA_SUBMIT_LABEL=Submit Question
AMA_THANKYOU_COPY=Question submitted! It will appear once answered.

# =============================================================================
# SECURITY HEADERS
# =============================================================================

# Set CSP_DISABLE=true when an edge proxy (Caddy, nginx, Cloudflare) emits its
# own Content-Security-Policy and you want to avoid double-headers. The other
# security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
# Strict-Transport-Security, Permissions-Policy) always ship.
CSP_DISABLE=false

# =============================================================================
# LOGGING CONFIGURATION
# =============================================================================
Expand Down
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [3.16.0] - 2026-05-18

Theme: **security headers + demo retire.** First user-driven scoping pass —
no anchoring issue. Closes two broken windows: the Pages demo had been
serving 96-day-stale wrong-account content, and the Security middleware
was shipping deprecated `X-XSS-Protection` while missing CSP / HSTS /
Permissions-Policy. A consolidated frontend audit doc catalogs deferred
items for v3.17.0+ cycles.

### Added

- **Content-Security-Policy** (enforcing) with a hardcoded SHA-256 hash
for the single inline FOUC-prevention script in `base.html`.
Directives: `default-src 'self'; base-uri 'self'; connect-src 'self';
font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src
'self' data: https:; object-src 'none'; script-src 'self' 'sha256-...';
style-src 'self'`. JSON-LD blocks are unaffected (non-JS MIME).
- **Strict-Transport-Security**: `max-age=31536000; includeSubDomains`.
The `preload` directive is intentionally omitted; opt-in deferred to a
later cycle after observed stability.
- **Permissions-Policy**: denies camera, microphone, geolocation, payment,
USB, magnetometer, gyroscope, accelerometer, and FLoC (`interest-cohort`).
- **`CSP_DISABLE` env var** (default `false`): set to `true` when an edge
proxy emits its own CSP and you want to avoid double-headers. Other
security headers always ship.
- **Regression tests** lock the FOUC script hash to `base.html` contents
and assert exactly one inline JavaScript across templates — a future
inline `<script>` addition without a hash entry fails CI before
reaching users' browsers.
- **`Live install` README section** points at https://log.1mb.dev with
honest-expectation framing ("may briefly lag during binary-pull cycles").
- **`docs/audit-2026-05-frontend.md`** indexes 11 deferred frontend
findings (innerHTML at compose-sheet, compose accretion, modulepreload,
Cmd-K, `<meta>` proliferation, etc.) — each filed as a GitHub issue with
the `audit-finding` label for v3.17.0+ scoping.

### Removed

- **`X-XSS-Protection` header.** Deprecated by Chrome (removed in v78),
never supported by Firefox, ignored by Safari. The `1; mode=block` value
is meaningless on modern browsers and can introduce vulnerabilities on
legacy ones. No migration impact.

### Operator actions (manual, post-merge)

- Disable the unused GitHub Pages deploy via repo settings or
`gh api repos/1mb-dev/markgo/pages --method DELETE` — the current
Pages serve is months-stale content from a pre-migration repo state.
- Update the repo `homepage` field to `https://log.1mb.dev` so the
sidebar link survives the Pages disable.
- File 11 audit-finding issues from `docs/audit-2026-05-frontend.md` and
backfill the `Issue` column with the resulting issue numbers.

## [3.15.1] - 2026-05-18

### Fixed
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ make build

Or download a release from [GitHub Releases](https://github.com/1mb-dev/markgo/releases).

## Live Install

[log.1mb.dev](https://log.1mb.dev) — running latest release (may briefly lag during binary-pull cycles).

## What You Get

**Write from anywhere** — CLI for drafting in your editor, web compose form for publishing from your phone. Quick capture: tap the FAB, type a thought, hit Publish. Under 5 seconds.
Expand Down
32 changes: 32 additions & 0 deletions docs/audit-2026-05-frontend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Frontend Audit — 2026-05 (v3.16.0 scoping pass)

Index of frontend-surface findings deferred from the v3.16.0 audit cycle. Each item is filed as a separate GitHub issue with the `audit-finding` label; this doc is the consolidated entry point. Items get closed in subsequent cycles when their issues land.

The execution scope for v3.16.0 itself was: security headers (CSP/HSTS/Permissions-Policy + X-XSS-Protection drop), Pages-demo retire (README → log.1mb.dev + operator actions A1/A2), and this audit doc.

## Findings

| # | Finding | Severity | Location | Suggested direction | Issue |
|---|---------|----------|----------|---------------------|-------|
| 1 | `innerHTML =` on draft card rendering | Medium | `web/static/js/modules/compose-sheet.js:376,389` | Audit each call site for source trust; if any user-rendered content reaches the assignment, switch to `textContent` + DOM builder helper. Even when source is trusted, prefer explicit construction for grep-ability. | TBD |
| 2 | Vestigial CSS file | Low | `web/static/css/articles.css` (5 lines) | Verify zero callers (`grep articles.css web/`), delete if confirmed dead. | TBD |
| 3 | Compose flow accretion | Medium | `compose.js` (485) + `compose-sheet.js` (556) = 1,041 lines | State-machine cleanup: modes (page/article/AMA/edit/quick) branch on multiple `data-*` attrs (mode, type, link-url, banner, banner-path-form). Consider a single resolved-state read at sheet open, then mode-specific branches operate on that snapshot. Likely v3.17+ dedicated focus area. | TBD |
| 4 | `<link rel="modulepreload">` opportunities | Low | `web/templates/base.html` head | Per-template dynamic-import modules (compose, search-page, contact, admin, drafts) could preload conditionally based on current template. Shaves INP on first interaction. Measure before optimizing. | TBD |
| 5 | Reading-progress affordance for long-form articles | Low | `web/static/css/article.css`, `web/static/js/app.js` | Calm-design fork: scroll-progress bar at top of article view. Common blog UX, defensible per "minimal chrome" guideline. Considered noise by some readers; gate behind a (potentially) operator-toggleable behavior. | TBD |
| 6 | Dark-mode AA contrast spot-check across color presets | Low | `web/static/css/main.css :root` | v3.10.1 lifted contrast for berry/ocean/forest/sunset but no comprehensive audit since. Run an automated contrast-check (axe-core, pa11y) across all 5 presets in both light + dark modes. Document deltas, fix outliers. | TBD |
| 7 | Service Worker `CACHE_VERSION` is manually bumped | Low | `web/static/sw.js:11` (`const CACHE_VERSION = 7`) | Inject from build version via ldflags-style substitution at startup (sw.js is served by `serveSwJs` handler — opportunity to template-substitute). Risk of forgotten bumps is real (any client-side cache change must invalidate, and humans forget). | TBD |
| 8 | Cmd-K command palette | Low | new | Modern blog UX expectation (Astro, Hugo themes). Search-popover already exists at `/static/js/modules/search-popover.js` — a Cmd-K palette would extend with navigation actions (go to /writing, /tags, etc.) + recent articles. v3.17+ candidate. | TBD |
| 9 | `<meta>` proliferation audit | Low | `web/templates/base.html` (62 meta tags) | Walk every meta tag, verify each has a present-day consumer (browser, crawler, JS). msapplication-* tags target legacy IE/Edge; some may be droppable. apple-touch-icon variations — keep all (no harm) or trim to modern sizes (60/76/120/152). Apple favicons reference `vnykmshr.github.io/markgo/static/img/...` — likely stale post-1mb-dev-migration; verify or replace with `{{ .config.BaseURL }}` resolution. | TBD |
| 10 | PWA install prompt UX | Low | `web/static/js/app.js` (no install handler) | No visible "install" affordance. Could add a one-time toast on `beforeinstallprompt`, or leave it browser-native. Defensible either way; surface to operators in docs at minimum. | TBD |
| 11 | `highlight.min.js` bundle trim | Low | `web/static/js/highlight.min.js` (1,212 lines, vendored full distribution) | Bundle ships every language. Most blogs use ~5. Trim to subset via highlight.js's custom-build tool, OR migrate to CSS-only `<pre><code>` styling (lose syntax color, gain ~50KB). v3.17+ candidate. | TBD |

## Filing protocol

Each item above gets a GitHub issue with:

- Title: `audit-finding: <short subject>`
- Label: `audit-finding`
- Body: the row content from this doc + any additional context discovered while filing.
- After filing, the `Issue` column above is updated with `#N` linking back.

Items are not blockers for v3.16.0 merge — they are scoping inputs for v3.17.0+ planning cycles.
23 changes: 23 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,29 @@ Operator-controllable copy on the AMA (Ask Me Anything) submission overlay. All
| `AMA_SUBMIT_LABEL` | `Submit Question` | Label on the submit button on both the AMA sheet and the `/about` reach section (v3.14.0+). |
| `AMA_THANKYOU_COPY` | `Question submitted! It will appear once answered.` | Toast shown after a successful submission. |

## Security Headers (v3.16.0+)

markgo emits a fixed set of security headers on every response:

| Header | Value | Notes |
|--------|-------|-------|
| `X-Content-Type-Options` | `nosniff` | Disables MIME sniffing. |
| `X-Frame-Options` | `DENY` | Disallows embedding in frames. |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Sends origin-only on cross-origin requests. |
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | One-year HSTS. The `preload` directive is intentionally omitted; preload-list registration is irreversible and only safe after observed stability. |
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=(), interest-cohort=()` | Denies powerful features the app does not use, including FLoC opt-out. |
| `Content-Security-Policy` | See below | Enforced by default; disable via `CSP_DISABLE=true` when an edge proxy emits its own. |

**CSP value:** `default-src 'self'; base-uri 'self'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data: https:; object-src 'none'; script-src 'self' 'sha256-...'; style-src 'self'`. The `script-src` hash covers the single inline FOUC-prevention script in `base.html`; any new inline executable script needs a hash entry (regression test enforces this).

`img-src` includes `https:` so article banner fields accept absolute URLs to externally-hosted images. JSON-LD blocks (`<script type="application/ld+json">`) are unaffected — browsers treat non-JS MIME types as data.

| Variable | Default | Description |
|----------|---------|-------------|
| `CSP_DISABLE` | `false` | Set to `true` to skip the `Content-Security-Policy` header (other security headers always ship). Useful when an edge proxy (Caddy, nginx, Cloudflare) emits its own policy. |

X-XSS-Protection is intentionally **not** emitted — it is deprecated, Chrome removed support in v78, and the `1; mode=block` value can introduce vulnerabilities on legacy browsers.

## Logging

| Variable | Default | Description |
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/serve/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ func setupServer(cfg *config.Config, logger *slog.Logger) (*gin.Engine, *service
middleware.Performance(logger),
middleware.SmartCacheHeaders(),
middleware.CORS(cfg.CORS.AllowedOrigins, cfg.Environment == envDevelopment),
middleware.Security(),
middleware.Security(cfg),
middleware.RateLimit(cfg.RateLimit.General.Requests, cfg.RateLimit.General.Window),
middleware.ErrorHandler(logger),
middleware.DiscardBodyOnHEAD(),
Expand Down
13 changes: 13 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ type Config struct {
SEO SEOConfig `json:"seo"`
Upload UploadConfig `json:"upload"`
AMA AMAConfig `json:"ama"`
Security SecurityConfig `json:"security"`
}

// SecurityConfig holds security-header configuration. Most security headers
// always ship (X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
// HSTS, Permissions-Policy); only Content-Security-Policy is operator-toggleable
// because edge proxies (Caddy, nginx) often emit their own policy.
type SecurityConfig struct {
CSPDisable bool `json:"csp_disable"` // CSP_DISABLE=true skips the Content-Security-Policy header (use when edge proxy emits its own)
}

// AMAConfig holds operator-controlled copy for the AMA (Ask Me Anything) submission
Expand Down Expand Up @@ -340,6 +349,10 @@ func Load() (*Config, error) {
SubmitLabel: getEnv("AMA_SUBMIT_LABEL", "Submit Question"),
ThankyouCopy: getEnv("AMA_THANKYOU_COPY", "Question submitted! It will appear once answered."),
},

Security: SecurityConfig{
CSPDisable: getEnvBool("CSP_DISABLE", false),
},
}

// Validate the configuration
Expand Down
54 changes: 51 additions & 3 deletions internal/middleware/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,64 @@ import (

"github.com/gin-gonic/gin"

"github.com/1mb-dev/markgo/internal/config"
apperrors "github.com/1mb-dev/markgo/internal/errors"
)

// Security adds basic security headers
func Security() gin.HandlerFunc {
// foucScriptHash is the SHA-256 (base64) of the inline FOUC-prevention script in
// web/templates/base.html. Required by the CSP script-src directive because the
// script reads localStorage before stylesheets load — it cannot move to an
// external file without reintroducing the flash. The hash is locked by
// TestSecurity_FOUCScriptHashMatches; editing the inline script without
// updating this constant fails the test before reaching users' browsers.
const foucScriptHash = "sha256-0pz7XU3iscvI1rWHhJ8OyLJ4xXNoivNIt1N5xpF6GUg="

// cspPolicy is the Content-Security-Policy emitted by Security(). Keep
// directives alphabetically ordered to make diffs reviewable.
//
// connect-src 'self' covers same-origin fetches: search, compose, AMA submit,
// offline-queue replay on reconnect, contact form, login. Adding analytics or
// a third-party error reporter requires extending this directive.
//
// img-src includes https: because article banner fields accept absolute URLs
// to externally-hosted images (operator's choice, see compose banner-path
// forms). data: covers favicons and inline preview thumbnails.
var cspPolicy = strings.Join([]string{
"default-src 'self'",
"base-uri 'self'",
"connect-src 'self'",
"font-src 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"img-src 'self' data: https:",
"object-src 'none'",
"script-src 'self' '" + foucScriptHash + "'",
"style-src 'self'",
}, "; ")

// permissionsPolicy denies access to powerful features the app does not use,
// including FLoC opt-out (interest-cohort).
const permissionsPolicy = "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=(), interest-cohort=()"

// hstsValue ships without preload — preload-list registration is irreversible
// without months of pain. Opt-in via v3.17+ after one cycle of observed stability.
const hstsValue = "max-age=31536000; includeSubDomains"

// Security adds security headers: X-Content-Type-Options, X-Frame-Options,
// Referrer-Policy, Strict-Transport-Security, Content-Security-Policy,
// Permissions-Policy. CSP can be disabled via the CSP_DISABLE env var for
// operators whose edge proxy emits its own policy.
func Security(cfg *config.Config) gin.HandlerFunc {
cspEnabled := cfg == nil || !cfg.Security.CSPDisable
return func(c *gin.Context) {
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("X-XSS-Protection", "1; mode=block")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Strict-Transport-Security", hstsValue)
c.Header("Permissions-Policy", permissionsPolicy)
if cspEnabled {
c.Header("Content-Security-Policy", cspPolicy)
}
c.Next()
}
}
Expand Down
Loading