security: XSS, device IDOR, upload authorization and password-reset type juggling - #899
Open
edwh wants to merge 14 commits into
Open
security: XSS, device IDOR, upload authorization and password-reset type juggling#899edwh wants to merge 14 commits into
edwh wants to merge 14 commits into
Conversation
Group and event descriptions (`free_text`) are Quill-authored HTML that we render
unescaped - the event one on the public /party/view/{id} page - and nothing
sanitised them. A host could store a script payload that fired for every visitor,
including admins, and there is no CSP to blunt it.
Escaping is not an option for a rich-text field, so sanitise with HTMLPurifier
(stevebauman/purify, the package the iFixit fork used) in the model mutator rather
than in the controllers, so every write path - v2 API, web forms, imports, seeders -
is covered by one rule. Network and tag descriptions get the same treatment because
NetworkPage.vue renders them with v-html.
free_text has two independent render paths: the Blade modals, and
GroupDescription/EventDescription -> ReadMore.vue's v-html fed from the API.
Sanitising on write covers both; fixing only the Blade side would not have.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hd9V5FKGKbghx4ztS3rPtF
Blade's @lang() compiles to a bare echo and the translator does not escape its :placeholder replacements, while many of the strings wrap those placeholders in markup. Flash messages have the same shape and are rendered with {!! !!}. The audit-log accordion is the residual half of the previously reported audit-log XSS: line 23 (the modified-values cell) was escaped, but line 8 renders `@lang(...metadata, $audit->getMetadata())`, and two of those values are attacker-controlled - user_name is a display name, and audit_url is the request URL including its query string, since develop resolves it with the stock UrlResolver. That fires for any host, coordinator or admin who reviews a group's history. Escape the values rather than the strings, so the intended markup survives. Also covers profile.no_bio (any user's own display name), the event/group description modal headers, share-stats and invite modals, the navbar network name, and the now_following / delete_succeeded / soft_deleted / you_have_joined flash messages. The same unescaped-placeholder bug exists a second time in the client: the Vue translator in lang-utils.js interpolates with a plain .replace(). Add an escapeHtml helper to the lang mixin and use it in GroupPage.vue, the one place a group name reaches a v-html binding. Dynamic translation *keys* taken from DB rows (skill and role names) echo the key verbatim when there is no translation, so those move to {{ __() }} too - one of them renders on the public registration page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hd9V5FKGKbghx4ztS3rPtF
…t object
Three endpoints had no authorization at all beyond being logged in:
POST /group/image-upload/{id}, /party/image-upload/{id} and /device/image-upload/{id}.
In each controller the delete-image endpoint had an ownership check and the upload
endpoint for the same object did not. The group one is destructive - it removes the
existing image first - so any account could permanently replace any group's photo.
Each guard now mirrors its own controller's delete method.
updateDevicev2 checked userHasEditEventsDevicesPermission against an eventid taken
from the request body, then mutated a device identified by the URL path, without
reconciling the two. Naming your own event let you overwrite and reassign any device
in the database. Authorise against the device's real event instead - which is what
deleteDevicev2 already does - and require rights on the destination as well when an
event change is requested.
createEventv2 only checked that the caller hosts *some* group, with a standing TODO
saying so. Because event visibility is inherited from the group's approved flag, that
let any host attach immediately-public events to any group. Require authority over
the target group, with network coordinators checked separately since theirs comes
from the group's networks rather than users_groups.
getEventv2 had no auth and no approval gate, unlike its sibling
getEventsForGroupv2, so an event on a group still awaiting moderation was fetchable
by id - including its exact lat/lng, which for a pending group may be a home address.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hd9V5FKGKbghx4ztS3rPtF
reset() passed the request value straight into filter_var(), which returns false for
an array. Laravel binds false as integer 0, and MySQL compares a VARCHAR column to 0
numerically - coercing every non-numeric token to 0 - so `recovery[]=1` turned
`where('recovery', ...)` from "match nothing" into "match a real user's live code",
and the page then rendered that user's email. Verified against MySQL:
`SELECT 'a3f5c1' = 0` returns 1 while `SELECT 'a3f5c1' = '0'` returns 0.
Reject anything that is not a non-empty string before the lookup, and drop
filter_var (deprecated since PHP 8.1, and the value is a bound parameter anyway).
Recovery codes were also never cleared after use, so a link stayed valid for the rest
of its 24 hour window - spend it as part of the same update. And changing your
password minted a *fresh* 24 hour recovery token as a side effect, leaving a live
reset code on every account that had ever changed its password; drop that.
The api_token is a bearer credential for the whole API, published to JavaScript in a
five-year cookie, and nothing ever rotated it - so a token stolen via any XSS
outlived every remedy, including a password reset. Rotate it on both password change
and password reset, and mark the cookie Secure when the request arrived over HTTPS
(httpOnly has to stay off; the Vue client reads it).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hd9V5FKGKbghx4ztS3rPtF
- Exports were written into public/ under a name derived from the group or venue and never deleted, so a privileged export (admins, hosts and coordinators see rows from unapproved events) persisted at a guessable URL for anyone to fetch. Build them under storage/app/exports and delete after sending. Also prefix cells starting =, +, - or @ so device free text can't become a spreadsheet formula. - The network logo is the one upload that bypasses FixometerFile's content-sniffed jpg/png/gif allowlist, and NetworkController had no validate() call at all, so a crafted SVG would be stored in the webroot and served same-origin. Validate it. - /test/check-auth was live and unauthenticated, resolving identity from the `authenticated` cookie - a plaintext email, excluded from cookie encryption, with no session or signature check. That is an oracle for whether an email is registered plus that user's internal id. Remove the route and the now-orphaned service. - CalendarEventsController compared the env hash with !=, which compares two numeric strings numerically and would accept variants of a numeric value. - RoleController::edit authorised the role in the route but then edited whatever role a hidden formId field named. It crosses no privilege boundary (the endpoint is admin-only either way), but it should act on the object it checked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hd9V5FKGKbghx4ztS3rPtF
Written before the fixes, so each one fails on the unpatched code:
- PasswordResetHardeningTest: the array-recovery type-juggle (both the takeover and
the email disclosure), single-use codes, no recovery token minted by a password
change, and API token rotation on change and reset.
- StoredXssHardeningTest: descriptions sanitised on create and update, the audit-log
metadata sinks (display name and request URL), profile.no_bio, and the
now_following flash message.
- AuthorizationHardeningTest: the three image-upload endpoints (with positive cases
so the guards aren't simply blocking everyone), device hijack via mismatched
authorization object, cross-group event creation, and the removed debug route.
Note for anyone extending these: asserting assertDontSee('onerror=alert') would
false-pass, because escaping only rewrites < > and quotes and leaves that substring
intact. Assert on the raw tag opening instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hd9V5FKGKbghx4ztS3rPtF
| $user = User::factory()->restarter()->create([ | ||
| 'password' => Hash::make('original'), | ||
| ]); | ||
| $token = 'c1d3e5f7a9b1234c5d6e'; |
| $stolenToken = $user->fresh()->api_token; | ||
| $this->assertNotEmpty($stolenToken); | ||
|
|
||
| $token = 'd2e4f6a8b0c2345d6e7f'; |
ExportTest read the generated CSV off disk, from the copy the controller used to leave in public/. Exports now build under storage/app/exports, so point the test there. deleteFileAfterSend() only fires on Response::send(), which the test kernel never calls, so the file is still present for these assertions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hd9V5FKGKbghx4ztS3rPtF
The Jest mock stands in for the lang mixin, so a component that calls escapeHtml would fail under test with the mock in place. Nothing does yet - GroupPage has no Jest test - but this is the kind of trap that costs someone an afternoon later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hd9V5FKGKbghx4ztS3rPtF
Shipping HTMLPurifier's default allowlist would have silently degraded existing descriptions the next time a host saved one. Checked against a survey of the live data (the anonymised production dump) rather than guessing: ~5,100 links carry target=, ~4,500 rel=, ~7,100 have inline style, ~1,300 have class, and there are div, hr, sub, sup, strike, font and a handful of tables. The default list allows none of those. The same survey found zero <script>, <iframe>, <embed>, <object>, <form>, javascript: URL or on* handler anywhere in groups or events, so nothing legitimate depends on the constructs we care about blocking. So config/purify.php widens elements and attributes to match reality while keeping the parts of HTMLPurifier's model that do the actual security work: on* handlers are never allowed, URIs are restricted to safe schemes, and style is parsed down to an allowlist of CSS properties. `position` is excluded on purpose - absolute positioning in user content is a UI-redressing vector. target= is allowed with HTML.TargetNoopener, which adds rel="noopener" to links that lacked it, so this is a small improvement on what is stored today. Verified by running 60 real descriptions through the sanitiser. What it still removes is empty class="" attributes, Facebook/Draft.js data-* junk, Word and VML remnants (<o:p>, <v:imagedata src="file:///C:/Users/...">), Google Docs id="docs-internal-guid-*" and CSS position. Genuine markup - class="_1mf _1mj", inline font styling, alignment, tables - round-trips. Two tests pin both directions: one asserts the constructs from the live survey survive, the other that script/handlers/javascript:/iframe/form do not. Also fixes three tests that CI caught, all of which were leaning on behaviour this branch closes: - ExportTest gave the Host data set host rights on groups 1 and 2 but not group 3, so it was creating an event on a group it had no relationship with. - OnlineEventsTest posted a groupid from PartyFactory, which creates its own group, so the event went to an unrelated group rather than the one under test. - Fixometer/BasicTest read the export from the webroot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hd9V5FKGKbghx4ztS3rPtF
Laravel's CSRF middleware only covers POST/PUT/PATCH/DELETE, so a destructive action
behind a GET route could be triggered by any page an admin or host happened to load -
an <img src> on a forum post is enough. Moved to POST:
/group/delete/{id} /skills/delete/{id}
/tags/delete/{id} /brands/delete/{id}
/device/image/delete/{iddevices}/{idxref} /group/image/delete/{idgroups}/{id}/{path}
/party/image/delete/{idevents}/{id}/{path}
Callers updated to match: the skills and tags delete buttons become CSRF-protected
forms, the event photo "Remove file" control posts through a form instead of navigating,
GroupActions.vue submits a form rather than setting window.location, and the device image
delete in the Vuex store switches from axios.get to axios.post. That last one was already
sending an X-CSRF-TOKEN header - on a GET, where the middleware ignores it.
Also removed the route for PartyController::deleteimage, which has no such method and so
has never been able to do anything.
Invite-acceptance and join links stay on GET deliberately: they are followed from email,
and they carry single-use hashes rather than acting on an arbitrary id.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hd9V5FKGKbghx4ztS3rPtF
My previous fix to this test was wrong. Giving the Host data set host rights on group3 did get past the new per-group check, but it also gave that user visibility of an unapproved group's devices - and the whole point of the test is that group3's data stays out of the export. It swapped a 403 for a wrong-content failure. group3 is deliberately unapproved and unrelated to $user, so its event is now created while acting as the admin, leaving $user with no relationship to it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hd9V5FKGKbghx4ztS3rPtF
Missed when the destructive routes moved to POST - my grep for callers used a quoting
pattern this file doesn't use ("/group/delete/$id" interpolated rather than
concatenated), so six call sites went unnoticed until CI ran the full suite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hd9V5FKGKbghx4ztS3rPtF
…hoosing a past date Two Playwright failures from the last CI run. Mine: the skills and tags delete buttons were wrapped in their own <form>, nested inside the surrounding edit form. HTML forbids nested forms, so the browser discarded the inner one and the button submitted the *edit* action - the tag was never deleted, the page stayed on the edit form, and the test's assertion about the tags table found no table at all. Use formaction/formmethod on the button instead, which posts the surrounding form (already carrying @csrf) to the delete route with no nesting. Not mine: "Invite volunteers modal opens from Event Actions dropdown" fails on a date boundary. The createEvent helper picks the last day cell of the last calendar row, which for July resolved to the 26th; CI ran on the 30th, so the event was in the past and the component correctly hid the menu item, which is gated on the event being upcoming. The page snapshot from the failure shows the dropdown open with the finished-event items (Request review, Share event stats) instead. Develop last went green on 15 July, when the 26th was still ahead. Nothing in this branch touches dates or the calendar, so this would fail on develop today too. The helper now always steps a month - forward for future events, back for past - so the chosen day cannot straddle today. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hd9V5FKGKbghx4ztS3rPtF
…utton My previous fix traded one bug for another. Using formaction on a submit button avoided the nested <form>, but put a second submit button into the edit form ahead of the save button - and the tag edit test clicks '.btn-create, button[type="submit"]', which resolves in DOM order. So editing a tag deleted it instead of saving it, the retries ate the Playwright step's 10 minute no-output budget, and the run reported a timeout rather than a failure. The delete controls are plain anchors again, marked data-method="post", with a delegated handler in app.js that submits them as a form carrying the CSRF token. No nested form, and no extra submit button competing with save. Applied to the skills and tags delete buttons and the event photo "Remove file" link. Verified by running the full Playwright suite locally: 50 passed in 7.1m, including the two that failed in CI. PHPUnit cannot catch this class of bug - those tests POST straight to the route and never exercise the rendered markup - which is why it took a browser run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hd9V5FKGKbghx4ztS3rPtF
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.




Fixes the findings from an adversarial review of
develop, including the parts of theiFixit disclosure that were never actually closed. Each fix has a test written against the
unpatched code first.
The two that matter most
Unauthenticated account takeover via the password reset (
UserController::reset).filter_var()returnsfalsefor an array; Laravel bindsfalseas integer0; MySQLcompares a
VARCHARcolumn to0numerically, coercing every non-numeric token to 0. SoPOST /user/resetwithrecovery[]=1turned "match nothing" into "match a real user's liverecovery code", set that user's password, and rendered their email back. Confirmed against
MySQL:
SELECT 'a3f5c1' = 0→1,SELECT 'a3f5c1' = '0'→0.Victim selection is constrained —
->first()has noORDER BY, so it hits the lowest-iduser holding a non-null
recovery, not a chosen target — but it is unauthenticated, silent,and pollable with a GET until a token goes live. Worth knowing: registration mints a
recovery token, and so did changing your password, so live codes were routine.
Stored XSS on a public page, three requests from a fresh account. Descriptions
(
free_text) are Quill HTML rendered raw, with no sanitiser and no CSP. Register →POST /api/v2/groups(no role check, and it makes you a host) →POST /api/v2/eventsagainstany approved group (
createEventv2only checked that you host some group — there was aTODO saying so). Event visibility is inherited from the group's
approvedflag, so it wentpublic with no moderation, and
/party/view/{id}needs no login.The disclosure's audit-log XSS (F005) was only half fixed
The reported line — the modified-values cell — was escaped. Line 8 of the same file was not:
it renders
@lang(...metadata, $audit->getMetadata()), and@langcompiles to a bare echowhile the translator does not escape
:placeholdervalues. Two of those values areattacker-controlled:
user_name(any display name) andaudit_url(the request URL withits query string —
developuses the stockUrlResolver;SanitisedUrlResolveronlyexists on
nuxt-client). It fires for any host, coordinator or admin reviewing a group'shistory, which is precisely the reported scenario.
Also fixed
updateDevicev2eventidfrom the request body, then mutated a device from the URL path. Name your own event and overwrite/reassign any device in the database. Now derives the event from the record, asdeleteDevicev2already did.imageUploadapi_tokenhttpOnlyhas to stay off — the Vue client reads it).now_following,delete_succeeded,soft_deleted,you_have_joinedinterpolate names into HTML strings rendered with{!! !!}..replace(). AddedescapeHtmland used it where a group name reachesv-html.getEventv2public/under a guessable name and never deleted, so a privileged export (which includes unapproved events' rows) was left fetchable by anyone. Nowstorage/app/exports, deleted after send. Plus CSV formula-injection prefixing.validate()calls — a crafted SVG would land in the webroot./test/check-authauthenticatedcookie with no session check. Removed, with its orphaned service.CALENDAR_HASH,RoleController!=on a hash; and a guard that authorised the route's role then edited whichever role a hiddenformIdnamed.Implementation notes for review
forms, imports, seeders) is covered once. This matters because
free_texthas twoindependent render paths — the Blade modals and
ReadMore.vue'sv-htmlfed from theAPI — so a Blade-only fix would have left the second one live.
stevebauman/purifyis the package the iFixit fork used, which should ease reconcilingwith their tree. Its default cache is Laravel's cache, not a serializer path, so CI needs
nothing writable.
in the translation strings still renders.
createEventv2checks network coordinators separately, since their authority comes fromthe group's networks rather than
users_groups.Destructive actions moved off GET
Laravel's CSRF middleware only covers POST/PUT/PATCH/DELETE, so a delete behind a GET route
could be triggered by any page an admin loaded.
/group/delete/{id},/skills/delete/{id},/tags/delete/{id},/brands/delete/{id}and the three image deletes are POST now.The delete controls are anchors marked
data-method="post", submitted as CSRF-carryingforms by a delegated handler in
app.js. Two approaches were tried and rejected first, bothcaught by the browser tests: a nested
<form>(browsers discard it, so the button submittedthe edit action) and
formactionon a submit button (it then sat ahead of the save button,and the tag edit test clicks
.btn-create, button[type="submit"], which resolves in DOMorder — so editing a tag deleted it). Worth knowing if anyone adds another one of these.
Invite-acceptance and join links stay GET on purpose: they are followed from email and carry
single-use hashes rather than acting on an arbitrary id. Also removed the route for
PartyController::deleteimage, which has no such method.Sanitiser allowlist is derived from the live data, not the package default
Shipping HTMLPurifier's default allowlist would have silently degraded existing descriptions
the first time each host saved one. A survey of the anonymised production dump found ~5,100
links carrying
target=, ~4,500rel=, ~7,100 inlinestyle, ~1,300class, plusdiv,hr,sub,sup,strike,fontand a few tables — none of which the default permits. Thesame survey found zero
<script>,<iframe>,<embed>,<object>,<form>,javascript:URL oron*handler, so nothing legitimate depends on what we block.config/purify.phpwidens elements and attributes to match, while keeping the parts ofHTMLPurifier that do the security work:
on*handlers are never allowed, URIs are limited tosafe schemes, and
styleis filtered to an allowlist of CSS properties.positionisexcluded deliberately — absolute positioning in user content is a UI-redressing vector.
target=is allowed withHTML.TargetNoopener, so links currently lackingrel="noopener"gain it.
Verified by running 60 real descriptions through it: what still gets removed is empty
class="", Facebook/Draft.jsdata-*junk, Word and VML remnants(
<v:imagedata src="file:///C:/Users/…">), Google Docsid="docs-internal-guid-*"and CSSposition. Genuine markup round-trips.Deliberately not in this PR
browser testing rather than being smuggled into a fix PR.
getGroupv2still returns a group's contactemailwith no approval gate. Confirmed asintended behaviour ("contact this group").
auditsrows may still hold credentials from before$auditExcludelanded.Not reachable through any route, so it's a data-remediation task.
One unrelated fix
tests/Integration/utils.jspicked an event date by clicking the last day cell of the lastcalendar row, which for July resolved to the 26th. Run on the 30th, the event was in the past
and the "Invite volunteers" test failed, since that menu item is gated on the event being
upcoming. Develop last went green on 15 July, when the 26th was still ahead — this fails on
develop today too. The helper now always steps a month, so the chosen day cannot straddle
today.
Verification
PHPUnit 550 green in CI; Jest green in CI; the full Playwright suite run locally, 50 passed in
7.1m. PHPUnit structurally cannot catch the delete-button bugs above — those tests POST
straight to the route and never render the markup — so UI changes here need a browser run.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Hd9V5FKGKbghx4ztS3rPtF