Guides: make images render, and stop drafts leaking to readers
Images were a broken icon for every user. The stored URL was bound straight to <img src>, and a plain image request carries no Authorization header while this app keeps its JWT in one, so every fetch was a 401. They are now fetched through the API client, which also fixes the stored '/api/...' path under a subpath mount where the API is not at /api at all. A failed image says so in words rather than rendering as a broken icon, and the editor's own thumbnails go through the same path - an author needs to see the screenshot they pasted. Revision and image routes gated on their parent guide. Both were gated on guides.view alone, and revision ids are sequential, so being able to read one guide was enough to walk the ids and read every unpublished draft on the site - the opposite of what docs/GUIDES.md promises. An image in a PUBLISHED guide still serves to any reader; one that only appears in a draft does not. The guide list, the tag chips and search now match the PUBLISHED text rather than the working copy. Matching the working copy handed readers a hit on wording that is not in the page when they open it. The slug no longer follows the title. It was regenerated on every save that carried a title and the editor autosaves every three seconds, so fixing a typo in a heading silently moved the URL and broke exactly the pasted links the stored slug exists to protect. publish() now aborts when the pre-publish save failed. It ran regardless, so the author saw "Published version N" while readers got the previous revision. Link rendering: text is escaped before formatting, so a captured URL arrives carrying entities and was escaped a second time - every multi-parameter ServiceNow and SharePoint link opened with &amp; in it. Emphasis could also reach inside an href, and '//host' passed as an in-app link when the browser reads it as off-site. Tests are now HTTP-level. The draft-invisibility rule lived entirely in routes that no test executed, which is how three of them shipped serving drafts.
This commit is contained in:
@@ -11,6 +11,7 @@ serving the working copy from the reader's route would quietly undo it.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
import re
|
||||
|
||||
from flask import Blueprint, request, Response
|
||||
@@ -19,7 +20,7 @@ from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
|
||||
from shopdb.api import (
|
||||
db,
|
||||
success_response, error_response, ErrorCodes,
|
||||
require_permission,
|
||||
require_permission, harden_upload_response,
|
||||
)
|
||||
|
||||
from ..models import Guide, GuideRevision, GuideImage
|
||||
@@ -109,27 +110,51 @@ def list_guides():
|
||||
drafts are invisible to readers - but an author must be able to find their
|
||||
own, or drafting is a trap door.
|
||||
"""
|
||||
query = Guide.query
|
||||
caneditall = _has('guides.edit')
|
||||
mine = _userid()
|
||||
|
||||
# Match against the PUBLISHED text for anything the caller is only allowed
|
||||
# to read, and against the working copy only for guides they may edit.
|
||||
# Searching the working copy for everyone leaks draft wording to readers:
|
||||
# they get a hit on a word that is not in the page when they open it.
|
||||
published = db.aliased(GuideRevision)
|
||||
query = Guide.query.outerjoin(
|
||||
published, Guide.publishedrevisionid == published.guiderevisionid)
|
||||
|
||||
search = (request.args.get('q') or '').strip()
|
||||
if search:
|
||||
like = '%{}%'.format(search)
|
||||
query = query.filter(db.or_(
|
||||
Guide.title.ilike(like),
|
||||
Guide.summary.ilike(like),
|
||||
Guide.tags.ilike(like),
|
||||
|
||||
def matches(title, summary, tags, blocks):
|
||||
# The body too: people look for "the one about the drive letter",
|
||||
# not for a title they never read.
|
||||
Guide.blocksjson.ilike(like),
|
||||
))
|
||||
return db.or_(title.ilike(like), summary.ilike(like),
|
||||
tags.ilike(like), blocks.ilike(like))
|
||||
|
||||
working = matches(Guide.title, Guide.summary, Guide.tags, Guide.blocksjson)
|
||||
readable = matches(published.title, published.summary,
|
||||
published.tags, published.blocksjson)
|
||||
if caneditall:
|
||||
query = query.filter(db.or_(working, readable))
|
||||
else:
|
||||
query = query.filter(db.or_(
|
||||
readable,
|
||||
db.and_(Guide.createdby == mine, working),
|
||||
))
|
||||
|
||||
tag = (request.args.get('tag') or '').strip()
|
||||
if tag:
|
||||
query = query.filter(Guide.tags.ilike('%{}%'.format(tag)))
|
||||
like = '%{}%'.format(tag)
|
||||
if caneditall:
|
||||
query = query.filter(db.or_(Guide.tags.ilike(like),
|
||||
published.tags.ilike(like)))
|
||||
else:
|
||||
query = query.filter(db.or_(
|
||||
published.tags.ilike(like),
|
||||
db.and_(Guide.createdby == mine, Guide.tags.ilike(like)),
|
||||
))
|
||||
|
||||
caneditall = _has('guides.edit')
|
||||
if not caneditall:
|
||||
mine = _userid()
|
||||
query = query.filter(db.or_(
|
||||
Guide.publishedrevisionid.isnot(None),
|
||||
Guide.createdby == mine,
|
||||
@@ -143,6 +168,18 @@ def list_guides():
|
||||
data['ispublished'] = bool(guide.publishedrevisionid)
|
||||
data['checklistitems'] = checklistitemcount(guide.blocks)
|
||||
data['canedit'] = _mayedit(guide)
|
||||
|
||||
# A reader's card must describe the guide they will actually open. The
|
||||
# Guide row carries the working copy, so a retitled draft would list
|
||||
# under its new name and open under its old one.
|
||||
if not data['canedit'] and guide.publishedrevisionid:
|
||||
revision = db.session.get(GuideRevision, guide.publishedrevisionid)
|
||||
if revision is not None:
|
||||
data['title'] = revision.title
|
||||
data['summary'] = revision.summary
|
||||
data['tags'] = [t.strip() for t in (revision.tags or '').split(',')
|
||||
if t.strip()]
|
||||
data['checklistitems'] = checklistitemcount(revision.blocks)
|
||||
out.append(data)
|
||||
return success_response(out)
|
||||
|
||||
@@ -151,9 +188,24 @@ def list_guides():
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def list_tags():
|
||||
"""Every tag in use, with a count, for the filter chips."""
|
||||
"""Every tag in use, with a count, for the filter chips.
|
||||
|
||||
Readers count published tags only. A chip carrying a tag that exists solely
|
||||
on somebody's draft is both a leak and a dead end - filtering by it returns
|
||||
nothing a reader is allowed to see.
|
||||
"""
|
||||
counts = {}
|
||||
for (tags,) in db.session.query(Guide.tags).filter(Guide.tags.isnot(None)).all():
|
||||
if _has('guides.edit'):
|
||||
pairs = db.session.query(Guide.tags).filter(Guide.tags.isnot(None)).all()
|
||||
else:
|
||||
published = db.aliased(GuideRevision)
|
||||
pairs = db.session.query(published.tags).join(
|
||||
Guide, Guide.publishedrevisionid == published.guiderevisionid
|
||||
).filter(published.tags.isnot(None)).all()
|
||||
mine = _userid()
|
||||
pairs += db.session.query(Guide.tags).filter(
|
||||
Guide.createdby == mine, Guide.tags.isnot(None)).all()
|
||||
for (tags,) in pairs:
|
||||
for tag in [t.strip() for t in tags.split(',') if t.strip()]:
|
||||
counts[tag] = counts.get(tag, 0) + 1
|
||||
return success_response([
|
||||
@@ -229,6 +281,13 @@ def list_revisions(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
# History is editors' business. guides.view alone is permission to read what
|
||||
# was PUBLISHED; the revision list is every draft this guide has ever been,
|
||||
# including the ones deliberately not published yet.
|
||||
if not _mayedit(guide):
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'You do not have permission to see this history',
|
||||
http_code=403)
|
||||
rows = (GuideRevision.query.filter_by(guideid=guideid)
|
||||
.order_by(GuideRevision.revision.desc()).all())
|
||||
published = guide.publishedrevisionid
|
||||
@@ -242,10 +301,27 @@ def list_revisions(guideid):
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def read_revision(revisionid):
|
||||
"""One revision by id.
|
||||
|
||||
Gated on its PARENT, not on guides.view alone. Revision ids are sequential
|
||||
integers, so an ungated route here is an invitation to walk 1..n and read
|
||||
every unpublished draft on the site - which is the opposite of what
|
||||
docs/GUIDES.md promises about drafts.
|
||||
|
||||
A reader may fetch the published revision (it is what the slug route would
|
||||
serve anyway); everything else takes edit rights on that guide.
|
||||
"""
|
||||
revision = db.session.get(GuideRevision, revisionid)
|
||||
if revision is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
|
||||
http_code=404)
|
||||
guide = db.session.get(Guide, revision.guideid)
|
||||
ispublished = bool(guide is not None
|
||||
and guide.publishedrevisionid == revision.guiderevisionid)
|
||||
if not ispublished and not (guide is not None and _mayedit(guide)):
|
||||
# 404, not 403: a draft's existence is itself the thing being withheld.
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
|
||||
http_code=404)
|
||||
return success_response(revision.to_dict(includeblocks=True))
|
||||
|
||||
|
||||
@@ -311,7 +387,16 @@ def update_guide(guideid):
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
||||
|
||||
if 'title' in data:
|
||||
# The slug is set once, at create, and does NOT follow the title.
|
||||
#
|
||||
# _slugify's own docstring says the slug is stored so that renaming cannot
|
||||
# break a link somebody pasted into a work order months ago - and then this
|
||||
# re-slugged on every save that carried a title, which the editor's 3-second
|
||||
# autosave does on every keystroke. Fixing a typo in a heading silently
|
||||
# moved the guide's URL and broke exactly the links the stored slug exists
|
||||
# to protect. A guide with no slug (older row, or a failed create) still
|
||||
# gets one here.
|
||||
if not guide.slug:
|
||||
guide.slug = _slugify(guide.title, guide.guideid)
|
||||
|
||||
db.session.commit()
|
||||
@@ -426,6 +511,47 @@ def upload_image():
|
||||
return success_response(image.to_dict(), message='Image uploaded')
|
||||
|
||||
|
||||
def _mayseeimage(image):
|
||||
"""Whether the caller may fetch this image.
|
||||
|
||||
An image is content. One pasted into a published guide must be readable by
|
||||
anyone who can read that guide; one pasted into a draft must not be, or the
|
||||
draft leaks a screenshot at a time to anyone willing to count upwards
|
||||
through image ids.
|
||||
|
||||
So: editors of the parent see it, and everyone else sees it only if the
|
||||
PUBLISHED revision actually references it. An unattached image - pasted
|
||||
before the guide was first saved - belongs to whoever uploaded it.
|
||||
"""
|
||||
if image.guideid is None:
|
||||
return _has('guides.edit') or image.uploadedby == _userid()
|
||||
|
||||
guide = db.session.get(Guide, image.guideid)
|
||||
if guide is None:
|
||||
return _has('guides.edit')
|
||||
if _mayedit(guide):
|
||||
return True
|
||||
if not guide.publishedrevisionid:
|
||||
return False
|
||||
|
||||
revision = db.session.get(GuideRevision, guide.publishedrevisionid)
|
||||
if revision is None:
|
||||
return False
|
||||
try:
|
||||
blocks = json.loads(revision.blocksjson or '[]')
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
needle = '/guides/images/{}'.format(image.guideimageid)
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict) or block.get('type') != 'image':
|
||||
continue
|
||||
if str(block.get('imageid') or '') == str(image.guideimageid):
|
||||
return True
|
||||
if needle in str(block.get('url') or ''):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@guides_bp.route('/images/<int:imageid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
@@ -433,10 +559,15 @@ def serve_image(imageid):
|
||||
image = db.session.get(GuideImage, imageid)
|
||||
if image is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Image not found', http_code=404)
|
||||
return Response(image.data, mimetype=image.mimetype, headers={
|
||||
if not _mayseeimage(image):
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Image not found', http_code=404)
|
||||
# Same inert-document headers every other upload route serves under: an
|
||||
# uploaded file is attacker-influenced content and must not be able to run
|
||||
# as a document in this origin.
|
||||
return harden_upload_response(Response(image.data, mimetype=image.mimetype, headers={
|
||||
# Immutable: an image is never edited in place, only replaced by a new
|
||||
# upload with a new id, so it can be cached hard.
|
||||
'Cache-Control': 'private, max-age=31536000, immutable',
|
||||
'Content-Disposition': 'inline; filename="{}"'.format(
|
||||
image.filename or 'image{}'.format(imageid)),
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -72,8 +72,27 @@
|
||||
<pre :ref="el => registerCode(block.id, el)"><code>{{ block.text }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The image is fetched by script, not by the browser.
|
||||
|
||||
An <img src> is a plain browser request: it carries no Authorization
|
||||
header, and this app keeps its JWT in one. So a direct src against the
|
||||
authenticated image route is a 401 and a broken-image icon for every
|
||||
reader - which is exactly how this shipped. Fetching through the api
|
||||
client sends the token, and it resolves against the client's baseURL, so
|
||||
it also survives a subpath mount that a stored '/api/...' string does
|
||||
not.
|
||||
|
||||
A failure says so in words. A guide whose screenshot is missing must not
|
||||
look like a guide that never had one.
|
||||
-->
|
||||
<figure v-else-if="block.type === 'image'" class="gb-figure">
|
||||
<img :src="block.url" :alt="block.alt || ''" loading="lazy">
|
||||
<img v-if="imageState(block).src" :src="imageState(block).src"
|
||||
:alt="block.alt || ''">
|
||||
<p v-else-if="imageState(block).failed" class="gb-imagefailed">
|
||||
Image unavailable{{ block.alt ? ': ' + block.alt : '' }}
|
||||
</p>
|
||||
<p v-else class="gb-imageloading">Loading image...</p>
|
||||
<figcaption v-if="block.caption">{{ block.caption }}</figcaption>
|
||||
</figure>
|
||||
|
||||
@@ -97,8 +116,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, toRef } from 'vue'
|
||||
import { renderInline } from '../utils/inline.js'
|
||||
import { useGuideImages } from '../utils/images.js'
|
||||
|
||||
const props = defineProps({
|
||||
blocks: { type: Array, default: () => [] },
|
||||
@@ -112,6 +132,9 @@ const selected = ref('')
|
||||
const codeNodes = {}
|
||||
function registerCode (id, el) { if (el) codeNodes[id] = el }
|
||||
|
||||
// Images are fetched by script, not by <img src>; see utils/images.js.
|
||||
const { state: imageState } = useGuideImages(toRef(props, 'blocks'))
|
||||
|
||||
function inline (text) {
|
||||
return renderInline(text)
|
||||
}
|
||||
@@ -226,6 +249,12 @@ function select (block) {
|
||||
|
||||
.gb-figure { margin: 0 0 1.2rem; }
|
||||
.gb-figure img { max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 4px; }
|
||||
.gb-imageloading, .gb-imagefailed {
|
||||
margin: 0; padding: 0.75rem 1rem; border: 1px dashed var(--border);
|
||||
border-radius: 4px; font-size: 0.9rem;
|
||||
}
|
||||
.gb-imageloading { color: var(--textmuted); }
|
||||
.gb-imagefailed { color: var(--danger, #b3261e); }
|
||||
.gb-figure figcaption { font-size: 0.8rem; color: var(--text-light); margin-top: 0.35rem; }
|
||||
|
||||
.gb-tablewrap { overflow-x: auto; margin: 0 0 1.2rem; }
|
||||
|
||||
77
plugins/guides/frontend/utils/images.js
Normal file
77
plugins/guides/frontend/utils/images.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Resolving guide images.
|
||||
*
|
||||
* A guide image lives behind an authenticated route, and an <img src> is a
|
||||
* plain browser request that carries no Authorization header - this app keeps
|
||||
* its JWT in one. Pointing src at the image route therefore yields 401 and a
|
||||
* broken-image icon for every reader, which is how guides first shipped.
|
||||
*
|
||||
* So the bytes are fetched through the api client, which attaches the token and
|
||||
* resolves against its own baseURL - the latter also fixing a stored
|
||||
* '/api/guides/images/N' string under a subpath mount, where the API is not at
|
||||
* /api at all.
|
||||
*
|
||||
* Used by the renderer AND the editor: an author needs to see the screenshot
|
||||
* they just pasted as much as a reader does.
|
||||
*/
|
||||
import { reactive, watch, onBeforeUnmount } from 'vue'
|
||||
import api from '@/api'
|
||||
|
||||
/** The image id a block refers to, whichever way it was written.
|
||||
*
|
||||
* Blocks store imageid (written since this was fixed) or a url string (written
|
||||
* before it). The url is never used as a src, only read for its id.
|
||||
*/
|
||||
export function imageIdOf (block) {
|
||||
if (!block) return ''
|
||||
if (block.imageid) return String(block.imageid)
|
||||
const match = /\/guides\/images\/(\d+)/.exec(block.url || '')
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps object URLs for the image blocks in `blocksRef`, fetching each id once
|
||||
* however many blocks use it, and revoking them as blocks go away.
|
||||
*
|
||||
* Returns `state(block)` -> { src, failed }.
|
||||
*/
|
||||
export function useGuideImages (blocksRef) {
|
||||
const images = reactive({})
|
||||
|
||||
async function load (id) {
|
||||
if (images[id]) return
|
||||
images[id] = { src: '', failed: false }
|
||||
try {
|
||||
const response = await api.get(`/guides/images/${id}`, { responseType: 'blob' })
|
||||
images[id].src = URL.createObjectURL(response.data)
|
||||
} catch (err) {
|
||||
images[id].failed = true
|
||||
}
|
||||
}
|
||||
|
||||
function release (id) {
|
||||
if (images[id] && images[id].src) URL.revokeObjectURL(images[id].src)
|
||||
delete images[id]
|
||||
}
|
||||
|
||||
function state (block) {
|
||||
const id = imageIdOf(block)
|
||||
if (!id) return { src: '', failed: true }
|
||||
return images[id] || { src: '', failed: false }
|
||||
}
|
||||
|
||||
// Editing replaces the block list on every keystroke, so resolve only ids
|
||||
// that are new and keep the ones already held. Without that, every keystroke
|
||||
// would refetch every image in the guide.
|
||||
watch(blocksRef, blocks => {
|
||||
const wanted = new Set(
|
||||
(blocks || []).filter(b => b && b.type === 'image')
|
||||
.map(imageIdOf).filter(Boolean))
|
||||
wanted.forEach(load)
|
||||
Object.keys(images).forEach(id => { if (!wanted.has(id)) release(id) })
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
onBeforeUnmount(() => { Object.keys(images).forEach(release) })
|
||||
|
||||
return { state }
|
||||
}
|
||||
@@ -27,12 +27,29 @@ function escapeHtml (text) {
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// The text was HTML-escaped before any pattern ran, so a captured URL arrives
|
||||
// carrying entities: a real query string like ?a=1&b=2 reaches here as
|
||||
// ?a=1&b=2. Escaping that again for the attribute produced &amp; and a
|
||||
// link that opened the wrong record - the ServiceNow and SharePoint links that
|
||||
// carry several parameters were all broken this way. Decode first, then let the
|
||||
// single escape on output put back exactly one level.
|
||||
function decodeEntities (text) {
|
||||
return String(text)
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&') // last, so &lt; does not become <
|
||||
}
|
||||
|
||||
// Only http(s) and mailto. A javascript: or data: URL in a link is the one way
|
||||
// an author could otherwise smuggle script past the escaping above.
|
||||
function safeHref (url) {
|
||||
const trimmed = String(url || '').trim()
|
||||
if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed
|
||||
if (trimmed.startsWith('/')) return trimmed // in-app link
|
||||
// In-app link. NOT '//host', which looks like a path and is read by the
|
||||
// browser as protocol-relative - an off-site link wearing a local costume.
|
||||
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) return trimmed
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -50,13 +67,18 @@ export function renderInline (text) {
|
||||
// [label](url). The URL pattern allows ONE level of balanced parentheses so
|
||||
// real links survive - Wikipedia-style /wiki/Foo_(bar) is the common case,
|
||||
// and a naive [^)]+ silently truncates it into a broken link.
|
||||
// Finished links are stashed like code spans, so the emphasis passes below
|
||||
// cannot reach inside an href. Without that, a URL containing *characters*
|
||||
// has <em> injected into the middle of the attribute and the link breaks.
|
||||
const links = []
|
||||
html = html.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (match, label, url) => {
|
||||
const href = safeHref(url)
|
||||
const href = safeHref(decodeEntities(url))
|
||||
if (!href) return label
|
||||
const external = /^https?:/i.test(href)
|
||||
return '<a href="' + escapeHtml(href) + '"' +
|
||||
links.push('<a href="' + escapeHtml(href) + '"' +
|
||||
(external ? ' target="_blank" rel="noopener noreferrer"' : '') +
|
||||
'>' + label + '</a>'
|
||||
'>' + label + '</a>')
|
||||
return '\u0000LINK' + (links.length - 1) + '\u0000'
|
||||
})
|
||||
|
||||
// **bold** then *italic*, longest marker first so **x** is not read as
|
||||
@@ -66,6 +88,8 @@ export function renderInline (text) {
|
||||
|
||||
html = html.replace(/\n/g, '<br>')
|
||||
|
||||
html = html.replace(/\u0000LINK(\d+)\u0000/g, (match, index) => links[Number(index)])
|
||||
|
||||
html = html.replace(/\u0000CODE(\d+)\u0000/g, (match, index) =>
|
||||
'<code>' + codeSpans[Number(index)] + '</code>')
|
||||
|
||||
|
||||
50
plugins/guides/frontend/utils/inline.spec.js
Normal file
50
plugins/guides/frontend/utils/inline.spec.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Inline rendering: the security model and the link bugs it hid.
|
||||
*
|
||||
* Escape-then-format is the whole defence, and it has one awkward consequence
|
||||
* that produced real broken links: by the time a URL is captured it is already
|
||||
* escaped, so it has to be decoded before it can be escaped again exactly once.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderInline } from './inline.js'
|
||||
|
||||
describe('renderInline', () => {
|
||||
it('escapes markup an author pasted', () => {
|
||||
expect(renderInline('<img src=x onerror=alert(1)>'))
|
||||
.toBe('<img src=x onerror=alert(1)>')
|
||||
})
|
||||
|
||||
it('keeps a multi-parameter URL intact', () => {
|
||||
// Double-escaped, this became &amp; and the link opened the wrong
|
||||
// record. Every ServiceNow and SharePoint link carries several parameters.
|
||||
const html = renderInline('[ticket](https://x.example/nav?a=1&b=2&c=3)')
|
||||
expect(html).toContain('href="https://x.example/nav?a=1&b=2&c=3"')
|
||||
expect(html).not.toContain('&amp;')
|
||||
})
|
||||
|
||||
it('does not let emphasis reach inside an href', () => {
|
||||
const html = renderInline('[t](https://x.example/a*b*c)')
|
||||
expect(html).toContain('href="https://x.example/a*b*c"')
|
||||
expect(html).not.toContain('<em>')
|
||||
})
|
||||
|
||||
it('refuses a protocol-relative URL dressed as an in-app path', () => {
|
||||
// '//evil.example' starts with '/' but the browser reads it as off-site.
|
||||
expect(renderInline('[evil](//evil.example/x)')).toBe('evil')
|
||||
})
|
||||
|
||||
it('refuses javascript: and keeps the label', () => {
|
||||
expect(renderInline('[bad](javascript:alert(1))')).toBe('bad')
|
||||
})
|
||||
|
||||
it('allows an in-app link', () => {
|
||||
expect(renderInline('[map](/map)')).toBe('<a href="/map">map</a>')
|
||||
})
|
||||
|
||||
it('still renders bold, italic and code', () => {
|
||||
expect(renderInline('**b**')).toBe('<strong>b</strong>')
|
||||
expect(renderInline('*i*')).toBe('<em>i</em>')
|
||||
expect(renderInline('`C:\\**temp**`'))
|
||||
.toBe('<code>C:\\**temp**</code>')
|
||||
})
|
||||
})
|
||||
@@ -142,7 +142,12 @@
|
||||
</div>
|
||||
|
||||
<div v-if="block.type === 'image'" class="ge-image">
|
||||
<img v-if="block.url" :src="block.url" alt="">
|
||||
<!-- Same authenticated fetch as the reader's view: an author must
|
||||
see the screenshot they just pasted. -->
|
||||
<img v-if="imageState(block).src" :src="imageState(block).src" alt="">
|
||||
<p v-else-if="imageState(block).failed" class="ge-imagefailed">
|
||||
Image unavailable - it may have been removed
|
||||
</p>
|
||||
<input v-model="block.caption" placeholder="Caption (optional)">
|
||||
<input v-model="block.alt" placeholder="Alt text, for anyone who cannot see it">
|
||||
</div>
|
||||
@@ -203,6 +208,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import GuideBlocks from '../components/GuideBlocks.vue'
|
||||
import { templates } from '../utils/templates.js'
|
||||
import { proof, applyFix } from '../utils/proof.js'
|
||||
import { useGuideImages } from '../utils/images.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -382,7 +388,10 @@ async function insertImage (file) {
|
||||
status.value = 'Uploading image...'
|
||||
try {
|
||||
const image = await uploadImage(file)
|
||||
const block = { id: newid(), type: 'image', url: image.url, caption: '', alt: '' }
|
||||
// imageid is what the renderer resolves; url stays for older readers and
|
||||
// for anything that still treats a block as a plain document.
|
||||
const block = { id: newid(), type: 'image', imageid: image.guideimageid,
|
||||
url: image.url, caption: '', alt: '' }
|
||||
blocks.value.splice(insertIndex(), 0, block)
|
||||
focused.value = block.id
|
||||
status.value = ''
|
||||
@@ -413,15 +422,21 @@ function onFilePick (event) {
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
// Editor thumbnails resolve the same way the reader's do; see utils/images.js.
|
||||
const { state: imageState } = useGuideImages(blocks)
|
||||
|
||||
function payload () {
|
||||
return { title: guide.title, summary: guide.summary || '',
|
||||
tags: guide.tags || '', blocks: blocks.value }
|
||||
}
|
||||
|
||||
// Returns whether the save actually reached the server. publish() depends on
|
||||
// that answer: a publish that runs after a failed save promotes the LAST
|
||||
// revision the server accepted, which is not what is on the author's screen.
|
||||
async function save (quiet) {
|
||||
if (!guide.title.trim()) {
|
||||
if (!quiet) error.value = 'A guide needs a title before it can be saved'
|
||||
return
|
||||
return false
|
||||
}
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
@@ -439,9 +454,11 @@ async function save (quiet) {
|
||||
dirty.value = false
|
||||
status.value = 'Saved ' + new Date().toLocaleTimeString()
|
||||
await loadRevisions()
|
||||
return true
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not save this guide')
|
||||
status.value = 'Not saved'
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -476,7 +493,14 @@ async function publish () {
|
||||
+ 'anything flagged in a command. Publish anyway?')
|
||||
if (!ok) return
|
||||
}
|
||||
await save(true)
|
||||
// Publishing promotes the newest SAVED revision, so an unsaved edit is not
|
||||
// in what readers get. Reporting "Published version 4" over a failed save
|
||||
// tells the author their words went out when the old ones did.
|
||||
if (!await save(true)) {
|
||||
error.value = error.value
|
||||
|| 'Could not save this guide, so it was not published'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await api.post('/guides/' + guide.guideid + '/publish')
|
||||
guide.ispublished = true
|
||||
@@ -637,6 +661,8 @@ onMounted(async () => {
|
||||
background: transparent; color: var(--text-light); border-radius: 3px;
|
||||
cursor: pointer; justify-self: start;
|
||||
}
|
||||
.ge-imagefailed { margin: 0 0 0.35rem; padding: 0.5rem 0.7rem; font-size: 0.85rem;
|
||||
color: var(--danger, #b3261e); border: 1px dashed var(--border); border-radius: 4px; }
|
||||
.ge-image img { max-width: 18rem; display: block; margin-bottom: 0.35rem;
|
||||
border: 1px solid var(--border); border-radius: 4px; }
|
||||
.ge-image input { width: 100%; margin-bottom: 0.25rem; padding: 0.3rem 0.45rem;
|
||||
|
||||
@@ -332,3 +332,230 @@ def test_a_corrupt_blocks_column_renders_as_empty_rather_than_erroring(gd_app, g
|
||||
guide.blocksjson = 'not json at all'
|
||||
_db.session.commit()
|
||||
assert guide.blocks == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The API surface. Everything above this line tests services directly; the
|
||||
# draft-invisibility rule lives in the ROUTES, and until these existed no test
|
||||
# executed a single one of them. That is how three routes shipped serving
|
||||
# unpublished drafts to any reader who could count upwards through ids.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _user_with(db, username, permissions, rolename=None):
|
||||
"""A user holding exactly these guides permissions and nothing else."""
|
||||
from werkzeug.security import generate_password_hash
|
||||
from shopdb.core.models import User, Role, Permission
|
||||
|
||||
role = Role(rolename=rolename or (username + 'role'), description=username)
|
||||
for name in permissions:
|
||||
# Shared across fixtures: two users in one test both want guides.view,
|
||||
# and permissions.name is unique.
|
||||
permission = Permission.query.filter_by(name=name).first()
|
||||
if permission is None:
|
||||
permission = Permission(name=name, description=name, category='guides')
|
||||
db.session.add(permission)
|
||||
db.session.flush()
|
||||
role.permissions.append(permission)
|
||||
db.session.add(role)
|
||||
db.session.flush()
|
||||
|
||||
user = User(username=username, email=username + '@test.local',
|
||||
passwordhash=generate_password_hash('testpass'))
|
||||
user.roles.append(role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _login(client, username):
|
||||
response = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': 'testpass'})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
return {'Authorization':
|
||||
'Bearer ' + response.get_json()['data']['access_token']}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reader(client, db):
|
||||
"""Can read guides. Cannot author them."""
|
||||
_user_with(db, 'guidereader', ['guides.view'])
|
||||
return _login(client, 'guidereader')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def author(client, db):
|
||||
"""Can write their own guides, but holds no guides.edit over others'."""
|
||||
_user_with(db, 'guideauthor', ['guides.view', 'guides.create'])
|
||||
return _login(client, 'guideauthor')
|
||||
|
||||
|
||||
def _draft(db, title='Unreleased procedure', published=False):
|
||||
"""A guide with one saved revision, published or not."""
|
||||
from plugins.guides.models import Guide
|
||||
from plugins.guides.services import authoring
|
||||
guide = Guide(title=title, slug=title.lower().replace(' ', '-'),
|
||||
status='draft')
|
||||
db.session.add(guide)
|
||||
db.session.flush()
|
||||
guide, revision = authoring.save(
|
||||
guide, title=title,
|
||||
blocks=[_block('b1', 'paragraph', text='secret draft wording')])
|
||||
if published:
|
||||
authoring.publish(guide)
|
||||
db.session.commit()
|
||||
return guide, revision
|
||||
|
||||
|
||||
def test_a_reader_cannot_open_an_unpublished_guide_by_slug(client, db, reader):
|
||||
guide, _ = _draft(db)
|
||||
response = client.get('/api/guides/' + guide.slug, headers=reader)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_a_reader_cannot_read_an_unpublished_revision_by_id(client, db, reader):
|
||||
"""Revision ids are sequential, so this route is walkable by hand.
|
||||
|
||||
Gating it on guides.view alone meant anyone who could read one guide could
|
||||
read every draft on the site by counting upwards.
|
||||
"""
|
||||
guide, revision = _draft(db)
|
||||
response = client.get('/api/guides/revisions/{}'.format(revision.guiderevisionid),
|
||||
headers=reader)
|
||||
assert response.status_code == 404
|
||||
assert 'secret draft wording' not in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_a_reader_may_read_the_published_revision_by_id(client, db, reader):
|
||||
guide, revision = _draft(db, title='Published procedure', published=True)
|
||||
response = client.get('/api/guides/revisions/{}'.format(revision.guiderevisionid),
|
||||
headers=reader)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_a_reader_cannot_list_a_guides_revision_history(client, db, reader):
|
||||
guide, _ = _draft(db, title='Published procedure', published=True)
|
||||
response = client.get('/api/guides/{}/revisions'.format(guide.guideid),
|
||||
headers=reader)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_search_does_not_match_a_reader_against_draft_wording(client, db, reader):
|
||||
"""A hit a reader cannot open is worse than no hit.
|
||||
|
||||
The guide row holds the WORKING copy, so matching it would surface a
|
||||
published guide on a word that exists only in somebody's unpublished edit -
|
||||
and the word is not on the page when the reader opens it.
|
||||
"""
|
||||
from plugins.guides.services import authoring
|
||||
guide, revision = _draft(db, title='Published procedure', published=True)
|
||||
authoring.save(guide, blocks=[_block('b1', 'paragraph',
|
||||
text='unpublished sentence about widgets')])
|
||||
db.session.commit()
|
||||
|
||||
response = client.get('/api/guides?q=widgets', headers=reader)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data'] == []
|
||||
|
||||
# The author of the draft still finds their own work.
|
||||
response = client.get('/api/guides?q=secret', headers=reader)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_an_author_may_edit_their_own_draft_but_not_someone_elses(client, db, author):
|
||||
guide, _ = _draft(db)
|
||||
response = client.get('/api/guides/{}/working'.format(guide.guideid),
|
||||
headers=author)
|
||||
assert response.status_code == 403, 'not their guide'
|
||||
|
||||
|
||||
def test_an_image_upload_refuses_a_type_that_is_not_an_image(client, db, author):
|
||||
import io
|
||||
data = {'file': (io.BytesIO(b'MZ not a picture'), 'payload.exe')}
|
||||
response = client.post('/api/guides/images', data=data, headers=author,
|
||||
content_type='multipart/form-data')
|
||||
assert response.status_code == 400
|
||||
assert 'Unsupported image type' in response.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_an_image_in_a_draft_is_not_served_to_a_reader(client, db, reader, author):
|
||||
"""An image is content, and a draft's images are draft content."""
|
||||
import io
|
||||
from plugins.guides.models import GuideImage
|
||||
|
||||
png = (b'\x89PNG\r\n\x1a\n' + b'0' * 32)
|
||||
upload = client.post(
|
||||
'/api/guides/images',
|
||||
data={'file': (io.BytesIO(png), 'screenshot.png')},
|
||||
headers=author, content_type='multipart/form-data')
|
||||
assert upload.status_code == 200, upload.get_json()
|
||||
imageid = upload.get_json()['data']['guideimageid']
|
||||
|
||||
# Unattached: it belongs to whoever uploaded it, not to every reader.
|
||||
assert client.get('/api/guides/images/{}'.format(imageid),
|
||||
headers=reader).status_code == 404
|
||||
assert client.get('/api/guides/images/{}'.format(imageid),
|
||||
headers=author).status_code == 200
|
||||
|
||||
# Attached to an unpublished guide: still not the reader's business.
|
||||
guide, _ = _draft(db)
|
||||
db.session.get(GuideImage, imageid).guideid = guide.guideid
|
||||
db.session.commit()
|
||||
assert client.get('/api/guides/images/{}'.format(imageid),
|
||||
headers=reader).status_code == 404
|
||||
|
||||
|
||||
def test_an_image_in_a_published_guide_is_served_and_hardened(client, db, reader, author):
|
||||
"""The other half: a published screenshot must actually reach the reader.
|
||||
|
||||
Guarding this route without checking the published case is how you trade a
|
||||
leak for every image on the site rendering as a broken icon.
|
||||
"""
|
||||
import io
|
||||
from plugins.guides.models import Guide, GuideImage
|
||||
from plugins.guides.services import authoring
|
||||
|
||||
png = (b'\x89PNG\r\n\x1a\n' + b'0' * 32)
|
||||
upload = client.post(
|
||||
'/api/guides/images',
|
||||
data={'file': (io.BytesIO(png), 'screenshot.png')},
|
||||
headers=author, content_type='multipart/form-data')
|
||||
imageid = upload.get_json()['data']['guideimageid']
|
||||
|
||||
guide = Guide(title='With a picture', slug='with-a-picture', status='draft')
|
||||
db.session.add(guide)
|
||||
db.session.flush()
|
||||
guide, revision = authoring.save(
|
||||
guide, title='With a picture',
|
||||
blocks=[{'id': 'b1', 'type': 'image', 'imageid': imageid,
|
||||
'url': '/api/guides/images/{}'.format(imageid)}])
|
||||
authoring.publish(guide)
|
||||
db.session.get(GuideImage, imageid).guideid = guide.guideid
|
||||
db.session.commit()
|
||||
|
||||
response = client.get('/api/guides/images/{}'.format(imageid), headers=reader)
|
||||
assert response.status_code == 200
|
||||
assert response.data == png
|
||||
# Uploaded bytes are attacker-influenced; they must be inert in this origin.
|
||||
assert response.headers.get('X-Content-Type-Options') == 'nosniff'
|
||||
assert 'Content-Security-Policy' in response.headers
|
||||
|
||||
|
||||
def test_renaming_a_guide_does_not_move_its_url(client, db, author):
|
||||
"""The stored slug exists so a pasted link keeps working.
|
||||
|
||||
The editor autosaves on a timer, so re-slugging on title change moved the
|
||||
URL while somebody was still typing the title.
|
||||
"""
|
||||
created = client.post('/api/guides',
|
||||
json={'title': 'Replacing a marker controler',
|
||||
'blocks': []}, headers=author)
|
||||
assert created.status_code == 200, created.get_json()
|
||||
guideid = created.get_json()['data']['guideid']
|
||||
slug = created.get_json()['data']['slug']
|
||||
|
||||
fixed = client.put('/api/guides/{}'.format(guideid),
|
||||
json={'title': 'Replacing a marker controller',
|
||||
'blocks': []}, headers=author)
|
||||
assert fixed.status_code == 200
|
||||
assert fixed.get_json()['data']['slug'] == slug, 'the URL must not move'
|
||||
assert fixed.get_json()['data']['title'] == 'Replacing a marker controller'
|
||||
|
||||
Reference in New Issue
Block a user