Upload serving and legacy cleanup
This reference covers upload validation, response headers, and cleanup for both local storage and OSS/CDN deployments.
Anonymous question attachments use the shared internal/imagefile policy: detect PNG/JPEG/GIF/WebP from the actual bytes, choose the canonical extension without using the supplied filename or MIME header, and reject empty files or files larger than 5 MiB. A post accepts at most six attachments and a 30 MiB total request body (including multipart overhead); all attachments are validated before storage writes begin. Malformed multipart requests are rejected and temporary files are cleaned up. The same image size and raster-type rules are used for downloaded avatars and site assets; only authenticated site-asset uploads retain their explicit SVG exception. This is content sniffing, not full image decoding or sanitization. Anonymous posting remains available; per-IP request rate limiting and storage quotas are separate deployment concerns.
/upload-img/* is untrusted content, including old files predating upload validation and admin-uploaded SVG. Both Caddy configurations and direct Next static serving apply the same response policy: Content-Security-Policy: sandbox; default-src 'none'; img-src data:; style-src 'unsafe-inline' and X-Content-Type-Options: nosniff. Only .png, .jpg, .jpeg, .gif, .webp, and .svg (case-insensitive) are served inline with image MIME types. Other suffixes, including .html, are served as application/octet-stream with Content-Disposition: attachment. SVG remains usable as an <img> (including site assets) but a directly navigated SVG cannot run scripts or inherit the site origin. This is a serving boundary, not validation of file contents: a mislabeled .png response may fail image decoding, but still has the sandbox and nosniff headers. Keep the Caddy and Next allowlists aligned when changing image formats. The CSP intentionally does not grant allow-scripts or allow-same-origin; do not add either to this upload path.
This local policy does not protect images delivered directly from Aliyun OSS or another object store/CDN. For every public /upload-img/* response at the final public hostname (including cached objects, 304, redirects where applicable, and custom error responses), configure and verify equivalent CSP, nosniff, MIME allowlist and download disposition at the OSS/CDN or an upload-domain proxy. Prefer a separate cookieless hostname for untrusted uploads as an additional origin boundary; it does not replace response headers. Confirm the edge neither strips nor overwrites the headers and invalidate cached old headers after rollout. Do not rely on Caddy's headers if a client can access the OSS URL directly. In particular, an object with old text/html or image/svg+xml metadata should never be allowed to render as an unsandboxed active document.
After deploying serving headers and backend upload validation, audit the existing local mounted upload directory (production /work/frontend/public/upload-img/, development /srv/uploads) and the OSS upload-img/ prefix. Inventory extensions, actual file signatures, HTML/SVG and mismatches, then find database and configuration references before choosing what to remove. Back up the objects, quarantine suspicious or unsupported files outside publicly served directories/buckets, update or remove their references, and invalidate CDN caches; verify representative old URLs at every public entry point. Do not mass-delete on extension alone: trusted site assets may use SVG, and unknown legacy references need review. This repository does not automatically delete old objects.
Local regression check (requires caddy, node, Python 3, and frontend dependencies): cd frontend && npm ci && npm run build, then from the repository root run python3 scripts/test-upload-serving.py. It exercises both Caddy configurations and direct Next via GET/HEAD of temporary files, including legacy HTML/SVG, unsupported suffixes and image formats. This check does not exercise a production OSS/CDN: test its public URLs and response headers independently before considering that route protected.