Block encoded unsafe links and revoked event credentials
This commit is contained in:
1 parent
df064155c7
commit
1e33f50c5e
4 files changed
+103
-7
No files matched your search
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -15,6 +16,8 @@ import (
|
|||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
|
||||||
"rawmark/internal/auth"
|
"rawmark/internal/auth"
|
||||||
"rawmark/internal/events"
|
"rawmark/internal/events"
|
||||||
"rawmark/internal/search"
|
"rawmark/internal/search"
|
||||||
@@ -27,6 +30,7 @@ type fixture struct {
|
|||||||
url string
|
url string
|
||||||
root string
|
root string
|
||||||
store *store.Store
|
store *store.Store
|
||||||
|
hub *events.Hub
|
||||||
// 已登录的客户端
|
// 已登录的客户端
|
||||||
admin *http.Client
|
admin *http.Client
|
||||||
}
|
}
|
||||||
@@ -61,6 +65,48 @@ func TestMoveRollsBackWhenShareUpdateFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEventsRejectRevokedCredentials(t *testing.T) {
|
||||||
|
for _, kind := range []string{"session", "token"} {
|
||||||
|
t.Run(kind, func(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
client := newClient()
|
||||||
|
headers := http.Header{}
|
||||||
|
var tokenID string
|
||||||
|
if kind == "session" {
|
||||||
|
f.do(client, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil).Body.Close()
|
||||||
|
} else {
|
||||||
|
created := decode[struct {
|
||||||
|
Token string
|
||||||
|
Info store.APIToken
|
||||||
|
}](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"events","scope":"read"}`, nil))
|
||||||
|
headers.Set("Authorization", "Bearer "+created.Token)
|
||||||
|
tokenID = created.Info.ID
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
conn, _, err := websocket.Dial(ctx, strings.Replace(f.url, "http://", "ws://", 1)+"/api/v1/events", &websocket.DialOptions{HTTPClient: client, HTTPHeader: headers})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.CloseNow()
|
||||||
|
// 首条事件确认订阅已经建立,避免把握手和广播的时序当作鉴权结果。
|
||||||
|
f.hub.Broadcast(events.Event{Type: "tree"})
|
||||||
|
if _, _, err := conn.Read(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if kind == "session" {
|
||||||
|
f.do(client, "POST", "/api/v1/auth/logout", "", nil).Body.Close()
|
||||||
|
} else {
|
||||||
|
f.do(f.admin, "DELETE", "/api/v1/tokens/"+tokenID, "", nil).Body.Close()
|
||||||
|
}
|
||||||
|
f.hub.Broadcast(events.Event{Type: "changed", Path: "private.md", Rev: "new"})
|
||||||
|
if _, _, err := conn.Read(ctx); websocket.CloseStatus(err) != websocket.StatusPolicyViolation {
|
||||||
|
t.Fatalf("撤销后应关闭连接,不能继续推送: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newFixture(t *testing.T) *fixture {
|
func newFixture(t *testing.T) *fixture {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
@@ -85,12 +131,13 @@ func newFixture(t *testing.T) *fixture {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { idx.Close() })
|
t.Cleanup(func() { idx.Close() })
|
||||||
server := New(v, am, st, events.NewHub(), idx)
|
hub := events.NewHub()
|
||||||
|
server := New(v, am, st, hub, idx)
|
||||||
server.ThemesDir = filepath.Join(dir, "themes")
|
server.ThemesDir = filepath.Join(dir, "themes")
|
||||||
server.PluginsDir = filepath.Join(dir, "plugins")
|
server.PluginsDir = filepath.Join(dir, "plugins")
|
||||||
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
|
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
f := &fixture{t: t, url: srv.URL, root: v.Root(), store: st, admin: newClient()}
|
f := &fixture{t: t, url: srv.URL, root: v.Root(), store: st, hub: hub, admin: newClient()}
|
||||||
if res := f.do(f.admin, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil); res.StatusCode != 200 {
|
if res := f.do(f.admin, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil); res.StatusCode != 200 {
|
||||||
t.Fatalf("登录失败: %d", res.StatusCode)
|
t.Fatalf("登录失败: %d", res.StatusCode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
@@ -15,6 +17,29 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.CloseNow()
|
defer conn.CloseNow()
|
||||||
|
reauthorize := func() bool {
|
||||||
|
var valid bool
|
||||||
|
var err error
|
||||||
|
if bearer, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok {
|
||||||
|
_, valid, err = s.auth.ValidateAPIToken(strings.TrimSpace(bearer))
|
||||||
|
} else {
|
||||||
|
var cookie *http.Cookie
|
||||||
|
cookie, err = r.Cookie(cookieName)
|
||||||
|
if err == nil {
|
||||||
|
valid, err = s.auth.Validate(cookie.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("事件连接鉴权失败", "err", err)
|
||||||
|
conn.Close(websocket.StatusInternalError, "鉴权失败")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
conn.Close(websocket.StatusPolicyViolation, "登录凭据已失效")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
// 客户端不发消息;CloseRead 负责处理控制帧并在连接断开时取消 ctx
|
// 客户端不发消息;CloseRead 负责处理控制帧并在连接断开时取消 ctx
|
||||||
ctx := conn.CloseRead(r.Context())
|
ctx := conn.CloseRead(r.Context())
|
||||||
msgs, unsubscribe := s.hub.Subscribe()
|
msgs, unsubscribe := s.hub.Subscribe()
|
||||||
@@ -32,10 +57,16 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
|||||||
conn.Close(websocket.StatusTryAgainLater, "too slow")
|
conn.Close(websocket.StatusTryAgainLater, "too slow")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !reauthorize() {
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := writeWithTimeout(ctx, conn, msg); err != nil {
|
if err := writeWithTimeout(ctx, conn, msg); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case <-ping.C:
|
case <-ping.C:
|
||||||
|
if !reauthorize() {
|
||||||
|
return
|
||||||
|
}
|
||||||
pctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
pctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
err := conn.Ping(pctx)
|
err := conn.Ping(pctx)
|
||||||
cancel()
|
cancel()
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ describe('安全模式', () => {
|
|||||||
'<p><a href="#">a</a> <a href="#">javascript:x</a> <img src="data:image/png;base64,AA" alt="i" /></p>\n',
|
'<p><a href="#">a</a> <a href="#">javascript:x</a> <img src="data:image/png;base64,AA" alt="i" /></p>\n',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
it('按浏览器解码后的协议过滤实体及控制字符', () => {
|
||||||
|
for (const href of ['javascript:void(0)', 'javascript:void(0)', 'javascript:void(0)', 'java	script:void(0)', ' javascript:void(0)']) {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
root.innerHTML = renderMarkdown(`[x](${href})`)
|
||||||
|
expect(root.querySelector('a')?.getAttribute('href')).toBe('#')
|
||||||
|
}
|
||||||
|
expect(renderMarkdown('[x](https://example.com/?a=1&b=2)')).toContain('https://example.com/?a=1&b=2')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Typora 扩展', () => {
|
describe('Typora 扩展', () => {
|
||||||
|
|||||||
@@ -58,9 +58,19 @@ export interface RenderOptions {
|
|||||||
const UNSAFE_PROTOCOL = /^\s*(javascript|vbscript|file|data):/i
|
const UNSAFE_PROTOCOL = /^\s*(javascript|vbscript|file|data):/i
|
||||||
const SAFE_DATA_IMAGE = /^\s*data:image\/(png|gif|jpeg|webp);/i
|
const SAFE_DATA_IMAGE = /^\s*data:image\/(png|gif|jpeg|webp);/i
|
||||||
|
|
||||||
|
function safeHref(href: string): string {
|
||||||
|
// 协议由 ASCII 组成;属性实体会被浏览器解码,URL 解析还会忽略制表符和换行。
|
||||||
|
const protocol = href.replace(/&#(?:([0-9]{1,7})|[xX]([\da-fA-F]{1,6}));|&(colon|Tab|NewLine);/g, (entity, decimal, hex, named) => {
|
||||||
|
if (named) return named === 'colon' ? ':' : named === 'Tab' ? '\t' : '\n'
|
||||||
|
const code = decimal ? Number(decimal) : parseInt(hex, 16)
|
||||||
|
return code <= 127 ? String.fromCharCode(code) : entity
|
||||||
|
}).replace(/[\t\r\n]/g, '').replace(/^[\x00-\x20]+/, '')
|
||||||
|
return UNSAFE_PROTOCOL.test(protocol) && !SAFE_DATA_IMAGE.test(protocol) ? '#' : href
|
||||||
|
}
|
||||||
|
|
||||||
export function renderMarkdown(src: string, { parser = markdownParser, unsafe = false }: RenderOptions = {}): string {
|
export function renderMarkdown(src: string, { parser = markdownParser, unsafe = false }: RenderOptions = {}): string {
|
||||||
const tree = parser.parse(src)
|
const tree = parser.parse(src)
|
||||||
const safeHref = (href: string) => (unsafe || !UNSAFE_PROTOCOL.test(href) || SAFE_DATA_IMAGE.test(href) ? href : '#')
|
const linkHref = (href: string) => unsafe ? href : safeHref(href)
|
||||||
const rawHtml = (s: string) => (unsafe ? s : escapeHtml(s))
|
const rawHtml = (s: string) => (unsafe ? s : escapeHtml(s))
|
||||||
const text = (from: number, to: number) => src.slice(from, to)
|
const text = (from: number, to: number) => src.slice(from, to)
|
||||||
const defs = new Map<string, LinkDefinition>()
|
const defs = new Map<string, LinkDefinition>()
|
||||||
@@ -296,13 +306,13 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
|
|||||||
if (!url) return escapeHtml(text(node.from, node.to))
|
if (!url) return escapeHtml(text(node.from, node.to))
|
||||||
const raw = text(url.from, url.to)
|
const raw = text(url.from, url.to)
|
||||||
const href = /^[^@\s]+@[^@\s]+$/.test(raw) && !raw.includes(':') ? 'mailto:' + raw : raw
|
const href = /^[^@\s]+@[^@\s]+$/.test(raw) && !raw.includes(':') ? 'mailto:' + raw : raw
|
||||||
return `<a href="${escapeAttr(safeHref(normalizeUrl(href)))}">${escapeHtml(raw)}</a>`
|
return `<a href="${escapeAttr(linkHref(normalizeUrl(href)))}">${escapeHtml(raw)}</a>`
|
||||||
}
|
}
|
||||||
case 'URL': {
|
case 'URL': {
|
||||||
// GFM 裸链接
|
// GFM 裸链接
|
||||||
const raw = text(node.from, node.to)
|
const raw = text(node.from, node.to)
|
||||||
const href = raw.startsWith('www.') ? 'http://' + raw : raw.includes('@') && !raw.includes(':') ? 'mailto:' + raw : raw
|
const href = raw.startsWith('www.') ? 'http://' + raw : raw.includes('@') && !raw.includes(':') ? 'mailto:' + raw : raw
|
||||||
return `<a href="${escapeAttr(safeHref(normalizeUrl(href)))}">${escapeHtml(raw)}</a>`
|
return `<a href="${escapeAttr(linkHref(normalizeUrl(href)))}">${escapeHtml(raw)}</a>`
|
||||||
}
|
}
|
||||||
case 'Link':
|
case 'Link':
|
||||||
case 'Image':
|
case 'Image':
|
||||||
@@ -334,8 +344,8 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const title = def.title !== null ? ` title="${escapeAttr(def.title)}"` : ''
|
const title = def.title !== null ? ` title="${escapeAttr(def.title)}"` : ''
|
||||||
if (isImage) return `<img src="${escapeAttr(safeHref(def.href))}" alt="${escapeAttr(plainText(content))}"${title} />`
|
if (isImage) return `<img src="${escapeAttr(linkHref(def.href))}" alt="${escapeAttr(plainText(content))}"${title} />`
|
||||||
return `<a href="${escapeAttr(safeHref(def.href))}"${title}>${content}</a>`
|
return `<a href="${escapeAttr(linkHref(def.href))}"${title}>${content}</a>`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 块级 ----
|
// ---- 块级 ----
|
||||||
|
|||||||
Reference in new issue
Block a user