Conversation
Users were already auto-subscribed on page creation but not on edit. Add PageSubscription.get_or_create in page_edit's transaction block, matching the pattern used in page_create. Closes part of #72 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Since all users authenticate via magic link, email is always set. Replace the username fallback with "Unknown" to avoid leaking usernames if email were ever missing. Closes part of #72 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Load highlight.js and its CSS on the form page, then call hljs.highlightElement() on code blocks after preview HTML is injected. Closes part of #72 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Resolve effective visibility for each subdirectory and the current directory in both root_view and directory_detail, then pass the resolved values to the visibility badge component instead of the raw field value (which could be "inherit"). Closes part of #72 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After selecting an existing directory, immediately fetch and show child directories instead of hiding the dropdown. Also add arrow key navigation (up/down/enter) with highlight styling matching the wiki link autocomplete pattern. Closes part of #72 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add /api/dir-inherit/ endpoint that returns resolved inheritance metadata for a directory. When the location picker changes, fetch the new metadata and either update existing inherit-select Alpine components or upgrade plain <select> elements to inherit-select components with proper Alpine directives. Closes part of #72 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove the can_edit_page check that blocked editors from the feedback page, and add a "Propose Change" link in the actions dropdown for authenticated users. Closes part of #72 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Switch dev dependencies from [project.optional-dependencies] to [dependency-groups] so prod builds can use --no-dev. Add pytest-playwright and install Chromium in dev Docker builds. Browser tests verify: no duplicate options in inherit-select dropdowns, dynamic updates when switching directories, plain select upgrade to inherit-select, and arrow key navigation in the location picker. Closes part of #72 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
for more information, see https://pre-commit.ci
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Auto-subscribe: mention that editing also auto-subscribes - Actions menu: add "Propose Change" to the list - Proposals guide: update to reflect that all users (including editors) can propose changes via "Propose Change" in Actions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The feedback page is now accessible to editors (for the propose change workflow), so editors submitting a comment get a 302 redirect instead of 404. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code review is billed via overage credits. To resume reviews, an organization admin can raise the monthly limit at claude.ai/admin-settings/claude-code.
Once credits are available, reopen this pull request to trigger a review.
The test was opening the visibility dropdown before _rebuildOptions had finished processing the dir-inherit API response, causing it to see stale options. Wait for the response and verify the button text updated before asserting on dropdown contents. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| '</div>' + | ||
| '<svg class="w-4 h-4 text-gray-400 shrink-0" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">' + | ||
| '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>' + | ||
| '</svg>'; | ||
| wrapper.appendChild(btn); | ||
|
|
||
| // Listbox | ||
| var listbox = document.createElement('div'); | ||
| listbox.id = 'listbox_' + fieldName; | ||
| listbox.setAttribute('role', 'listbox'); | ||
| listbox.style.display = 'none'; | ||
| listbox.className = 'absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden'; | ||
|
|
||
| // Inherit option | ||
| var inheritOpt = document.createElement('div'); |
There was a problem hiding this comment.
🔴 In _upgradeSelectsToInherit (page-form.js:105-107), inheritOpt.innerHTML is built via string concatenation using fieldMeta.source (the directory title from /api/dir-inherit/), which is user-controlled and not HTML-escaped. A malicious directory title injects arbitrary HTML into every page-creation form that picks that directory. The fix is to build the inherit option with createElement/textContent the same way _rebuildOptions in alpine-components.js already does.
Extended reasoning...
What the bug is
In _upgradeSelectsToInherit (page-form.js lines 105-107), the inherit option DOM node is populated using innerHTML with raw string concatenation:
inheritOpt.innerHTML =
'<div class="text-sm font-medium">' + fieldMeta.display + '</div>' +
'<div ...>Provided by ' + fieldMeta.source + '</div>';fieldMeta.source is source.title — the raw title string of the directory that provides the inherited value, returned verbatim by /api/dir-inherit/ (see directory_inherit_meta in directories/views.py: result[field_name] = {'source': source.title, ...}). Directory titles are plain CharField values with no sanitization on save or retrieval.
The specific code path that triggers it
- Attacker creates a directory whose title contains HTML, e.g.
<img src=x onerror=alert(document.cookie)>or<style>body{display:none}</style>. - A victim navigates to
/c/new/, then picks that directory in the location picker. fetchInheritMetafires, fetches/api/dir-inherit/?path=..., and the API returns the malicious title assource._upgradeSelectsToInheritreplaces the plain<select>with a custom dropdown by settinginheritOpt.innerHTML— injecting the attacker-controlled HTML directly into the DOM.
Why existing code doesn't prevent it
fieldMeta.display is safe (it comes from Django TextChoices static string values), but no escaping is applied to fieldMeta.source. The contrast with _rebuildOptions in alpine-components.js (which correctly uses textContent for both inheritDisplay and inheritSource) shows that the risk was understood for the Alpine update path but overlooked for the initial upgrade path in page-form.js.
Impact
The Content-Security-Policy's script-src: 'self' (no unsafe-inline) blocks inline JS event handlers like onerror, which reduces the impact below full JavaScript execution. However: (1) style-src includes 'unsafe-inline', so <style> injection executes and can be used for CSS-based data exfiltration, UI defacement, or phishing overlays; (2) arbitrary HTML injection still enables form injection, redirect via <meta http-equiv=refresh>, and visual deception attacks. Any user with directory creation/edit permission can trigger this for every user who subsequently creates a page in that directory.
Step-by-step proof
- Attacker with directory-edit permission calls
PUT /c/evil-dir/edit-dir/(or creates a new directory) with title:<style>#page-form{display:none}</style>Legit Title. - Victim logs in, navigates to
/c/new/, and typesevilin the location picker. - The
evil-dirsuggestion appears; victim selects it. fetchInheritMeta('/api/dir-inherit/?path=evil-dir')fires.- Backend returns
{"visibility": {"source": "<style>#page-form{display:none}</style>Legit Title", ...}}. _upgradeSelectsToInheritsetsinheritOpt.innerHTML = '...' + '<style>#page-form{display:none}</style>Legit Title' + '...'.- The
<style>block executes, hiding the entire page form. Any attacker-controlled CSS runs.
How to fix
Replace the innerHTML assignment with element construction using textContent, mirroring the pattern already used in _rebuildOptions in alpine-components.js:
var titleEl = document.createElement('div');
titleEl.className = 'text-sm font-medium';
titleEl.textContent = fieldMeta.display;
inheritOpt.appendChild(titleEl);
var sourceEl = document.createElement('div');
sourceEl.className = 'text-xs text-gray-400 dark:text-gray-500';
sourceEl.setAttribute('aria-hidden', 'true');
sourceEl.textContent = 'Provided by ' + fieldMeta.source;
inheritOpt.appendChild(sourceEl);| } | ||
| this.labelMap = map | ||
|
|
||
| var fieldName = this.$el.getAttribute('data-field') | ||
|
|
||
| // Update inherit metadata when the directory changes | ||
| var self = this | ||
| var fieldName = this.$el.getAttribute('data-field') | ||
| if (fieldName) { | ||
| document.addEventListener('dir-inherit-update', function(e) { | ||
| var meta = e.detail[fieldName] |
There was a problem hiding this comment.
🟡 The init() function in the inheritSelect Alpine component declares var fieldName twice on lines 425 and 429, both assigning the same expression (this.$el.getAttribute('data-field')). This is a copy-paste artifact — the first declaration at line 425 is dead code, immediately overwritten by the identical assignment four lines later. No runtime bug occurs due to JavaScript's var hoisting, but removing the first declaration would clarify the code.
Extended reasoning...
What the bug is
In the inheritSelect component's init() function, var fieldName is declared and assigned twice in the same function scope. The pattern is:
var fieldName = this.$el.getAttribute('data-field') // line 425 (dead)
// Update inherit metadata when the directory changes
var self = this
var fieldName = this.$el.getAttribute('data-field') // line 429 (used)
if (fieldName) {
document.addEventListener('dir-inherit-update', ...)
}
The specific code path
The block at line 425 was inserted first (likely when wiring up the label-map logic), then the entire block including its own var fieldName was added immediately after, with var self = this sandwiched in between. The result is two declarations of the same variable with the same value.
Why existing code does not prevent it
JavaScript's var declarations are function-scoped and hoisted to the top of the containing function. A second var fieldName in the same function is perfectly legal and simply re-assigns the already-hoisted variable. No linter warning fires in non-strict mode. Since both assignments set identical values, runtime behavior is unaffected.
Impact
There is no runtime bug. The first assignment (line 425) is immediately overwritten by the second (line 429) before any code that uses fieldName runs. A reader of the code is likely to be confused about whether the first fieldName was ever used for something.
How to fix
Remove the first dead declaration and its assignment at line 425. Only the declaration at line 429 should remain.
Step-by-step proof
- JavaScript hoists var fieldName to the top of init().
- Line 425 executes: fieldName = this.$el.getAttribute('data-field'). Value is say visibility.
- Line 428 executes: self = this.
- Line 429 executes: fieldName = this.$el.getAttribute('data-field'). Same value overwrites.
- The if (fieldName) check proceeds. The first assignment at line 425 had zero observable effect.
Addressing the refutation
The refutation correctly notes there is no runtime bug. Filing as nit is appropriate — this is a genuine copy-paste error introduced by this PR that adds confusion without serving any purpose.
| function fetchInheritMeta(dirPath) { | ||
| if (!dirPath) return; | ||
| fetch('/api/dir-inherit/?path=' + encodeURIComponent(dirPath)) | ||
| .then(function(r) { return r.ok ? r.json() : null; }) | ||
| .then(function(meta) { | ||
| if (!meta) return; | ||
| _upgradeSelectsToInherit(meta); | ||
| document.dispatchEvent(new CustomEvent('dir-inherit-update', { detail: meta })); | ||
| }); | ||
| } |
There was a problem hiding this comment.
🟡 fetchInheritMeta has no .catch() handler, so two failure paths produce unhandled promise rejections: a network error causes fetch() to reject directly, and a session expiry causes Django's @login_required to redirect to the login page — fetch follows the 302 to a 200 HTML response where r.ok is true but r.json() throws a SyntaxError. The form continues working with plain select fallbacks in both cases, but a one-line .catch(function(){}) would silence the console errors.
Extended reasoning...
What the bug is and how it manifests
The fetchInheritMeta function in page-form.js (lines 29-38) makes a fetch() call but ends its .then() chain without any .catch(). When the Promise rejects for any reason, the rejection propagates without a handler, producing an unhandled promise rejection logged as a console error in modern browsers.
The two real failure paths
-
Network failure: If the user is on a flaky connection or the server is temporarily unreachable,
fetch()itself rejects with a TypeError. Since there is no.catch(), this becomes an unhandled rejection. -
Session expiry: The endpoint is protected by Django's
@login_requireddecorator. If the session expires while the page is open and the user then selects a directory, Django issues a 302 redirect to the login page.fetch()follows the redirect automatically and receives a 200 OK HTML response. Because the status is 200,r.okistrue, so the code callsr.json()- but the body is HTML.r.json()returns a Promise that rejects with a SyntaxError, which propagates as an unhandled rejection.
Why existing code does not prevent it
The r.ok ? r.json() : null guard only handles non-2xx HTTP status codes (e.g. 404, 500). It does not handle network errors (which reject fetch() before any response is received) or the login-redirect scenario (which returns 200 HTML). Neither failure path is caught.
Impact
Functional impact is low: both failures are silent, _upgradeSelectsToInherit is never called, and the existing plain select elements remain so the form is still submittable. The refutation correctly notes that "can crash the page" is overstated for modern browsers - this is a code-quality issue, not a crash. The UI degrades silently rather than catastrophically.
How to fix it
Append .catch(function() {}) to the chain at the end of fetchInheritMeta.
Step-by-step proof of the session-expiry path
- User opens
/c/new/with a valid session. - Session expires.
- User selects a directory in the location picker, triggering
fetchInheritMeta('staff'). - Browser sends
GET /api/dir-inherit/?path=staffwith the expired session cookie. - Django
@login_requiredissues a 302 to/u/login/?next=.... fetch()follows the redirect automatically; receives 200 OK with Content-Type: text/html.r.okistrue(status 200), so the code callsr.json().r.json()tries to parse HTML as JSON and the parser throws a SyntaxError.- The returned Promise rejects with no
.catch(), so the rejection is unhandled and logged to the browser console.
| function selectDirItem(el) { | ||
| if (el.dataset.path) { | ||
| segments.push({path: el.dataset.path, title: el.dataset.title}); | ||
| renderChips(); locationInput.value = ''; fetchSuggestions(''); | ||
| } else if (el.dataset.new) { | ||
| var parentPath = currentParentPath(); | ||
| var slug = el.dataset.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); | ||
| var newPath = parentPath ? parentPath + '/' + slug : slug; | ||
| segments.push({path: newPath, title: el.dataset.title, isNew: true}); | ||
| renderChips(); locationInput.value = ''; dirDropdown.classList.add('hidden'); locationInput.focus(); | ||
| } | ||
| } | ||
|
|
||
| function fetchSuggestions(query) { | ||
| var parent = currentParentPath(); | ||
| var url = '/api/dir-search/?parent=' + encodeURIComponent(parent); |
There was a problem hiding this comment.
🟣 In fetchSuggestions (page-form.js), directory titles from the API (d.title) and the typed query are concatenated unescaped into innerHTML attribute values, allowing attribute boundary breakout with a double-quote character. A directory title like foo" onclick="alert(1) would inject an event handler; with style-src: unsafe-inline in the CSP, a title like foo"><style>body{display:none}</style> would inject a working style element. This vulnerability pre-dates the PR — the diff shows only hover CSS classes were removed from these lines — but the PR touches the code, making this a good time to fix it by HTML-escaping d.title and query before interpolation.
Extended reasoning...
What the bug is and how it manifests
In fetchSuggestions in page-form.js (lines ~195-210), directory dropdown items are built by concatenating server-supplied and user-supplied strings directly into an HTML string that is assigned to dirDropdown.innerHTML:
html += '<div ... data-title="' + d.title + '">' + d.title + '</div>';
html += '<div ... data-title="' + query + '">Create "' + query + '"</div>';
Neither d.title (from /api/dir-search/ JSON) nor query (from locationInput.value) is HTML-escaped before interpolation.
The specific code path that triggers it
A directory title containing a double-quote character (e.g. foo" data-evil="injected) breaks out of the data-title attribute boundary when assigned to innerHTML. A payload like foo" onclick="alert(1) injects an onclick attribute. The d.path field is safe because it is produced by Django slugify(), emitting only [a-z0-9-] characters.
Why existing code does not prevent it
The CSP blocks inline event handler execution (script-src: self, no unsafe-inline), so onclick= injection cannot execute JavaScript directly. However, style-src includes unsafe-inline, so a title like foo"><style>body{display:none}</style> would inject a fully functional style element when the dropdown renders. HTML structure injection (premature tag closing, anchor injection, etc.) is also possible regardless of CSP.
What the impact would be
This is a stored XSS / HTML injection vector: any user with directory-edit permissions can set a malicious title. When any authenticated user opens the page-creation form and the dropdown renders that directory, the injected markup executes in their browser session. The query self-XSS (user attacking their own session) is lower severity and not a security concern.
How to fix it
HTML-escape special characters before interpolation. A small helper suffices:
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
Replace d.title and query in the html += lines with escapeHtml(d.title) and escapeHtml(query). The data-title attribute values read back via el.dataset.title automatically decode HTML entities, so downstream use in selectDirItem requires no changes.
Step-by-step proof
- An admin creates a directory with title: Staff"><style>* { display: none }</style>
- Any user visits /c/new/ and focuses the location input.
- fetchSuggestions("") fires, calls /api/dir-search/, and receives {"path": "staff", "title": "Staff"><style>..."}.
- The code builds: html += <div ... data-title="Staff"><style>* { display: none }</style>"...
- dirDropdown.innerHTML = html is called — the browser parses the injected style tag.
- Because style-src includes unsafe-inline, the injected style is applied and the page content is hidden.
Pre-existing note: The git diff confirms the PR only changed CSS class names on these exact lines (removing hover:bg-gray-100 dark:hover:bg-gray-700). The injection pattern was identical before and after the PR. Severity is pre_existing.
Use wait_for_function + evaluate to select dropdown items, avoiding race conditions where the dropdown rebuilds between Playwright's element resolution and click. Also check for duplicate options via DOM data-value attributes instead of opening the dropdown (avoids Alpine x-show timing issues). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace innerHTML with createElement/textContent in _upgradeSelectsToInherit to prevent HTML injection via directory titles - Add escapeHtml() and apply to d.title and query in fetchSuggestions innerHTML (pre-existing vulnerability) - Add .catch() to fetchInheritMeta to handle network errors and session expiry gracefully - Remove duplicate var fieldName declaration in inheritSelect init Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fixes
Fixes: #72, which contained about ten things identified during the first week with real users.
Summary
Addresses the demo feedback items from #72:
/api/dir-inherit/endpoint returns resolved inheritance metadata; plain<select>elements upgrade to inherit-select Alpine components when a directory is picked; existing inherit-selects update dynamicallymax-h-[calc(100vh-2rem)] overflow-y-autoto the TOC navThis also pushed it past what it could do with backend-testing only, so this adds Playwright browser testing infrastructure:
[project.optional-dependencies]to[dependency-groups](prod builds use--no-dev)Deployment
This PR should:
skip-deploy(skips everything below)skip-web-deployskip-daemon-deploy🤖 Generated with Claude Code