mirror of
https://github.com/thomiceli/opengist.git
synced 2026-08-07 07:14:49 +00:00
Sanitize DOM for iypnb (#736)
This commit is contained in:
@@ -1,11 +1,14 @@
|
|||||||
package context
|
package context
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
"github.com/markbates/goth/gothic"
|
"github.com/markbates/goth/gothic"
|
||||||
"github.com/thomiceli/opengist/internal/config"
|
"github.com/thomiceli/opengist/internal/config"
|
||||||
"github.com/thomiceli/opengist/internal/session"
|
"github.com/thomiceli/opengist/internal/session"
|
||||||
"path/filepath"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
@@ -19,10 +22,28 @@ func NewStore(sessionsPath string) *Store {
|
|||||||
s := &Store{sessionsPath: sessionsPath}
|
s := &Store{sessionsPath: sessionsPath}
|
||||||
|
|
||||||
s.flashStore = sessions.NewCookieStore([]byte("opengist"))
|
s.flashStore = sessions.NewCookieStore([]byte("opengist"))
|
||||||
|
hardenCookie(s.flashStore.Options)
|
||||||
encryptKey, _ := session.GenerateSecretKey(filepath.Join(s.sessionsPath, "session-encrypt.key"))
|
encryptKey, _ := session.GenerateSecretKey(filepath.Join(s.sessionsPath, "session-encrypt.key"))
|
||||||
s.UserStore = sessions.NewFilesystemStore(s.sessionsPath, config.SecretKey, encryptKey)
|
s.UserStore = sessions.NewFilesystemStore(s.sessionsPath, config.SecretKey, encryptKey)
|
||||||
s.UserStore.MaxLength(10 * 1024)
|
s.UserStore.MaxLength(10 * 1024)
|
||||||
|
hardenCookie(s.UserStore.Options)
|
||||||
gothic.Store = s.UserStore
|
gothic.Store = s.UserStore
|
||||||
|
|
||||||
return s
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
gojson "encoding/json"
|
gojson "encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ func GistIndex(ctx *context.Context) error {
|
|||||||
ctx.SetData("hasMoreFiles", hasMoreFiles)
|
ctx.SetData("hasMoreFiles", hasMoreFiles)
|
||||||
ctx.SetData("revision", revision)
|
ctx.SetData("revision", revision)
|
||||||
ctx.SetData("htmlTitle", gist.Title)
|
ctx.SetData("htmlTitle", gist.Title)
|
||||||
|
setGistCSP(ctx)
|
||||||
return ctx.Html("gist.html")
|
return ctx.Html("gist.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,3 +303,22 @@ func escapeJavaScriptContent(htmlContent, cssUrl, themeUrl string, autoMode bool
|
|||||||
|
|
||||||
return js, nil
|
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 <embed> element PDFObject inserts.
|
||||||
|
"object-src 'self'; "+
|
||||||
|
"base-uri 'self'; "+
|
||||||
|
"frame-ancestors 'self'")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
@@ -134,6 +136,7 @@ func isClientGone(err error) bool {
|
|||||||
func dataInit(next Handler) Handler {
|
func dataInit(next Handler) Handler {
|
||||||
return func(ctx *context.Context) error {
|
return func(ctx *context.Context) error {
|
||||||
ctx.SetData("loadStartTime", time.Now())
|
ctx.SetData("loadStartTime", time.Now())
|
||||||
|
ctx.SetData("cspNonce", newCSPNonce())
|
||||||
|
|
||||||
if err := loadSettings(ctx); err != nil {
|
if err := loadSettings(ctx); err != nil {
|
||||||
return ctx.ErrorRes(500, "Cannot load settings", err)
|
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 {
|
func csrfInit(next Handler) Handler {
|
||||||
return func(ctx *context.Context) error {
|
return func(ctx *context.Context) error {
|
||||||
var csrf string
|
var csrf string
|
||||||
|
|||||||
Generated
+19
@@ -17,6 +17,7 @@
|
|||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
|
"dompurify": "^3.4.11",
|
||||||
"github-markdown-css": "^5.9.0",
|
"github-markdown-css": "^5.9.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"jdenticon": "^3.3.0",
|
"jdenticon": "^3.3.0",
|
||||||
@@ -904,6 +905,14 @@
|
|||||||
"undici-types": "~7.13.0"
|
"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": {
|
"node_modules/anymatch": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||||
@@ -1066,6 +1075,16 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.23.0",
|
"version": "5.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
|
"dompurify": "^3.4.11",
|
||||||
"github-markdown-css": "^5.9.0",
|
"github-markdown-css": "^5.9.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"jdenticon": "^3.3.0",
|
"jdenticon": "^3.3.0",
|
||||||
|
|||||||
+28
-20
@@ -1,6 +1,13 @@
|
|||||||
import hljs from 'highlight.js';
|
import hljs from 'highlight.js';
|
||||||
import latex from './latex';
|
import latex from './latex';
|
||||||
import { marked } from 'marked';
|
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 {
|
class IPynb {
|
||||||
private element: HTMLElement;
|
private element: HTMLElement;
|
||||||
@@ -10,7 +17,9 @@ class IPynb {
|
|||||||
|
|
||||||
constructor(element: HTMLElement) {
|
constructor(element: HTMLElement) {
|
||||||
this.element = element;
|
this.element = element;
|
||||||
let notebookContent = element.innerText;
|
// textContent yields the raw notebook JSON the server escaped into the
|
||||||
|
// <pre>; it is parsed as data, never injected as markup.
|
||||||
|
let notebookContent = element.textContent || '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.notebook = JSON.parse(notebookContent);
|
this.notebook = JSON.parse(notebookContent);
|
||||||
@@ -49,10 +58,10 @@ class IPynb {
|
|||||||
outputElement.appendChild(textElement);
|
outputElement.appendChild(textElement);
|
||||||
} else if (output.output_type === 'display_data' || output.output_type === 'execute_result') {
|
} else if (output.output_type === 'display_data' || output.output_type === 'execute_result') {
|
||||||
if (output.data['text/plain']) {
|
if (output.data['text/plain']) {
|
||||||
outputElement.innerHTML += `\n<pre>${output.data['text/plain']}</pre>`;
|
outputElement.innerHTML += sanitize(`\n<pre>${output.data['text/plain']}</pre>`);
|
||||||
}
|
}
|
||||||
if (output.data['text/html']) {
|
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/'));
|
const images = Object.keys(output.data).filter(key => key.startsWith('image/'));
|
||||||
@@ -60,19 +69,7 @@ class IPynb {
|
|||||||
const imgEl = document.createElement('img');
|
const imgEl = document.createElement('img');
|
||||||
const imgType = images[0]; // Use the first image type found
|
const imgType = images[0]; // Use the first image type found
|
||||||
imgEl.src = `data:${imgType};base64,${output.data[imgType]}`;
|
imgEl.src = `data:${imgType};base64,${output.data[imgType]}`;
|
||||||
outputElement.innerHTML += imgEl.outerHTML;
|
outputElement.innerHTML += sanitize(imgEl.outerHTML);
|
||||||
}
|
|
||||||
} else if (output.output_type === 'execute_result') {
|
|
||||||
if (output.data['text/plain']) {
|
|
||||||
outputElement.innerHTML += `<pre>${output.data['text/plain']}</pre>`;
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
} else if (output.output_type === 'error') {
|
} else if (output.output_type === 'error') {
|
||||||
outputElement.classList.add('error');
|
outputElement.classList.add('error');
|
||||||
@@ -91,13 +88,24 @@ class IPynb {
|
|||||||
switch (cell.cell_type) {
|
switch (cell.cell_type) {
|
||||||
case 'markdown':
|
case 'markdown':
|
||||||
cellElement.classList.add('markdown-cell');
|
cellElement.classList.add('markdown-cell');
|
||||||
cellElement.innerHTML = `<div class="markdown-body">${marked.parse(latex.render(source))}</div>`;
|
cellElement.innerHTML = sanitize(
|
||||||
|
`<div class="markdown-body">${marked.parse(latex.render(source)) as string}</div>`
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case 'code':
|
case 'code': {
|
||||||
cellElement.classList.add('code-cell');
|
cellElement.classList.add('code-cell');
|
||||||
cellElement.innerHTML = `<pre class="hljs"><code class="language-${this.language}">${source}</code></pre>`;
|
// Build the code block via DOM APIs so the source is treated as text,
|
||||||
hljs.highlightElement(cellElement.querySelector('code') as HTMLElement);
|
// 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;
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+3
-3
@@ -8,12 +8,12 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
<base href="{{ $.c.ExternalUrl }}" />
|
<base href="{{ $.c.ExternalUrl }}" />
|
||||||
|
|
||||||
{{ if .canonicalUrl }}
|
{{ if .canonicalUrl }}
|
||||||
<link rel="canonical" href="{{ .canonicalUrl }}" />
|
<link rel="canonical" href="{{ .canonicalUrl }}" />
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
<script>
|
<script nonce="{{ .cspNonce }}">
|
||||||
window.opengist_base_url = "{{ $.c.ExternalUrl }}";
|
window.opengist_base_url = "{{ $.c.ExternalUrl }}";
|
||||||
window.opengist_locale = "{{ .locale.Code }}".substring(0, 2);
|
window.opengist_locale = "{{ .locale.Code }}".substring(0, 2);
|
||||||
const checkTheme = () => {
|
const checkTheme = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user