{"repro_id":"REPRO-2026-00158","version":7,"title":"goshs: PUT upload accepts cross-origin requests without CSRF token","repro_type":"security","status":"published","severity":"medium","description":"The PUT upload handler in `httpserver/updown.go` lacks the CSRF token that\nthe POST upload handler requires. Combined with permissive CORS\n(`Access-Control-Allow-Origin: *` on the OPTIONS preflight), any cross-origin\npage the victim visits can issue an HTTP `PUT` and **write arbitrary files**\nto a goshs server reachable from the victim's browser (loopback, LAN, etc.).\n\nThe fix adds CSRF-token validation to the PUT path so unauthenticated\ncross-origin uploads are rejected.","root_cause":"# RCA Report: CVE-2026-42091 — goshs PUT Upload CSRF\n\n## Summary\n\ngoshs (a Go-based HTTP file server CLI) versions ≤ v2.0.1 are vulnerable to Cross-Site Request Forgery (CSRF) on the PUT upload endpoint. While the POST upload handler enforced CSRF token validation via `checkCSRF()`, the `put()` handler in `httpserver/updown.go` omitted this check entirely. Combined with permissive CORS headers (`Access-Control-Allow-Origin: *`), a malicious cross-origin webpage can silently issue an HTTP PUT and write arbitrary files to any reachable goshs instance.\n\n## Impact\n\n- **Package**: `github.com/patrickhener/goshs`\n- **Affected versions**: `≤ v2.0.1`\n- **Fixed version**: `v2.0.2`\n- **CWE**: CWE-352 (Cross-Site Request Forgery)\n- **Severity**: Medium (CVSS 3.1 base 6.5)\n- **Risk**: Any website visited by a user whose browser can reach a goshs upload server (loopback, LAN, VPN, etc.) can upload arbitrary files without user consent.\n\n## Root Cause\n\nThe `put()` handler in `httpserver/updown.go` did not call `fs.checkCSRF(w, req)` before processing the request. The `checkCSRF()` function (present in `httpserver/handler.go`) validates the `X-CSRF-Token` header, absence of `Origin`/`Referer` (non-browser clients), or same-origin requests. Because `put()` skipped this validation, a browser-initiated cross-origin PUT request—bearing an arbitrary `Origin` header but no valid token—was accepted and the file was written to disk.\n\nThe fix commit [`0e715b94e10c3d1aa552276000f15f104dee2f32`](https://github.com/patrickhener/goshs/commit/0e715b94e10c3d1aa552276000f15f104dee2f32) adds the missing `checkCSRF()` call at the top of `put()`.\n\n## Reproduction Steps\n\nThe reproduction is fully automated by `repro/reproduction_steps.sh`. It performs the following:\n\n1. Clones the `goshs` repository (or reuses an existing clone).\n2. Builds the vulnerable binary at tag `v2.0.1` (`goshs_v2.0.1`).\n3. Builds the fixed binary at tag `v2.0.2` (`goshs_v2.0.2`).\n4. Starts each binary in a separate scratch upload directory with upload enabled.\n5. Sends a cross-origin-style PUT request (`curl -X PUT -H \"Origin: http://evil.com\" --data-binary 'pwned' http://localhost:<port>/pwned.txt`).\n6. Captures the HTTP response status and checks whether `pwned.txt` was created.\n\n**Expected evidence of reproduction:**\n- **v2.0.1 (vulnerable)**: HTTP `200 OK` and `pwned.txt` is created in the upload directory.\n- **v2.0.2 (fixed)**: HTTP `403 Forbidden` and `pwned.txt` is **not** created.\n\n## Evidence\n\nAll logs are written to `logs/`:\n\n- `logs/vuln.log` — goshs v2.0.1 server output.\n- `logs/vuln_result.json` — captured HTTP status (`200`), response body, and `file_created: true`.\n- `logs/fixed.log` — goshs v2.0.2 server output.\n- `logs/fixed_result.json` — captured HTTP status (`403`), response body (`Forbidden`), and `file_created: false`.\n\nExcerpt from `logs/vuln_result.json`:\n```json\n{\n  \"version\": \"vuln\",\n  \"http_status\": \"200\",\n  \"response_body\": \"\",\n  \"file_created\": true,\n  \"upload_dir\": \".../logs/upload_vuln\"\n}\n```\n\nExcerpt from `logs/fixed_result.json`:\n```json\n{\n  \"version\": \"fixed\",\n  \"http_status\": \"403\",\n  \"response_body\": \"Forbidden\",\n  \"file_created\": false,\n  \"upload_dir\": \".../logs/upload_fixed\"\n}\n```\n\nEnvironment:\n- Go version used for build: `go1.24.7` (v2.0.1) / `go1.25.0` (v2.0.2)\n- OS: Linux (containerized)\n- Network: localhost (`127.0.0.1`)\n\n## Recommendations / Next Steps\n\n1. **Immediate fix**: Upgrade to `goshs v2.0.2` or later, which enforces `checkCSRF()` on every mutating handler (PUT, POST, DELETE).\n2. **Defense in depth**: If running goshs on a publicly reachable interface, pair it with `--basic-auth` (`-b user:pass`). When basic auth is configured, the CORS preflight already fails in browsers, providing an additional layer of protection.\n3. **Regression testing**: Any future change to upload handlers should be accompanied by a test that sends a cross-origin request with a mismatched `Origin` header and asserts `403 Forbidden`.\n\n## Additional Notes\n\n- **Idempotency**: The reproduction script was executed twice consecutively and produced identical results both times.\n- **Edge cases**: The script simulates a browser cross-origin request by explicitly sending an `Origin` header. Without this header, `curl` requests are treated as non-browser clients and are allowed by design (see `checkCSRF` logic). The vulnerability specifically affects browser-based CSRF attacks.\n- **Limitations**: The reproduction does not require a real browser or a second origin; the malicious cross-origin behavior is accurately modeled with `curl` and the `Origin` header.\n","ghsa_id":"GHSA-rhf7-wvw3-vjvm","cve_id":"CVE-2026-42091","cwe_id":"CWE-352 (Cross-Site Request Forgery)","source_url":"https://github.com/patrickhener/goshs","package":{"name":"github.com/patrickhener/goshs","ecosystem":"go","affected_versions":"<= v2.0.1 (commit f5671ea)","fixed_version":"v2.0.2"},"reproduced_at":"2026-05-23T06:58:26.415780+00:00","duration_secs":2117.3758425712585,"tool_calls":171,"turns":152,"handoffs":3,"total_cost_usd":1.6293310100000011,"agent_costs":{"repro":0.21792407000000005,"support":0.020822760000000003,"vuln_variant":1.3905841800000012},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p6":0.21792407000000005},"support":{"accounts/fireworks/models/kimi-k2p6":0.020822760000000003},"vuln_variant":{"accounts/fireworks/models/kimi-k2p6":1.3905841800000012}},"quality":{"idempotent_verified":false,"community_verifications":0},"published_at":"2026-05-23T06:58:28.363670+00:00","retracted":false,"artifacts":[{"path":"repro/rca_report.md","filename":"rca_report.md","size":4614,"category":"analysis"},{"path":"repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":3555,"category":"reproduction_script"},{"path":"vuln_variant/rca_report.md","filename":"rca_report.md","size":8327,"category":"analysis"},{"path":"vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":6356,"category":"reproduction_script"},{"path":"bundle/context.json","filename":"context.json","size":3141,"category":"other"},{"path":"bundle/metadata.json","filename":"metadata.json","size":647,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":3153,"category":"ticket"},{"path":"repro/validation_verdict.json","filename":"validation_verdict.json","size":1026,"category":"other"},{"path":"vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1143,"category":"other"},{"path":"vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":5301,"category":"documentation"},{"path":"vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":2548,"category":"other"},{"path":"vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":1420,"category":"other"},{"path":"vuln_variant/source_identity.json","filename":"source_identity.json","size":736,"category":"other"},{"path":"logs/variant_results.json","filename":"variant_results.json","size":701,"category":"other"},{"path":"logs/latest.pid","filename":"latest.pid","size":5,"category":"other"},{"path":"logs/vuln_result.json","filename":"vuln_result.json","size":180,"category":"other"},{"path":"logs/latest.log","filename":"latest.log","size":1353,"category":"log"},{"path":"logs/vuln.pid","filename":"vuln.pid","size":5,"category":"other"},{"path":"logs/upload_bypass/pwned_bypass.txt","filename":"pwned_bypass.txt","size":12,"category":"other"},{"path":"logs/bypass.log","filename":"bypass.log","size":1101,"category":"log"},{"path":"logs/fixed_result.json","filename":"fixed_result.json","size":192,"category":"other"},{"path":"logs/upload_latest/pwned_latest.txt","filename":"pwned_latest.txt","size":12,"category":"other"},{"path":"logs/upload_vuln/pwned_vuln.txt","filename":"pwned_vuln.txt","size":5,"category":"other"},{"path":"logs/upload_vuln/pwned.txt","filename":"pwned.txt","size":5,"category":"other"},{"path":"logs/bypass.pid","filename":"bypass.pid","size":5,"category":"other"},{"path":"logs/vuln.log","filename":"vuln.log","size":1096,"category":"log"},{"path":"logs/fixed.log","filename":"fixed.log","size":991,"category":"log"},{"path":"logs/fixed.pid","filename":"fixed.pid","size":5,"category":"other"}]}