Plane's frontend calls getFileMetaDataForUpload() which uses the file-type
library to sniff MIME from magic bytes. For unsniffable formats (plain text,
.json, .csv, etc.) it returns "" — and that empty string was being threaded
through to S3Storage.generate_presigned_post(), signing the presigned URL
with `Content-Type=""`. Browsers can't reliably send an empty Content-Type
header, so the SigV4 signature never matched and R2 returned 403
SignatureDoesNotMatch. UI showed an opaque upload error.
Two-sided fix:
* apps/api/plane/settings/storage.py — default file_type to
"application/octet-stream" when empty/None. The signed URL now always has
a non-empty Content-Type the browser can match.
* packages/services/src/file/helper.ts — generateFileUploadPayload now
prefers the signed Content-Type from upload_data.fields["Content-Type"]
over file.type. The browser must send EXACTLY the signed value, not its
own MIME guess from extension. Belt-and-suspenders defense alongside the
backend default.
Reproduced empirically against R2 with the new keys 2026-05-01: empty
Content-Type signs, then PUT with `Content-Type: text/plain` returns 403
SignatureDoesNotMatch. With this patch, signing "application/octet-stream"
+ sending it back verbatim returns 200.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructure "What's customized" into three patch groups with full file
inventories:
1. Zitadel OIDC (repurpose GitHub OAuth)
2. Brand label + logo
3. Presigned PUT for uploads (R2/B2 don't implement PostObject)
Each patch group is independently revertable; group 3 references
binarybeachio/docs/features/storage-upload-flow.md for the decision
record + rollback procedure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
== WHY (KEEP THIS — IT'S WHY THE FORK EXISTS) ==
Vanilla Plane's upload flow uses AWS S3 PostObject (presigned POST +
multipart/form-data + signed-policy-document). Cloudflare R2 AND
Backblaze B2 — the two most common self-host S3-compatible backends —
both return HTTP 501 NotImplemented for PostObject. Empirically verified
2026-04-30 against B2 s3.us-west-004.backblazeb2.com from inside Plane's
own prod api container, replicating Plane's exact boto3 call:
PUT against B2: 200 OK
POST against B2: 501 NotImplemented "This API call is not supported."
POST against R2: 501 NotImplemented (failure that started this thread)
The error code is `NotImplemented` (not `SignatureDoesNotMatch` etc),
meaning the server rejects the verb itself — no boto3 config, addressing-
style flag, or signature variant fixes it. Tested both path-style and
virtual-hosted-style URLs against B2; both fail identically for POST.
This patch rewrites the upload flow to use presigned PUT, which is
universally supported (R2, B2, AWS S3 native, MinIO, Wasabi, etc).
== WHAT (FIVE-FILE BACKEND, FIVE-FILE FRONTEND) ==
Backend:
* apps/api/plane/settings/storage.py — S3Storage.generate_presigned_post
now mints a presigned PUT URL via generate_presigned_url(HttpMethod="PUT").
Method name kept for caller compat. Response shape:
{url, method: "PUT", fields: {Content-Type, key}}.
* apps/api/plane/utils/openapi/responses.py — example response updated.
* apps/api/plane/tests/unit/settings/test_storage.py — 2 tests updated to
assert the new boto3 call.
Frontend:
* packages/types/src/file.ts — TFileSignedURLResponse.upload_data adds
optional method?: "PUT" | "POST"; drops AWS POST-form-data fields.
* packages/services/src/file/helper.ts — generateFileUploadPayload now
returns a TFileUploadRequest descriptor (url+method+body+headers) that
dispatches on method. POST branch kept for upstream parity but the
fork backend never emits POST.
* packages/services/src/file/file-upload.service.ts +
apps/web/core/services/file-upload.service.ts — uploadFile signature
changes from (url, FormData, progress?) to (payload, progress?).
* 5 caller sites updated (apps/web/core/services/file.service.ts x3,
issue_attachment.service.ts x1, sites-file.service.ts x1).
== TRADEOFFS ACCEPTED ==
* Lost: signed `content-length-range` enforcement at the storage layer.
Server-side validation in the API view still rejects oversized requests
with 413 before minting the URL, so a determined client could only
over-upload by misreporting size, capped at the bucket's own size limit.
* Different request shape on the wire (PUT with raw binary body vs POST
with multipart form). Externally invisible to users.
== ROLLBACK ==
If this becomes a maintenance nightmare:
git revert <this-commit-sha>
# rebuild + push images, swap compose tags, redeploy
After revert, uploads will only work against backends that implement
PostObject (MinIO, AWS S3 native). R2 and B2 will return 501 again.
== FULL DECISION RECORD ==
binarybeachio repo: docs/features/storage-upload-flow.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small fork tweaks bundled together; none touch upload flow:
* OIDC: pass `prompt=select_account` so Zitadel always shows its account
picker rather than silently passing through an existing session. Override
with OIDC_PROMPT env var.
* Branding: swap "with binarybeach.io" -> "with BinaryBeach.io" and replace
GitHub light/dark logo imports with our brand mark (works on both themes).
* Session: thread the binarybeachio session-lifecycle convention values
(SESSION_COOKIE_AGE, ADMIN_SESSION_COOKIE_AGE, SESSION_SAVE_EVERY_REQUEST)
through docker-compose.bb-local.yml app-env mixin and document the
cross-fork convention link in BINARYBEACHIO.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Patches the plane-backend GitHubOAuthProvider so the /auth/github/*
flow points at our self-hosted Zitadel instance when ZITADEL_DOMAIN
is set, and falls back to vanilla GitHub OAuth when unset (regression-
safe). Touch surface is one backend file plus a cosmetic frontend
label change. Full rationale, configuration steps, refresh procedure,
and AGPL compliance notes in BINARYBEACHIO.md at repo root.
* fix: validate redirects in favicon fetching to prevent SSRF
The previous SSRF fix (GHSA-jcc6-f9v6-f7jw) only validated redirects for
the main page URL but not for the favicon fetch path. An attacker could
craft an HTML page with a favicon link that redirects to a private IP,
bypassing the IP validation and leaking internal network data as base64.
Extract a reusable `safe_get()` function that validates every redirect hop
against private/internal IPs and use it for both page and favicon fetches.
Resolves: GHSA-9fr2-pprw-pp9j
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address PR review feedback for SSRF favicon fix
- Fix off-by-one in redirect limit: only raise RuntimeError when the
response is still a redirect after MAX_REDIRECTS hops, not when the
final response is a successful 200
- Return final URL from safe_get() so favicon href resolution uses the
correct origin after redirects instead of the original URL
- Add unit tests for validate_url_ip and safe_get covering private IP
blocking, redirect-following, and redirect limit enforcement
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restrict role modification in ProjectMemberViewSet.partial_update to
Admins only and enforce that requesters cannot modify or assign roles
equal to or higher than their own. Previously, Guests could demote
Admins by exploiting a missing lower-bound check on role changes.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The bulk update date endpoint fetched issues by ID without filtering
by workspace or project, allowing any authenticated project member to
modify start_date and target_date of issues in any workspace/project
across the entire instance (IDOR - CWE-639).
Scoped the query to include workspace__slug and project_id filters,
consistent with other issue endpoints in the codebase.
Ref: GHSA-4q54-h4x9-m329
* chore(deps): replace dotenvx with dotenv and update dependency overrides
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: sort devDependencies in package.json files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Update brace-expansion override from 2.0.2 to 5.0.5 and add picomatch,
yaml@1, and yaml@2 overrides to pin transitive dependency versions.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fixes#6746
API-driven issue updates (PUT update, PUT create-via-upsert, PATCH) were
missing `model_activity.delay()` calls, so webhooks were never dispatched
for changes made through the API. The web UI paths already include these
calls (e.g. in `post()` at L475), but the `put()` and `partial_update()`
methods only called `issue_activity.delay()`.
This adds `model_activity.delay()` immediately after each existing
`issue_activity.delay()` in these three code paths, using the same
signature as the existing call in `post()`.
Tested on Plane CE v1.2.1 self-hosted: API PATCH triggers
`webhook_send_task` in the Celery worker, confirming webhook delivery.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>