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: 5 additions & 5 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '20'
cache: 'npm'
Expand All @@ -35,10 +35,10 @@ jobs:
run: npm run build

- name: Setup Pages
uses: actions/configure-pages@v4
uses: actions/configure-pages@1f0c5cde4bc74cd7e1254d0cb4de8d49e9068c7d # v4.0.0

- name: Upload artifact
uses: actions/upload-pages-artifact@v3
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
with:
path: ./out

Expand All @@ -51,4 +51,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
18 changes: 12 additions & 6 deletions .github/workflows/promote-sandbox-to-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@ jobs:
ahead-count: ${{ steps.diff.outputs.ahead-count }}
steps:
- name: Checkout sandbox
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: sandbox
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "20"
cache: "npm"
Expand All @@ -73,8 +73,10 @@ jobs:
echo "ahead-count=$AHEAD" >> "$GITHUB_OUTPUT"

- name: Record lint outcome
env:
LINT_OUTCOME: ${{ steps.lint.outcome }}
run: |
echo "Lint step outcome: ${{ steps.lint.outcome }}"
echo "Lint step outcome: $LINT_OUTCOME"

# Runs only when a previous required step (checkout/install/build) failed.
- name: Report build failure
Expand Down Expand Up @@ -103,12 +105,14 @@ jobs:
# Runs when the build succeeds, to auto-resolve a previously filed failure issue.
- name: Close stale build-failure issue if now fixed
if: success()
env:
VALIDATED_SHA: ${{ github.sha }}
run: |
EXISTING=$(gh issue list --state open --json number,title \
--jq ".[] | select(.title == \"$BUILD_FAILURE_ISSUE_TITLE\") | .number" | head -n1)

if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --body "Build passed again at \`${{ github.sha }}\`. Closing."
gh issue comment "$EXISTING" --body "Build passed again at \`$VALIDATED_SHA\`. Closing."
gh issue close "$EXISTING"
fi

Expand All @@ -121,7 +125,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0

Expand Down Expand Up @@ -251,11 +255,13 @@ jobs:
# Runs when PR create/update succeeds, to auto-resolve a previously filed failure issue.
- name: Close stale promotion-failure issue if now fixed
if: steps.promote_pr.outcome == 'success'
env:
VALIDATED_SHA: ${{ github.sha }}
run: |
EXISTING=$(gh issue list --state open --json number,title \
--jq ".[] | select(.title == \"$PR_FAILURE_ISSUE_TITLE\") | .number" | head -n1)

if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --body "Promotion PR created/updated successfully at \`${{ github.sha }}\`. Closing."
gh issue comment "$EXISTING" --body "Promotion PR created/updated successfully at \`$VALIDATED_SHA\`. Closing."
gh issue close "$EXISTING"
fi
32 changes: 32 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,45 @@ export const metadata: Metadata = {
},
}

// This site is statically exported to GitHub Pages, which does not support
// setting custom HTTP response headers (no server, no _headers file). A CSP
// delivered via <meta http-equiv> is the only mechanism available in this
// environment - it can't enforce frame-ancestors, X-Frame-Options,
// Strict-Transport-Security, or Permissions-Policy (those require a real
// HTTP header and a hosting layer that can send one), but it does restrict
// which origins scripts, styles, images, fonts, and connections can load
// from, which is the main defense-in-depth value here.
//
// script-src includes 'unsafe-inline' because Next.js's static export embeds
// its RSC hydration payload as inline <script> tags with content that
// differs on every build; per-script hashing/nonces are not available
// without a server-rendering runtime (not used here). All other directives
// are scoped to the exact external origins this app actually calls (see the
// Xanga sidebar applets under components/xanga/applets/).
const CONTENT_SECURITY_POLICY = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://itunes.apple.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https://images.credly.com",
"font-src 'self' data:",
"connect-src 'self' https://api.allorigins.win https://api.open-meteo.com",
"frame-src https://w.soundcloud.com",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; ')

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta httpEquiv="Content-Security-Policy" content={CONTENT_SECURITY_POLICY} />
<meta name="referrer" content="strict-origin-when-cross-origin" />
</head>
<body className={`${openSans.className} ${openSans.variable}`} suppressHydrationWarning>
<ThemeProvider>
<XangaLayoutWrapper>
Expand Down
24 changes: 22 additions & 2 deletions components/xanga/applets/NowListeningApplet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ type ItunesLookupEpisode = {

function jsonp<T>(url: string): Promise<T> {
return new Promise((resolve, reject) => {
const callbackName = `__jsonp_cb_${Math.random().toString(36).slice(2)}`
// crypto.randomUUID() is used instead of Math.random() purely to avoid a
// predictable/collidable global callback name on window - this value has
// no security purpose beyond namespacing.
const callbackName = `__jsonp_cb_${crypto.randomUUID().replace(/-/g, '_')}`
const sep = url.includes('?') ? '&' : '?'
const script = document.createElement('script')
const timeout = window.setTimeout(() => {
Expand Down Expand Up @@ -51,6 +54,23 @@ function jsonp<T>(url: string): Promise<T> {
})
}

// Episode links come from third-party RSS/iTunes responses (fetched via an
// external proxy for the RSS fallback), so they're untrusted input. Only
// allow http(s) URLs through to the DOM to prevent a compromised/malicious
// feed from injecting a javascript:/data: URL into an href.
function toSafeHttpUrl(url: string | undefined): string | undefined {
if (!url) return undefined
try {
const parsed = new URL(url)
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.toString()
}
} catch {
// Not a valid absolute URL.
}
return undefined
}

function formatDateMaybe(s: string | undefined): string | undefined {
if (!s) return undefined
const d = new Date(s)
Expand Down Expand Up @@ -190,7 +210,7 @@ export function NowListeningApplet() {
<div className="space-y-3">
{shows.map((show) => {
const title = show.latest?.title ?? 'Latest episode'
const link = show.latest?.link ?? show.siteUrl ?? '#'
const link = toSafeHttpUrl(show.latest?.link) ?? toSafeHttpUrl(show.siteUrl) ?? '#'
const date = formatDateMaybe(show.latest?.pubDate)

return (
Expand Down
45 changes: 40 additions & 5 deletions lib/blog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,44 @@ import path from 'path'
import matter from 'gray-matter'
import { remark } from 'remark'
import html from 'remark-html'
import sanitizeHtml from 'sanitize-html'

const postsDirectory = path.join(process.cwd(), 'content/blog')

// Blog HTML is generated from Markdown (local files plus Medium-synced content).
// remark-html does not sanitize its output, so raw HTML embedded in a Markdown
// file (e.g. <script>, event handlers, <iframe>) would otherwise be rendered
// as-is via dangerouslySetInnerHTML. Sanitize with an allowlist before it ever
// reaches the page.
function sanitizeBlogHtml(unsafeHtml: string): string {
return sanitizeHtml(unsafeHtml, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'h1', 'h2']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
a: ['href', 'name', 'target', 'rel'],
img: ['src', 'alt', 'title', 'width', 'height'],
},
allowedSchemes: ['http', 'https', 'mailto'],
transformTags: {
a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer' }, true),
},
})
}

// Guards against a slug like `../../secret` escaping content/blog when
// resolving a file path, even though slugs currently only ever come from
// build-time generateStaticParams() (derived from real filenames), not from
// live user/request input.
function resolvePostPath(slug: string): string | null {
const fullPath = path.join(postsDirectory, `${slug}.md`)
const resolved = path.resolve(fullPath)
const resolvedDir = path.resolve(postsDirectory)
if (resolved !== resolvedDir && !resolved.startsWith(resolvedDir + path.sep)) {
return null
}
return resolved
}

export interface BlogPost {
slug: string
title: string
Expand All @@ -31,7 +66,7 @@ export async function getBlogPosts(): Promise<BlogPost[]> {
const { data, content } = matter(fileContents)

const processedContent = await remark().use(html).process(content)
const contentHtml = processedContent.toString()
const contentHtml = sanitizeBlogHtml(processedContent.toString())

// Get excerpt (first paragraph or first 200 chars)
const excerpt = data.excerpt || content.split('\n\n')[0].substring(0, 200) + '...'
Expand All @@ -57,17 +92,17 @@ export async function getBlogPosts(): Promise<BlogPost[]> {
}

export async function getBlogPost(slug: string): Promise<BlogPost | null> {
const fullPath = path.join(postsDirectory, `${slug}.md`)
if (!fs.existsSync(fullPath)) {
const fullPath = resolvePostPath(slug)

if (!fullPath || !fs.existsSync(fullPath)) {
return null
}

const fileContents = fs.readFileSync(fullPath, 'utf8')
const { data, content } = matter(fileContents)

const processedContent = await remark().use(html).process(content)
const contentHtml = processedContent.toString()
const contentHtml = sanitizeBlogHtml(processedContent.toString())

const excerpt = data.excerpt || content.split('\n\n')[0].substring(0, 200) + '...'

Expand Down
Loading
Loading