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
3 changes: 2 additions & 1 deletion website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@tailwindcss/typography": "^0.5.20",
"@types/express": "^5.0.1",
"@types/node": "^20.17.19",
"dompurify": "^3.4.13",
"github-slugger": "^2.0.0",
"gray-matter": "^4.0.3",
"jsdom": "^28.0.0",
Expand All @@ -48,4 +49,4 @@
"typescript": "~6.0.2",
"vitest": "^4.0.8"
}
}
}
16 changes: 16 additions & 0 deletions website/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 10 additions & 12 deletions website/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import express from 'express';
import { join } from 'node:path';
import { githubApi } from './server/github-api';
import { authRoutes } from './server/auth';
import { SECURITY_HEADERS } from './server/security-headers';

const browserDistFolder = join(import.meta.dirname, '../browser');

Expand All @@ -18,16 +19,15 @@ const angularApp = new AngularNodeAppEngine({
});

/**
* Example Express Rest API endpoints can be defined here.
* Uncomment and define endpoints as necessary.
*
* Example:
* ```ts
* app.get('/api/{*splat}', (req, res) => {
* // Handle API request
* });
* ```
* Security headers on every SSR response. Static/prerendered files served by
* the Vercel CDN get the same set from the `headers` block in vercel.json.
*/
app.use((req, res, next) => {
for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
res.setHeader(name, value);
}
next();
});

/**
* JSON API backed by the GitHub REST API (cached server-side).
Expand Down Expand Up @@ -56,9 +56,7 @@ app.use(
app.use((req, res, next) => {
angularApp
.handle(req)
.then((response) =>
response ? writeResponseToNodeResponse(response, res) : next(),
)
.then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
.catch(next);
});

Expand Down
14 changes: 13 additions & 1 deletion website/src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,21 @@ export function readAuthCookie(req: Request): string | null {
* forwarded headers are client-supplied and must not decide the OAuth `redirect_uri`
* nor whether cookies get the `Secure` attribute.
*/
let warnedMissingOrigin = false;

function siteOrigin(req: Request): string {
const configured = process.env['SITE_ORIGIN'];
if (configured) {
return configured.replace(/\/+$/, '');
}
if (!warnedMissingOrigin && process.env['NODE_ENV'] === 'production') {
warnedMissingOrigin = true;
console.warn(
'SITE_ORIGIN is not set: falling back to client-supplied x-forwarded-* headers ' +
'to build the OAuth redirect_uri and decide the Secure cookie flag. ' +
'Set SITE_ORIGIN in production.',
);
}
const forwardedHost = String(req.headers['x-forwarded-host'] ?? '')
.split(',')[0]
.trim();
Expand All @@ -57,7 +67,9 @@ function safeEqual(a: string, b: string): boolean {

/** Only same-site paths are allowed as post-login redirect targets. */
function safePath(value: unknown): string {
const path = String(value ?? '/');
// Browsers treat `\` as `/` in the Location header, so `/\evil.com` would
// become a protocol-relative redirect to evil.com. Normalize before checking.
const path = String(value ?? '/').replace(/\\/g, '/');
return path.startsWith('/') && !path.startsWith('//') ? path : '/';
}

Expand Down
37 changes: 37 additions & 0 deletions website/src/server/security-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Security headers applied to every SSR response. Keep in sync with the
* `headers` block in vercel.json, which applies the same set to the static
* and prerendered pages served straight from the Vercel CDN.
*
* The CSP allow-list covers the third parties the site actually loads:
* Google Tag Manager / Analytics and AdSense (injected after consent, see
* src/app/consent.ts), the giscus comments iframe, GitHub avatars and the
* GitHub-hosted demo videos embedded in challenge docs. `unsafe-inline` for
* scripts is required by the two bootstrap scripts in index.html and
* Angular's hydration event-replay script; for styles by Angular's inlined
* component styles and shiki's inline color attributes.
*/
export const CONTENT_SECURITY_POLICY = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://pagead2.googlesyndication.com https://googleads.g.doubleclick.net https://tpc.googlesyndication.com https://ep2.adtrafficquality.google https://giscus.app",
"style-src 'self' 'unsafe-inline'",
// https: because AdSense creatives load images from arbitrary Google CDNs.
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com https://pagead2.googlesyndication.com https://ep1.adtrafficquality.google",
'frame-src https://giscus.app https://googleads.g.doubleclick.net https://tpc.googlesyndication.com https://www.google.com https://ep2.adtrafficquality.google',
"media-src 'self' https://github.com https://user-images.githubusercontent.com https://private-user-images.githubusercontent.com",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self' https://github.com",
"frame-ancestors 'self'",
].join('; ');

export const SECURITY_HEADERS: Record<string, string> = {
'Content-Security-Policy': CONTENT_SECURITY_POLICY,
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'SAMEORIGIN',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Strict-Transport-Security': 'max-age=31536000',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
};
49 changes: 40 additions & 9 deletions website/tools/generate-content.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import matter from 'gray-matter';
import { Marked } from 'marked';
import { createHighlighter } from 'shiki';
import GithubSlugger from 'github-slugger';
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';

const CONTENT_DIR = new URL('../src/content', import.meta.url).pathname;
const OUT_DIR = new URL('../src/app/generated', import.meta.url).pathname;
Expand Down Expand Up @@ -74,8 +76,19 @@ function resolveAuthor(slug) {
}

const SHIKI_LANGS = [
'typescript', 'javascript', 'html', 'css', 'json', 'bash', 'shell',
'yaml', 'diff', 'angular-html', 'angular-ts', 'jsx', 'tsx',
'typescript',
'javascript',
'html',
'css',
'json',
'bash',
'shell',
'yaml',
'diff',
'angular-html',
'angular-ts',
'jsx',
'tsx',
];

/** Light colors inline, dark colors in `--shiki-dark*` vars (see styles.css). */
Expand All @@ -89,6 +102,20 @@ const highlighter = await createHighlighter({
langs: SHIKI_LANGS,
});

/**
* The rendered HTML is injected with `bypassSecurityTrustHtml` (doc-page.ts), so
* Angular's sanitizer never sees it. marked passes raw HTML through untouched,
* which would let a `<script>` or `onerror=` in a contributed markdown file run
* on every visitor's browser. Sanitize here instead: DOMPurify strips scripts,
* event handlers and javascript: URLs while keeping the tags the docs rely on
* (details/summary, video, svg, shiki's inline styles, heading ids, classes).
*/
const DOMPurify = createDOMPurify(new JSDOM('').window);

function sanitizeHtml(html) {
return DOMPurify.sanitize(html);
}

/** Per-document state collected by the renderer. */
let toc = [];
let slugger = new GithubSlugger();
Expand All @@ -105,11 +132,15 @@ const marked = new Marked({
return `<h${depth} id="${id}">${text}</h${depth}>\n`;
},
code({ text, lang }) {
const language = SHIKI_LANGS.includes(lang) ? lang
: lang === 'ts' ? 'typescript'
: lang === 'js' ? 'javascript'
: lang === 'sh' ? 'shell'
: 'text';
const language = SHIKI_LANGS.includes(lang)
? lang
: lang === 'ts'
? 'typescript'
: lang === 'js'
? 'javascript'
: lang === 'sh'
? 'shell'
: 'text';
return highlighter.codeToHtml(text, {
lang: language === 'text' ? 'text' : language,
themes: SHIKI_THEMES,
Expand Down Expand Up @@ -172,7 +203,7 @@ function renderDocument(raw) {
html = html.replace(/(?:<p>)?%%ASIDE_(\d+)%%(?:<\/p>)?/g, (_, i) =>
renderAside(asides[Number(i)]),
);
return { data, html, toc };
return { data, html: sanitizeHtml(html), toc };
}

function tsModule(doc, outFile) {
Expand Down Expand Up @@ -296,5 +327,5 @@ ${mapEntries.join('\n')}

console.log(
`Generated ${mapEntries.length} documents, ` +
`${manifest.guides.length} guides, ${manifest.challenges.length} challenge categories.`,
`${manifest.guides.length} guides, ${manifest.challenges.length} challenge categories.`,
);
4 changes: 3 additions & 1 deletion website/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
Expand All @@ -17,7 +18,8 @@
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
Expand Down
18 changes: 17 additions & 1 deletion website/vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,21 @@
"installCommand": "pnpm install",
"buildCommand": "pnpm build",
"outputDirectory": "dist/angular-challenges-website/browser",
"rewrites": [{ "source": "/(.*)", "destination": "/api/ssr" }]
"rewrites": [{ "source": "/(.*)", "destination": "/api/ssr" }],
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://pagead2.googlesyndication.com https://googleads.g.doubleclick.net https://tpc.googlesyndication.com https://ep2.adtrafficquality.google https://giscus.app; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com https://pagead2.googlesyndication.com https://ep1.adtrafficquality.google; frame-src https://giscus.app https://googleads.g.doubleclick.net https://tpc.googlesyndication.com https://www.google.com https://ep2.adtrafficquality.google; media-src 'self' https://github.com https://user-images.githubusercontent.com https://private-user-images.githubusercontent.com; object-src 'none'; base-uri 'self'; form-action 'self' https://github.com; frame-ancestors 'self'"
},
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "X-Frame-Options", "value": "SAMEORIGIN" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{ "key": "Strict-Transport-Security", "value": "max-age=31536000" },
{ "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" }
]
}
]
}
Loading