diff --git a/internal/web/context/store.go b/internal/web/context/store.go index 5998f22..496ee62 100644 --- a/internal/web/context/store.go +++ b/internal/web/context/store.go @@ -1,11 +1,14 @@ package context import ( + "net/http" + "path/filepath" + "strings" + "github.com/gorilla/sessions" "github.com/markbates/goth/gothic" "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/session" - "path/filepath" ) type Store struct { @@ -19,10 +22,28 @@ func NewStore(sessionsPath string) *Store { s := &Store{sessionsPath: sessionsPath} s.flashStore = sessions.NewCookieStore([]byte("opengist")) + hardenCookie(s.flashStore.Options) encryptKey, _ := session.GenerateSecretKey(filepath.Join(s.sessionsPath, "session-encrypt.key")) s.UserStore = sessions.NewFilesystemStore(s.sessionsPath, config.SecretKey, encryptKey) s.UserStore.MaxLength(10 * 1024) + hardenCookie(s.UserStore.Options) gothic.Store = s.UserStore return s } + +// hardenCookie applies secure defaults to a session cookie. HttpOnly keeps the +// cookie out of reach of JavaScript (so an XSS cannot read the auth session), +// and SameSite=Lax mitigates CSRF. Secure is only set when the configured +// external URL is HTTPS, otherwise the cookie would never be sent over a +// plain-HTTP deployment and logins would silently break. +func hardenCookie(opts *sessions.Options) { + if opts == nil { + return + } + opts.HttpOnly = true + opts.SameSite = http.SameSiteLaxMode + if strings.HasPrefix(strings.ToLower(config.C.ExternalUrl), "https://") { + opts.Secure = true + } +} diff --git a/internal/web/handlers/gist/gist.go b/internal/web/handlers/gist/gist.go index b4730b2..7d6b276 100644 --- a/internal/web/handlers/gist/gist.go +++ b/internal/web/handlers/gist/gist.go @@ -6,6 +6,7 @@ import ( gojson "encoding/json" "fmt" "net/url" + "os" "strings" "time" @@ -44,6 +45,7 @@ func GistIndex(ctx *context.Context) error { ctx.SetData("hasMoreFiles", hasMoreFiles) ctx.SetData("revision", revision) ctx.SetData("htmlTitle", gist.Title) + setGistCSP(ctx) return ctx.Html("gist.html") } @@ -301,3 +303,22 @@ func escapeJavaScriptContent(htmlContent, cssUrl, themeUrl string, autoMode bool return js, nil } + +func setGistCSP(ctx *context.Context) { + if os.Getenv("OG_DEV") == "1" { + return + } + nonce, _ := ctx.GetData("cspNonce").(string) + ctx.Response().Header().Set("Content-Security-Policy", + "default-src 'self'; "+ + "script-src 'self' 'nonce-"+nonce+"'; "+ + "style-src 'self' 'unsafe-inline'; "+ + "img-src 'self' data:; "+ + "font-src 'self' data:; "+ + "connect-src 'self'; "+ + // 'self' (not 'none') so same-origin PDF previews keep working via + // the element PDFObject inserts. + "object-src 'self'; "+ + "base-uri 'self'; "+ + "frame-ancestors 'self'") +} diff --git a/internal/web/server/middlewares.go b/internal/web/server/middlewares.go index c7ac564..7ebbc00 100644 --- a/internal/web/server/middlewares.go +++ b/internal/web/server/middlewares.go @@ -1,7 +1,9 @@ package server import ( + "crypto/rand" "crypto/subtle" + "encoding/base64" "errors" "fmt" "html/template" @@ -134,6 +136,7 @@ func isClientGone(err error) bool { func dataInit(next Handler) Handler { return func(ctx *context.Context) error { ctx.SetData("loadStartTime", time.Now()) + ctx.SetData("cspNonce", newCSPNonce()) if err := loadSettings(ctx); err != nil { return ctx.ErrorRes(500, "Cannot load settings", err) @@ -352,6 +355,20 @@ func sessionInit(next Handler) Handler { } } +// newCSPNonce returns a fresh random nonce for the Content-Security-Policy of +// the current request. The same value is exposed to templates (so inline +// scripts can carry a matching nonce attribute) and used in the CSP header set +// by handlers that opt into a strict policy (e.g. the gist view page). +func newCSPNonce() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + // rand.Read never returns an error on supported platforms; fall back to + // an empty nonce rather than serving a non-functional inline script. + return "" + } + return base64.RawStdEncoding.EncodeToString(b) +} + func csrfInit(next Handler) Handler { return func(ctx *context.Context) error { var csrf string diff --git a/package-lock.json b/package-lock.json index 4ed1dea..a562bea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.3.0", "codemirror": "^6.0.2", + "dompurify": "^3.4.11", "github-markdown-css": "^5.9.0", "highlight.js": "^11.11.1", "jdenticon": "^3.3.0", @@ -904,6 +905,14 @@ "undici-types": "~7.13.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -1066,6 +1075,16 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "dev": true, + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/enhanced-resolve": { "version": "5.23.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", diff --git a/package.json b/package.json index 4290cc4..1aabf81 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.3.0", "codemirror": "^6.0.2", + "dompurify": "^3.4.11", "github-markdown-css": "^5.9.0", "highlight.js": "^11.11.1", "jdenticon": "^3.3.0", diff --git a/public/ts/ipynb.ts b/public/ts/ipynb.ts index 0b5a10c..8f3c054 100644 --- a/public/ts/ipynb.ts +++ b/public/ts/ipynb.ts @@ -1,6 +1,13 @@ import hljs from 'highlight.js'; import latex from './latex'; import { marked } from 'marked'; +import DOMPurify from 'dompurify'; + +// Notebook content is attacker-controlled: any user can store a `.ipynb` gist +// whose JSON ends up injected into the page. Every fragment that reaches an +// `innerHTML` sink must therefore be sanitized to prevent stored XSS (see the +// security advisory describing the original markdown-cell / output sinks). +const sanitize = (html: string): string => DOMPurify.sanitize(html); class IPynb { private element: HTMLElement; @@ -10,7 +17,9 @@ class IPynb { constructor(element: HTMLElement) { this.element = element; - let notebookContent = element.innerText; + // textContent yields the raw notebook JSON the server escaped into the + //
; it is parsed as data, never injected as markup.
+    let notebookContent = element.textContent || '';
 
     try {
       this.notebook = JSON.parse(notebookContent);
@@ -49,10 +58,10 @@ class IPynb {
         outputElement.appendChild(textElement);
       } else if (output.output_type === 'display_data' || output.output_type === 'execute_result') {
         if (output.data['text/plain']) {
-          outputElement.innerHTML += `\n
${output.data['text/plain']}
`; + outputElement.innerHTML += sanitize(`\n
${output.data['text/plain']}
`); } if (output.data['text/html']) { - outputElement.innerHTML += '\n' + output.data['text/html']; + outputElement.innerHTML += '\n' + sanitize(output.data['text/html']); } const images = Object.keys(output.data).filter(key => key.startsWith('image/')); @@ -60,19 +69,7 @@ class IPynb { const imgEl = document.createElement('img'); const imgType = images[0]; // Use the first image type found imgEl.src = `data:${imgType};base64,${output.data[imgType]}`; - outputElement.innerHTML += imgEl.outerHTML; - } - } else if (output.output_type === 'execute_result') { - if (output.data['text/plain']) { - outputElement.innerHTML += `
${output.data['text/plain']}
`; - } - if (output.data['text/html']) { - outputElement.innerHTML += output.data['text/html']; - } - if (output.data['image/png']) { - const imgEl = document.createElement('img'); - imgEl.src = `data:image/png;base64,${output.data['image/png']}`; - outputElement.appendChild(imgEl); + outputElement.innerHTML += sanitize(imgEl.outerHTML); } } else if (output.output_type === 'error') { outputElement.classList.add('error'); @@ -91,13 +88,24 @@ class IPynb { switch (cell.cell_type) { case 'markdown': cellElement.classList.add('markdown-cell'); - cellElement.innerHTML = `
${marked.parse(latex.render(source))}
`; + cellElement.innerHTML = sanitize( + `
${marked.parse(latex.render(source)) as string}
` + ); break; - case 'code': + case 'code': { cellElement.classList.add('code-cell'); - cellElement.innerHTML = `
${source}
`; - hljs.highlightElement(cellElement.querySelector('code') as HTMLElement); + // Build the code block via DOM APIs so the source is treated as text, + // not markup, before highlight.js processes it. + const pre = document.createElement('pre'); + pre.classList.add('hljs'); + const code = document.createElement('code'); + code.classList.add(`language-${this.language}`); + code.textContent = source; + pre.appendChild(code); + cellElement.appendChild(pre); + hljs.highlightElement(code); break; + } default: break; } diff --git a/templates/base/base_header.html b/templates/base/base_header.html index 3c64a01..fdc49ce 100644 --- a/templates/base/base_header.html +++ b/templates/base/base_header.html @@ -8,12 +8,12 @@ {{ end }} - + {{ if .canonicalUrl }} {{ end }} - -