heydeer
Sign in
Back to HeyDeer

Research / Code review

HeyDeer finds 78% more issues than GitHub Copilot on the same pull requests

We gave eight coding agents the same two GitHub issues in Umami, an open-source analytics app. Every one of the 16 pull requests they opened had issues. HeyDeer and GitHub Copilot reviewed each pull request, and every finding was checked against the code.

HeyDeer team · · Benchmark

Issues found, HeyDeer vs Copilot
119 vs 67
Of the 124 distinct defects either one found
95% vs 54%
Average cost per review
$1.14 vs $2.37

Results

An issue is a finding whose claim holds in the code, counted at its audited severity. A false positive is a finding whose claim is wrong.

Coding agentHeyDeerGitHub Copilot
IssuesFalse positivesIssuesFalse positives
Claude Fable 5.1130 high · 8 medium · 5 low0100 high · 6 medium · 4 low0
GPT-6 Astra131 high · 6 medium · 6 low071 high · 4 medium · 2 low0
Claude Opus 5.5140 high · 5 medium · 9 low070 high · 5 medium · 2 low0
GPT-6.1 Sol130 high · 6 medium · 7 low140 high · 2 medium · 2 low1
Claude Sonnet 5.5160 high · 7 medium · 9 low0110 high · 5 medium · 6 low0
Grok 4.7162 high · 6 medium · 8 low182 high · 4 medium · 2 low1
DeepSeek V4.1 Flash130 high · 5 medium · 8 low070 high · 3 medium · 4 low0
GLM-5.3-Flash210 high · 11 medium · 10 low0130 high · 9 medium · 4 low0
All agents1193 high · 54 medium · 62 low2673 high · 38 medium · 26 low2
Cost per review$1.14$2.37

What only one reviewer found

Counting findings that describe the same defect once, the two reviewers found 124 distinct defects. Both found 61. HeyDeer alone found 57 (16 medium, 41 low); Copilot alone found 6 (6 low).

Setup

Every agent started from Umami v3.4.0 (commit ec0ff50), where we opened two GitHub issues in a private copy of the repository:

  • Issue #1: Expiring and password-protected share links

    Share links currently work forever, and anyone with the URL can view the data. Users want to share a dashboard with a client for a limited time, or protect it with a password. ## Requirements - Every share link (website, board, link, pixel) can have an optional expiry time and an optional password, set at creation and changeable or removable later. - After a link expires, the share page says so and no data is accessible. - A password-protected link asks for the password before showing any data. - When a link expires or its password changes, people who already opened the link lose access too. - Passwords are stored hashed. Owners can see whether a link has a password and when it expires, but never the password itself. Add the settings to the share form and a password prompt to the share page. Existing tests must pass.

  • Issue #2: Exclude IP addresses per website

    Teams want to stop counting their own office or VPN traffic for a specific website. Today the only option is the server-wide `IGNORE_IP` environment variable. ## Requirements - Website settings get an "Excluded IP addresses" list: IPv4 or IPv6 addresses and CIDR ranges, up to 100 entries. - Traffic from those addresses is not recorded for that website: no page views, events, sessions or session recordings. `IGNORE_IP` keeps working. - Changes apply as soon as they are saved, including to visitors already on the site. - Only users who can edit the website's settings can change the list. - Invalid entries are rejected with a clear error. Add the field to the website settings page. Existing tests must pass.

Each agent ran once, under the same conditions, from this prompt, which tells it not to use skills. We filled in the model and harness for each run:

Implement GitHub issues heydeer/bench-umami#1 and heydeer/bench-umami#2 in this
repository, with one pull request per issue. For issue #1, create branch
{model}-issue-1 from `main`; for issue #2, create branch {model}-issue-2 from
`main`. Follow the repository's existing conventions, add or update tests where
they belong, and make sure the existing tests pass. For each issue, commit your
work, push the branch, and open a pull request against `main` in
heydeer/bench-umami that closes the issue. Start each pull request description
with the line "Model: {Model} · Harness: {harness}". Do not use any skills,
including built-in ones.
ModelHarnessVersion
Claude Fable 5.1Claude Code2.42304.2
GPT-6 AstraCodex0.163.0
Claude Opus 5.5Claude Code2.42304.2
GPT-6.1 SolCodex0.163.0
Claude Sonnet 5.5Claude Code2.42304.2
Grok 4.7Cursor3.21.18
DeepSeek V4.1 FlashOpenCode2.0.18
GLM-5.3-FlashOpenCode2.0.18

We don’t publish the code the agents wrote. Run the prompt on the same commit to get your own pull requests; agents won’t write the same code twice.

Reviews

Each pull request got one review from each reviewer, with no custom instructions. HeyDeer reviewed through its review API at its default Balanced feedback scope. GitHub Copilot code review was requested on each pull request at its default Balanced effort.

How findings were judged

We first checked every finding against the code to settle false positives: findings whose claim is wrong, because the problem they describe doesn’t happen. A blind audit then checked every other finding again, without the reviewer’s name or the severity it reported. For each, it judged whether the finding’s main consequence happens, rated its severity on one scale for both reviewers, and marked findings that describe the same defect. The code was built to settle two findings about the browser bundle.

high
Substantially wrong behavior, security failure or data-integrity loss affecting significant use or users.
medium
Meaningful but limited functional, performance or maintenance harm that should be fixed before merging.
low
A small concrete issue, applicable convention violation or readability improvement that need not hold the merge.

No finding was rated critical. To test the audit, a second, independent audit rated Copilot’s findings again: it gave 60 of the 67 the same severity, and the other seven differ by one level.

Cost

The cost per review is the average at pay-as-you-go prices, without plan allowances, as of 2026-10-22. HeyDeer’s 16 reviews used 1,830 credits, at $10 per 1,000 add-on credits. GitHub billed 4,266.66 AI Credits, at $0.01 each, for the day it reviewed these pull requests: 18 reviews, including two repeat reviews of identical commits. GitHub Actions minutes, which Copilot code review also uses in private repositories, are not included.

Limits

  • Two features in one codebase, and one run per agent: a different run produces different code and different findings.
  • We don’t publish the code the agents wrote, so the verdicts can’t be checked line by line. The dataset shows every finding and how it was judged.
  • We build HeyDeer, so we publish every review and verdict behind these numbers.
  • Both reviewers change over time, and so do their prices. These results are from October 2026.

Every review

Each finding as the reviewer posted it, with its verdict and audited severity.

Claude Fable 5.1

Expiring and password-protected share links

HeyDeer · 8 findings

  • medium: Prevent share viewers from obtaining sibling share URLs

    src/app/api/boards/[boardId]/shares/route.ts:27

    A recipient of an expiring share can obtain a permanent sibling URL and retain access after their original link expires. `canViewBoard` accepts the recipient's share token, and this endpoint returns sibling shares including their slugs. Resolving an unprotected sibling then issues a token with that sibling's restrictions. The link and pixel share-list endpoints allow the same path through `canViewLink` and `canViewPixel`, including grants from board tokens. Although enumeration predates this PR, introducing independent expiration and passwords makes it bypass the new access restrictions. The website share-list endpoint already requires authenticated resource access.

    Fix: Require authenticated resource access independent of share-token grants when listing board, link, and pixel shares.

    Issue, medium. Reported as high.

  • medium: Limit public password attempts before synchronous verification

    src/app/api/share/[slug]/route.ts:227

    Anyone with a protected slug can make unlimited password guesses and distinguish failures from successful token issuance. The new public POST handler uses `skipAuth: true`; neither the route nor `parseRequest` records or limits failures. Creation permits one-character passwords, making short passwords enumerable. Each guess also invokes synchronous `bcrypt.compareSync` through `verifySharePassword` and `checkPassword`, blocking the event loop during verification. The existing 2FA verification flow demonstrates an application-level failed-attempt policy, but this endpoint has none.

    Fix: Bound failed unlock attempts before verification and use asynchronous password comparison.

    Issue, medium. Reported as medium.

  • low: Validate password length against bcrypt's byte limit

    src/lib/schema.ts:372

    Changing only the suffix of a long password leaves the previous password usable to unlock the share and obtain a fresh token. This schema accepts 100 characters, while both share-password helpers delegate directly to bcryptjs. Its [documented limit](https://raw.githubusercontent.com/dcodeIO/bcrypt.js/v3.0.3/README.md) is 72 UTF-8 bytes, with longer inputs silently truncated. Multibyte passwords reach that boundary below 100 characters. Token fingerprint rotation does not prevent the previous password from passing a fresh verification.

    Fix: Reject passwords exceeding bcrypt's UTF-8 byte limit consistently or use a hashing scheme that preserves the complete supported input.

    Issue, low. Reported as medium.

  • medium: Discard cached authorization when rejecting a share token

    src/components/hooks/useApi.ts:30

    Rejected tokens are restored from the query cache after this handler clears the app store. Invalidation preserves the previous successful data, and `useShareTokenQuery` copies its token back when it observes the cleared `shareId`. [TanStack Query's error transition](https://raw.githubusercontent.com/TanStack/query/main/packages/query-core/src/query.ts) also preserves data after a failed refetch. For a deleted share, resolution returns 404, which `ShareProvider` does not gate, so the cached share becomes ready again and renders children with the revoked token. For protected shares, the storage effect also prioritizes cached data over `query.error`, writing the rejected token back instead of removing it.

    Fix: Clear cached authorization on rejection and prevent its restoration until share resolution succeeds.

    Issue, medium. Reported as medium.

  • low: Recheck access independently of analytics requests

    src/components/hooks/queries/useShareTokenQuery.ts:12

    An idle share can remain displayed indefinitely after expiration or a password change without showing the access gate. This query has no polling or expiration timer, and the global query configuration disables window-focus refetching. A supported board containing only text has no recurring analytics requests, so nothing triggers the new token-rejection handler. Server-side requests remain protected, but the already-open page never transitions to the expiry notice or password prompt.

    Fix: Schedule expiration handling and periodically revalidate open shares independently of child requests.

    Issue, low. Reported as low.

  • medium: Include the access fragment in generated request contracts

    src/lib/schema.ts:370

    The published creation and update request schemas omit `expiresAt` and `password`, including their null-clearing semantics. The analyzer resolves imported object fragments through `knownFragments`, which lacks `shareAccessParams`; its declaration lookup only collects local variables. Consequently, all five creation contracts and the update contract omit these properties, as do the exported `OperationTypes` request bodies. The convenience client forwards unknown fields, but consumers receive no generated typing or schema documentation for these options.

    Fix: Teach contract generation to resolve `shareAccessParams` and regenerate the affected request schemas and client types.

    Issue, medium. Reported as low.

  • medium: Represent non-expiring shares with a nullable response type

    packages/api-client/src/generated/types.ts:9216

    An ordinary non-expiring share returns `expiresAt: null`, but the generated response declares an unconditional string. The migration leaves existing shares at null, and creation without expiration also preserves null. This mismatch occurs throughout the share response contracts: schema validation rejects valid responses, and consumers of the exported response type can call string methods without a type error and fail at runtime.

    Fix: Preserve expiration nullability in response contract generation and regenerate the affected schemas and client types.

    Issue, medium. Reported as low.

  • low: Preserve an unchanged expiration when editing other fields

    src/lib/share.ts:57

    Editing only a share's name silently shortens an expiration containing seconds. `getShareAccessDefaults` formats the timestamp to minute precision, and both website and simple edit forms always submit it through `getShareAccessPayload`. For an API-created expiration of `14:30:45`, saving an unrelated change replaces it with `14:30:00`, potentially expiring the link immediately during that minute.

    Fix: Omit expiration from updates when unchanged or preserve the original timestamp's precision.

    Issue, low. Reported as low.

GitHub Copilot · 7 findings

  • medium: Rate-limit share password guesses

    src/app/api/share/[slug]/route.ts:228

    This public unlock path has no attempt limit, so anyone who knows a slug can make unrestricted password guesses. Each guess also invokes synchronous bcrypt through `checkPassword`, blocking the server worker during the comparison. Enforce bounded attempts or backoff per share and requester before calling `verifySharePassword`, and return 429 when the limit is reached. The protection should also work in deployments without Redis.

    Issue, medium. Reported as high.

  • low: Include resolver error responses in contracts

    src/app/api/share/[slug]/route.ts:244

    Returning through `resolveShare` hides its error branches from contract generation. The generated GET contract now lists only 200, dropping its previous 404, while POST lists only 200/400. Both operations can also return 401, 404, and 410, which are essential outcomes of the password and expiry flow. Declare these responses explicitly or teach response inference to follow the resolver, then regenerate both contracts and the client.

    Issue, low. Reported as medium.

  • medium: Declare nullable share expiration responses

    src/app/api/share/contract.generated.ts:144

    This response schema declares `expiresAt` only as a date-time string, but non-expiring shares return `expiresAt: null`, including existing shares after migration. The same mismatch appears across the generated share responses, and the API client consequently exposes `expiresAt: string`. Ordinary responses therefore violate the published schema and typed consumers can treat null as a string. Preserve the nullable expiry in response inference or an explicit response schema, then regenerate the affected contracts and client types.

    Issue, medium. Reported as medium.

  • medium: Discard stale share authorization query data

    src/components/hooks/useApi.ts:32

    Invalidating this query keeps its previous successful payload. After the store is cleared, `useShareTokenQuery` can copy that cached token back into the store. If the share was deleted, the refetch returns 404 but retains the old data; `ShareProvider` does not gate that error and renders the cached share again. Reset the share query so its old authorization payload is discarded before the link is resolved again.

    Issue, medium. Reported as medium.

  • low: Handle rejected share tokens despite valid sessions

    src/lib/request.ts:59

    A rejected share token does not always make `checkAuth` return null. If the viewer also has a valid login session, it returns session auth with `shareToken: null`, so this branch is skipped. A signed-in viewer without independent resource access then receives a generic `401 unauthorized` from the analytics route. Since `useApi` re-resolves the link only for `share-token-invalid`, that viewer does not reach the password or expiry gate after revocation. Detect rejected share credentials explicitly on share-context requests, even when session authentication succeeds, while preserving session behavior outside share context.

    Issue, low. Reported as medium.

  • low: Enforce bcrypt's 72-byte password limit

    src/lib/schema.ts:372

    The 100-character limit accepts passwords beyond bcrypt's 72-byte input limit. `hashSharePassword` and `verifySharePassword` delegate directly to bcrypt, so 72 ASCII characters followed by `a` also unlock with the same prefix followed by `b`. Multibyte passwords reach the limit sooner. Reject passwords exceeding 72 UTF-8 bytes, or consistently prehash the entire password before hashing and verification. Keep create/update, unlock, and form validation aligned.

    Issue, low. Reported as medium.

  • medium: Register imported request schema fragment

    src/lib/schema.ts:372

    The generated create/update request schemas omit both `expiresAt` and `password`. The OpenAPI analyzer resolves imported spreads through `knownFragments` in `src/openapi/source-analysis.ts`, but this new fragment is not registered there. As a result, all six create/update operations accept fields that their published contracts and generated client request types do not expose. Register or resolve this fragment in the analyzer, or provide explicit request contracts, then regenerate the affected contracts and client.

    Issue, medium. Reported as medium.

Exclude IP addresses per website

HeyDeer · 5 findings

  • medium: Remove excluded IP addresses from public share responses

    src/app/api/websites/[websiteId]/route.ts:37

    Public analytics viewers can now retrieve configured office, VPN, or personal IP addresses. The public share endpoint issues tokens accepted by `canViewSharedWebsite`, and this GET returns the complete `getWebsite` result. Adding the scalar `excludedIps` field makes that result include the exclusion list automatically, without requiring settings access. The same exposure applies to website IDs authorized through board shares.

    Fix: Exclude `excludedIps` from responses authorized solely through share tokens and adjust the response contract accordingly.

    Issue, medium. Reported as medium.

  • medium: Prevent stale cache fills from undoing exclusion invalidation

    src/app/api/websites/[websiteId]/route.ts:87

    Newly excluded traffic can continue being recorded after a successful save. On a cache miss, `UmamiRedisClient.fetch` reads the website and then unconditionally caches its result. A tracking request can read the old exclusions, pause while this update commits and deletes the key, and subsequently restore the old website. `fetchWebsite` caches that value for 86,400 seconds, so subsequent requests can use outdated exclusions for up to a day.

    Fix: Coordinate website cache fills with settings updates so an older read cannot restore stale exclusions after invalidation.

    Issue, medium. Reported as medium.

  • low: Handle IPv4-mapped IPv6 CIDRs consistently

    src/lib/ip.ts:187

    A saved exclusion such as `::ffff:10.0.0.0/104` never blocks matching clients. Validation accepts the range, but `ipaddr.process(clientIp)` converts mapped clients to IPv4 while `parseCIDR(entry)` retains IPv6, making this family comparison fail. The [library implementation](https://raw.githubusercontent.com/whitequark/ipaddr.js/master/lib/ipaddr.js) confirms this conversion. Header-derived clients are also normalized to IPv4 by `getIpAddress`. This additionally regresses `IGNORE_IP` for mapped `payload.ip` values: the previous matcher used `ipaddr.parse`, preserving the matching IPv6 representation.

    Fix: Normalize mapped CIDR ranges and their prefix lengths consistently with client addresses.

    Issue, low. Reported as medium.

  • low: Reject truncated IPv4 entries instead of silently reinterpreting them

    src/lib/ip.ts:131

    An administrator entering `192.168.1` receives no validation error, but the saved entry excludes `192.168.0.1`. Both the form and API use this validator, and matching subsequently uses the same permissive parser. The [library contract](https://raw.githubusercontent.com/whitequark/ipaddr.js/master/README.md) explicitly accepts fewer-than-four-part, hexadecimal, and octal IPv4 forms. Consequently, a missing octet can silently configure a different exclusion instead of prompting correction. CIDR address portions have the same issue.

    Fix: Require strict four-part decimal IPv4 validation for addresses and CIDR address portions while retaining IPv6 support.

    Issue, low. Reported as low.

  • medium: Avoid repeated database reads for cached tracking visitors

    src/app/api/send/route.ts:132

    With Redis disabled, every cache-token page view or event now adds two sequential database queries. `fetchWebsite` calls `getWebsite`, which performs a website lookup followed by `attachShareIdToWebsite`'s share lookup. Previously, a valid cache token skipped both queries. This adds database latency and read load to every repeat tracking request, including websites with no exclusions.

    Fix: Provide a server-side settings cache with update invalidation so repeat tracking requests avoid database lookups when Redis is disabled.

    Issue, medium. Reported as low.

GitHub Copilot · 3 findings

  • medium: Redact excluded IPs from public shared website responses

    src/app/api/websites/response-schema.ts:39

    The new field exposes configured office/VPN addresses and internal network ranges to public dashboard viewers. `GET /api/websites/{websiteId}` allows share-token access through `canViewSharedWebsite` and returns the full `getWebsite()` result. Anyone with a public share URL can obtain a token from `/api/share/{slug}` and retrieve the list. Redact `excludedIps` for share-only access while retaining it for authorized authenticated users, and add a public-share regression test. Keep the response schema and generated client consistent with the redacted response.

    Issue, medium. Reported as high.

  • medium: Prevent stale replica cache from restoring deleted exclusions

    src/app/api/websites/[websiteId]/route.ts:88

    With both Redis and `DATABASE_REPLICA_URL` enabled, this deletion can keep the previous exclusion list active for another 24 hours. `getWebsite()` reads through the replica-enabled Prisma client, and `fetchWebsite()` caches that result for 86400 seconds. A tracking request after the deletion but before replication catches up can therefore cache the old row and continue recording newly excluded visitors. Refresh from authoritative data and prevent stale replica reads or concurrent cache fills from overwriting the refreshed settings.

    Issue, medium. Reported as medium.

  • low: Preserve IPv6 matching for IPv4-mapped client addresses

    src/lib/ip.ts:189

    IPv4-mapped IPv6 clients fail to match IPv6 CIDRs because `ipaddr.process()` converts the client to IPv4, while `parseCIDR()` retains the IPv6 range. For example, `::ffff:203.0.113.5` no longer matches either `::ffff:203.0.113.0/120` or `::/0`. Both ranges pass validation, so excluded traffic is still recorded; existing `IGNORE_IP` rules also regress. Match IPv6 ranges against the original address while keeping the normalized client for IPv4 ranges, and add regression tests for mapped and broader IPv6 CIDRs.

    Issue, low. Reported as medium.

GPT-6 Astra

Expiring and password-protected share links

HeyDeer · 9 findings

  • medium: Prevent restricted share recipients from discovering sibling links

    src/app/api/boards/[boardId]/shares/route.ts:27

    A recipient can escape a share’s expiry or password revocation when another unrestricted share exists for the same resource. `canViewBoard` accepts the recipient’s share token, and this endpoint returns sibling slugs through `getSharesByEntityId`. The recipient can exchange an unrestricted sibling slug for an independent token before the original access expires. Link and pixel share-list endpoints allow the same disclosure. Website metadata also exposes the latest sibling slug through `getWebsite` → `attachShareIdToWebsite`. These existing disclosures materially undermine the independently restricted links introduced here.

    Fix: Require authenticated resource access for share enumeration and omit alternative share slugs from metadata returned to share-only callers.

    Issue, medium. Reported as high.

  • high: Preserve mounted content during successful share refreshes

    src/app/share/ShareProvider.tsx:148

    Every successful five-second poll replaces the dashboard with `Loading` and then remounts it. The endpoint signs a fresh token, and [jsonwebtoken includes the current issuance timestamp](https://github.com/auth0/node-jsonwebtoken/blob/v9.0.3/sign.js), so successive polls return different strings. On the render receiving that response, the Zustand token still differs; `useShareTokenQuery` updates it only in an effect, which [runs after the commit](https://react.dev/reference/react/useEffect). The dashboard consequently loses descendant state repeatedly—for example, `EventsDataTable` resets its selected view to `all`. The previous hook did not continuously trigger this gate.

    Fix: Synchronize refreshed authorization without unmounting already-authorized content for the same share.

    Issue, high. Reported as medium.

  • medium: Retain unlocked credentials after transient polling failures

    src/components/hooks/queries/useShareTokenQuery.ts:37

    One failed network request or temporary server error discards the only stored unlock token. Subsequent polls explicitly send an empty token, so a protected share returns `share-password-required` even after the service recovers. The viewer must enter the password again although access was never revoked. Unprotected shares can recover on a later poll, so the failure is not permanent for every share. The regression is clearing authorization indiscriminately; retries were already disabled by the application’s existing query defaults.

    Fix: Discard stored authorization only for definitive access rejection and retain it for retryable refresh failures.

    Issue, medium. Reported as low.

  • medium: Bound public share-password attempts

    src/app/api/share/[slug]/route.ts:155

    Anyone knowing a protected slug can submit an unlimited sequence of password guesses and obtain a token when one succeeds. This public endpoint neither records failures nor applies an attempt limit, and the inspected proxy provides no such guard. The accepted policy permits one-character passwords. Each attempt additionally invokes `checkPassword`, which performs synchronous bcrypt verification with cost 10 on the request thread, imposing blocking CPU work before rejection.

    Fix: Apply a bounded attempt policy before verification, including deployments without Redis, and use asynchronous password comparison.

    Issue, medium. Reported as medium.

  • low: Validate password byte length before bcrypt truncates it

    src/app/api/share/route.ts:17

    Accepted multibyte passwords can authenticate identically despite different suffixes. The schemas limit string length to 72 UTF-16 code units, whereas [bcrypt truncates after 72 UTF-8 bytes](https://github.com/dcodeIO/bcrypt.js/blob/v3.0.2/index.js). For example, 36 repetitions of `é` followed by either `A` or `B` pass validation but produce equivalent password checks. Changing between those passwords rotates `accessVersion`, yet someone knowing the old password can immediately unlock the share again. The same validation appears in the update, entity-specific creation, and unlock routes.

    Fix: Reject passwords exceeding 72 UTF-8 bytes consistently in creation, update, and unlock validation.

    Issue, low. Reported as medium.

  • low: Describe owner-facing expiry values as nullable datetimes

    src/app/api/share/contract.generated.ts:164

    Ordinary no-expiry responses contradict the published owner API contracts: `expiresAt` is declared as a required non-null string, while the migration initializes existing rows to null and creation without expiry also returns null. This propagates into the typed client’s create, update, and entity share-list responses. Conversely, request contracts describe expiry as `{}` or null, yielding `expiresAt?: unknown | null` rather than the datetime enforced by the routes. Client consumers therefore receive incorrect guarantees for both reading and setting expiry.

    Fix: Define explicit nullable datetime schemas for owner share requests and responses, then regenerate their contracts and client.

    Issue, low. Reported as medium.

  • medium: Preserve concrete fields in the getShare response type

    packages/api-client/src/generated/types.ts:9461

    The generated `getShare` response loses its previously declared fields and exposes only an unknown-valued index signature plus `hasPassword`. The current route still returns public fields including `id`, `name`, `slug`, and `parameters`. Existing typed-client consumers can no longer use those fields as their documented types—for example, calling a string method on `name` now requires an assertion. The owner GET contract has the same loss of field information after the new `publicShare` wrapper.

    Fix: Provide a concrete public-share response schema for the owner GET route and regenerate its client type.

    Issue, medium. Reported as low.

  • low: Preserve expiry precision when editing unrelated settings

    src/components/share/ShareAccessFields.tsx:19

    Renaming a share can silently shorten its lifetime. `localExpiry` discards seconds and milliseconds, and both edit forms always submit that truncated value through `shareAccessValues`. In a UTC browser, an API-created expiry of `2030-01-01T12:34:59Z` becomes `12:34:00Z` after an unrelated edit. This loses almost a minute and can immediately expire a link saved near its deadline.

    Fix: Preserve the original expiry unless the owner changes the expiry field.

    Issue, low. Reported as low.

  • low: Localize the new share controls and access messages

    src/components/share/ShareAccessFields.tsx:25

    Users selecting another supported locale receive English expiry and password controls because the new labels, removal checkbox, and protection status bypass translation. `ShareProvider` likewise hard-codes its password prompt, submit label, and expiry message. The enclosing share forms consistently use `useMessages`, and `useLocale` loads the selected locale’s messages, so these new strings remain untranslated within otherwise localized screens.

    Fix: Add message keys and use the existing translation mechanism for the new controls and access messages.

    Issue, low. Reported as low.

GitHub Copilot · 5 findings

  • medium: Throttle password guesses on the public unlock endpoint

    src/app/api/share/[slug]/route.ts:159

    The new public unlock endpoint allows unlimited password guesses for a known share URL. Each attempt also runs synchronous bcrypt, occupying the server's event loop. Add throttling before password comparison, keyed by share and requester, and return 429 after excessive attempts. Use shared state for multi-instance deployments and ensure the limit also works when Redis is disabled.

    Issue, medium. Reported as high.

  • low: Validate share passwords against bcrypt's 72-byte limit

    src/lib/share-access.ts:14

    The new schemas limit passwords to 72 characters, but `hashPassword` uses bcrypt, which only considers the first 72 UTF-8 bytes. For example, 24 `界` characters followed by `a` or `b` are accepted as different 25-character passwords, yet their bcrypt inputs have the same effective prefix. Replacing one with the other rotates the token version but still allows the old password to unlock the share. Use a shared 72-byte validation rule for all share-password creation, update, and unlock paths, with matching validation messages and regression tests.

    Issue, low. Reported as high.

  • medium: Prevent share-only users from discovering sibling share URLs

    src/queries/prisma/share.ts:59

    This response still includes sibling shares' `slug` values. The board, link, and pixel share-list routes use `canViewBoard`, `canViewLink`, and `canViewPixel`, which accept share-only tokens. If an indefinite, unprotected sibling share exists, a guest can discover its URL while their restricted share is valid and continue accessing the data after that share expires or its password changes. Require authenticated resource membership for those lists, evaluating permissions without the share token rather than relying on hash redaction.

    Issue, medium. Reported as high.

  • high: Avoid regenerating unchanged share JWTs

    src/app/api/share/[slug]/route.ts:206

    Each five-second poll signs a fresh JWT with a new `iat`, even when the share is unchanged. Before the query effect copies it into the store, `ShareProvider` sees unequal tokens and renders `Loading`, unmounting the analytics subtree and resetting component-local state. Keep the token stable when its claims are unchanged. Omitting the unused signing timestamp avoids these recurring remounts while retaining the live database expiry and access-version checks.

    Issue, high. Reported as medium.

  • medium: Restore public-share response fields and nullable expiry types

    src/app/api/share/id/[shareId]/contract.generated.ts:116

    The regenerated owner-facing share contracts no longer match the responses. `getShare` now types only `hasPassword`, turning previously typed fields such as `id`, `slug`, and `parameters` into `unknown`. The create/list/update contracts also declare `expiresAt` as a non-null string, although unexpiring shares return null. This breaks typed client consumers and causes schema-based validators to reject ordinary responses. Define an explicit reusable public-share response schema that preserves the existing fields and nullable expiry, then regenerate the contracts and API client.

    Issue, medium. Reported as medium.

Exclude IP addresses per website

HeyDeer · 4 findings

  • medium: Keep excluded IP addresses out of public share responses

    src/app/api/websites/[websiteId]/route.ts:34

    Anyone with a public website share link can obtain a share token and call this endpoint with the share headers. `canViewSharedWebsite` permits that request, while `getWebsite` fetches every scalar field and `attachShareIdToWebsite` preserves them. Adding `excludedIps` therefore exposes the configured office, VPN, or individual addresses to anonymous analytics viewers. The merge-base website record contained no exclusion list.

    Fix: Omit `excludedIps` from public share responses and expose it only through an appropriately authorized settings response.

    Issue, medium. Reported as medium.

  • low: Apply exclusions to the actual persistence target

    src/app/api/send/route.ts:158

    An excluded caller can bypass the website exclusion by sending the website UUID as `payload.pixel` or `payload.link`. The request schema accepts these alternatives without checking their entity type, so `websiteId` is absent and this guard skips the lookup. Downstream, `sourceId` still contains the website UUID and reaches `createSession` and `saveEvent`. A `performance` request using `payload.pixel`, for example, stores ordinary performance measurements for that website, and `getPerformanceStats` includes them. The new exclusion rule therefore does not cover every accepted input that writes to the website.

    Fix: Apply exclusions to the resolved persistence target, or reject pixel and link inputs that identify a website, before writing data.

    Issue, low. Reported as medium.

  • medium: Avoid an uncached primary-database read on every collection request

    src/lib/website-ip-exclusions.ts:7

    Every eligible website collection request now awaits `getWebsiteExcludedIps`, which explicitly reads the primary database, including websites with no exclusions. Previously, valid tracker tokens skipped the website lookup and other requests could use the Redis-cached `fetchWebsite` path. This adds a serial primary-database round trip to every pageview and event and bypasses configured read replicas. In ClickHouse deployments, cached-session collection now also fails with HTTP 500 before persistence when that primary read fails, although its event writes otherwise use ClickHouse or Kafka. Recording requests incur the additional lookup as well.

    Fix: Use shared exclusion caching with synchronous invalidation on settings updates while preserving immediate enforcement across instances.

    Issue, medium. Reported as medium.

  • low: Route the new settings text through localization

    src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx:56

    The new label, placeholder, and validation errors remain English when a user selects another supported language. The surrounding fields resolve their text through `useMessages`, but this field uses literal strings and returns the English messages from `excludedIpsSchema` directly. This bypasses the existing settings form's translation convention.

    Fix: Add translation keys for the label, placeholder, and validation errors and resolve them through the existing message system.

    Issue, low. Reported as low.

GitHub Copilot · 2 findings

  • low: Check exclusions against sourceId used for persistence

    src/app/api/send/route.ts:158

    The exclusion check uses `websiteId`, but session and event writes use `sourceId`. A request with the excluded website's UUID in `payload.pixel` or `payload.link` passes the exactly-one-source validation, skips this check, and can still persist sessions and events under that website's ID. Check exclusions against `sourceId` so the check covers the same ID used for persistence. Add regression cases for both alternate source fields.

    Issue, low. Reported as high.

  • medium: Hide excluded IPs from shared website responses

    src/app/api/websites/response-schema.ts:38

    The new field exposes configured office/VPN addresses and network ranges to shared-dashboard viewers. `getWebsite()` selects all Website fields, and `GET /api/websites/[websiteId]` returns that object after `canViewSharedWebsite()` accepts a share token. Anyone holding a dashboard share link can obtain that token through `/api/share/[slug]` without logging in. Omit `excludedIps` from shared responses while keeping it available to authorized settings users, update the response schema and generated client types, and add a shared-response regression test.

    Issue, medium. Reported as high.

Claude Opus 5.5

Expiring and password-protected share links

HeyDeer · 9 findings

  • medium: Prevent restricted viewers from discovering alternative share links

    src/app/api/boards/[boardId]/shares/route.ts:27

    A viewer with a valid board-share token can list every share for that board because `canViewBoard` accepts the token. `getSharesByEntityId` returns the other shares' slugs, and `formatShare` preserves them. If another share is unprotected and nonexpiring, the viewer can exchange its slug for a token that survives the original link's expiry or password change. The link and pixel share-list endpoints allow the same path. The website endpoint already requires authenticated resource access.

    Fix: Require authenticated resource access when listing board, link, and pixel shares.

    Issue, medium. Reported as high.

  • medium: Rate-limit anonymous share-password attempts

    src/app/api/share/[slug]/route.ts:263

    Anyone possessing a protected share's slug can repeatedly submit password guesses without an attempt limit. This endpoint skips authentication, and neither `parseRequest` nor the supplied proxy applies throttling. Creation and editing accept passwords as short as one character. Each attempt also calls `bcrypt.compareSync` through `checkSharePassword` and `checkPassword`, synchronously occupying the event loop during comparison, consistent with the [bcryptjs API](https://github.com/dcodeIO/bcrypt.js/blob/v3.0.3/README.md#api).

    Fix: Bound failed unlock attempts before password verification and avoid synchronous bcrypt in the request handler.

    Issue, medium. Reported as medium.

  • low: Refresh authorization claims during share revalidation

    src/app/api/share/[slug]/route.ts:187

    After an owner enables a website section, revalidation returns fresh `parameters` but reuses the old token. The navigation exposes the section, while its API requests fail because `canViewWebsiteSection` reads the token's stale parameters. Newly added board resources have the same mismatch. Removed permissions likewise remain in the returned token across rechecks. `parseShareToken` checks the password fingerprint and expiry, but does not refresh these claims. Previously, resolving the slug issued a token containing the current configuration.

    Fix: Issue refreshed authorization claims when the computed share configuration changes.

    Issue, low. Reported as medium.

  • low: Represent nonexpiring shares with nullable response schemas

    src/app/api/share/contract.generated.ts:167

    Creating a share without expiration stores `expiresAt: null`, which `formatShare` preserves. This response schema instead requires a date-time string. The management GET, update, and entity-list contracts make the same guarantee, and the generated client declares `expiresAt: string`. Ordinary nonexpiring shares therefore violate the published response schema and give client consumers an incorrect non-null guarantee.

    Fix: Preserve expiration nullability in response-contract generation and regenerate the affected contracts and client types.

    Issue, low. Reported as medium.

  • low: Validate password length in UTF-8 bytes

    src/lib/schema.ts:116

    `max(72)` limits string length rather than UTF-8 bytes, while [bcryptjs silently truncates passwords after 72 bytes](https://github.com/dcodeIO/bcrypt.js/blob/v3.0.3/README.md#security-considerations). For example, 24 copies of `界` followed by `A` pass this validation, but the suffix is ignored and the 24-character prefix alone unlocks the share. Both the management forms and unlock schema accept these inputs, so the full entered password is not enforced.

    Fix: Reject passwords exceeding 72 UTF-8 bytes consistently in share-management and unlock validation.

    Issue, low. Reported as low.

  • low: Preserve denied access across subsequent refresh failures

    src/app/share/ShareProvider.tsx:93

    After an open page receives `share-expired` or `share-password-required`, its successful share data and stored token remain cached. A subsequent HTTP 500 or network failure replaces the query error while retaining that data, as shown by [TanStack Query's error reducer](https://github.com/TanStack/query/blob/%40tanstack/query-core%405.102.8/packages/query-core/src/query.ts#L632-L646). Neither gate then matches, `isShareReady` remains true, and previously loaded content becomes visible again. A failed expiry-time refresh also leaves cached content visible without checking the known expiration locally.

    Fix: Preserve denied access until successful validation and enforce known expiration independently of request error codes.

    Issue, low. Reported as low.

  • low: Cancel outstanding access checks before committing an unlock

    src/components/hooks/queries/useShareTokenQuery.ts:54

    Periodic GET requests continue while the password form is displayed. If one starts without a valid token and finishes after a successful unlock, its password-required error replaces the successful query state and returns the viewer to the prompt. [TanStack Query's `setQueryData`](https://github.com/TanStack/query/blob/%40tanstack/query-core%405.102.8/packages/query-core/src/queryClient.ts#L166-L198) updates cached data without cancelling the outstanding fetch. The page recovers on another successful validation, but the correct password appears to have failed.

    Fix: Cancel outstanding queries for this share before committing the successful unlock result.

    Issue, low. Reported as low.

  • low: Preserve expiration precision during unrelated edits

    src/components/share/ShareAccessFields.tsx:19

    The API accepts expiration timestamps with seconds and fractional seconds, but these defaults truncate them to minutes. Both edit forms then unconditionally send the reconstructed timestamp through `getShareAccessPayload`. Changing only a share's name or password can therefore silently shorten its lifetime: `12:00:45Z` becomes `12:00:00Z` in a UTC browser.

    Fix: Omit `expiresAt` from edit requests unless the expiration control was changed.

    Issue, low. Reported as low.

  • low: Apply the missing-share guard to deletion too

    src/app/api/share/id/[shareId]/route.ts:80

    Deleting an already-deleted or nonexistent share still throws when evaluating `share.entityId`, producing a server error instead of a not-found response. The new GET guard fixes the same nullable lookup in this resource route, but leaves DELETE unchecked.

    Fix: Return `notFound()` before checking deletion permissions when the share lookup returns null.

    Issue, low. Reported as low.

GitHub Copilot · 4 findings

  • medium: Prevent share tokens from authorizing sibling share listings

    src/app/api/boards/[boardId]/shares/route.ts:37

    The board, link, and pixel share listings accept share-token authorization and return sibling shares' slugs. The website listing also allows this for a logged-in recipient without account access, because `canViewAuthenticatedWebsite` forwards the share token. While their original share is active, a recipient can discover an unprotected, longer-lived sibling share and obtain a token that survives the original link's expiry or password change. Require account-only resource access in all four listing routes: pass `{ user: auth.user }` to the respective view-permission check, as `canViewBoardEntities` already does, rather than allowing the supplied share token to authorize management access.

    Issue, medium. Reported as high.

  • medium: Add rate limiting to the public share unlock endpoint

    src/app/api/share/[slug]/route.ts:263

    This public unlock endpoint has no attempt limit in the route or `parseRequest`. Each guess for an active protected share reaches `bcrypt.compareSync` through `checkSharePassword`, allowing unlimited online guessing and repeated blocking of the server's event loop. Add server-side throttling before password verification, keyed by share and requester, and return 429 when the limit is exceeded. The protection should also work in supported deployments without Redis.

    Issue, medium. Reported as high.

  • low: Validate passwords by UTF-8 byte length for bcrypt compatibility

    src/lib/schema.ts:116

    `.max(72)` limits string length, not bcrypt's 72-byte UTF-8 input limit. For example, 24 copies of `界` followed by `A` pass validation but occupy 73 bytes; bcrypt discards the suffix, so the same prefix followed by `B` also unlocks the share. The unlock schema in `src/app/api/share/[slug]/route.ts:242` has the same problem. Enforce `new TextEncoder().encode(value).length <= 72` in both creation/update and unlock validation, ideally through a shared validator, and add multibyte-password tests so every accepted character participates in authentication.

    Issue, low. Reported as high.

  • low: Refresh stale authorization claims after shared resource changes

    src/app/api/share/[slug]/route.ts:190

    A valid password fingerprint does not mean the token's authorization claims are current. After an owner adds a website to a shared board, this response returns the new `websiteIds` but reuses a token containing only the old IDs, so the added website's analytics requests fail. Enabling a website section similarly leaves stale `parameters` in the token checked by `canViewWebsiteSection`. Compare the freshly computed resource and parameter claims before reusing a token; issue and install a replacement when they differ. Ensure subsequent client requests use that replacement rather than a token captured by an existing API callback.

    Issue, low. Reported as medium.

Exclude IP addresses per website

HeyDeer · 5 findings

  • medium: Check website access independently before exposing excluded addresses

    src/app/api/websites/[websiteId]/route.ts:37

    A share viewer signed into an unrelated account can retrieve the owner's excluded addresses. `checkAuth` preserves both the authenticated user and share token, and `canViewSharedWebsite` delegates to `canViewWebsite`, which accepts the share token before checking ownership or team membership. This condition then skips redaction solely because `auth.user` exists, exposing addresses that the share-viewer restriction otherwise hides.

    Fix: Require website access independent of the share token before returning `excludedIps`.

    Issue, medium. Reported as medium.

  • medium: Normalize mapped IPv6 CIDR ranges consistently with client addresses

    src/lib/excluded-ips.ts:74

    Saving `::ffff:203.0.113.0/120` does not exclude matching clients. `parseAddress` converts a mapped client such as `::ffff:203.0.113.7` to IPv4, but `parseCIDR` preserves the IPv6 network, so the family comparison always fails. The upstream [parser implementation](https://github.com/whitequark/ipaddr.js/blob/main/lib/ipaddr.js) confirms that representation. This also regresses `IGNORE_IP` for supported `/api/send` requests supplying a mapped `payload.ip`: the merge-base implementation parsed both client and range as IPv6 and matched them.

    Fix: Compare mapped clients and CIDR networks in a consistent representation while preserving their prefix semantics.

    Issue, medium. Reported as medium.

  • medium: Prevent in-flight cache fills from restoring outdated exclusions

    src/queries/prisma/website.ts:204

    A successful settings save can leave tracking using the previous exclusions for 24 hours. On a cache miss, `redis.client.fetch` awaits the database query and then unconditionally writes its result. A tracking request can read the old settings, the update can commit and delete both keys, and the earlier request can subsequently restore an old value with the 86,400-second TTL. Later requests then continue recording newly excluded traffic or dropping traffic whose exclusion was removed. Both website cache paths permit this interleaving.

    Fix: Coordinate cache fills and invalidation so reads started before an update cannot repopulate outdated exclusion settings afterward.

    Issue, medium. Reported as medium.

  • low: Cached tracking requests now query Postgres for every event without Redis

    src/app/api/send/route.ts:133

    Deployments without Redis now incur an additional synchronous Postgres query on every website tracking request carrying a session cache token, including websites with no exclusions. The tracker retains and sends that token after the first successful request. Previously this path skipped the website lookup; now `fetchWebsiteExcludedIps` calls `getWebsiteExcludedIps`, which performs a `website.findUnique` without caching when Redis is disabled. This adds a database round trip to each cached-session event and increases database work independently of whether the feature is used.

    Fix: Provide an efficient cache with update invalidation for exclusion settings on deployments without Redis.

    Issue, low. Reported as medium.

  • low: Excluded IP entries have no length limit, but the column is VARCHAR(50)

    src/app/api/websites/request-schema.ts:21

    Both UI and API validation accept scoped IPv6 entries longer than the new database column allows. For example, `fe80:0000:0000:0000:0000:0000:0000:0001%enx001122334455` is 55 characters. The upstream [IPv6 parser](https://github.com/whitequark/ipaddr.js/blob/main/lib/ipaddr.js) accepts zone identifiers without a length cap, and `isValidIpEntry` adds no length restriction. Saving this entry therefore reaches the `VARCHAR(50)[]` column and fails with a server error instead of a validation error.

    Fix: Enforce the storage length limit in the shared entry validator or normalize accepted entries before storing them.

    Issue, low. Reported as low.

GitHub Copilot · 3 findings

  • medium: Redact exclusions from unrelated signed-in share viewers

    src/app/api/websites/[websiteId]/route.ts:37

    A share-link viewer signed in to an unrelated account can retrieve the owner's excluded IP addresses. `checkAuth` retains both user and share credentials, and `canViewSharedWebsite` delegates to `canViewWebsite`, which accepts the share token before checking ownership or team membership. The request passes authorization but skips this redaction because `auth.user` exists. Return the list only when the user has access independently of the share token, and add a test for a signed-in share viewer.

    Issue, medium. Reported as medium.

  • medium: Match mapped IPv6 client addresses against IPv6 CIDRs

    src/lib/excluded-ips.ts:77

    Accepted mapped IPv6 CIDRs cannot match their mapped client addresses. `parseAddress` converts the client to IPv4, but `parseCIDR` leaves the range as IPv6, so `::ffff:10.0.0.0/104` fails to exclude even `::ffff:10.1.2.3`. This affects both website exclusions and server-wide `IGNORE_IP`. Preserve the original client address for IPv6 range comparisons while retaining normalized IPv4 matching for IPv4 ranges. Add regression cases for mapped CIDRs, including `/128`.

    Issue, medium. Reported as medium.

  • medium: Prevent stale cache fills from restoring deleted exclusions

    src/queries/prisma/website.ts:207

    An in-flight tracking request can restore old exclusions after these deletions. On a cache miss, `redis.client.fetch` reads the database and then sets the cache without coordinating with updates. A request can read the old settings, the update can commit and delete both keys, and the request can then cache its old result for 24 hours. Subsequent requests can therefore keep recording newly excluded traffic after a successful save. Coordinate cache fills and updates with a generation/version check, or read exclusions uncached in both tracking lookup paths. Add a regression test for this ordering.

    Issue, medium. Reported as medium.

GPT-6.1 Sol

Expiring and password-protected share links

HeyDeer · 9 findings

  • medium: Prevent restricted viewers from discovering sibling share URLs

    src/app/api/boards/[boardId]/shares/route.ts:27

    A recipient can bypass their share's expiration or password-change revocation when another unrestricted share exists for the same entity. `canViewBoard` accepts the recipient's share token, and this endpoint returns every sibling share's slug. Before their grant expires, the recipient can resolve an unrestricted sibling slug through `/api/share/{slug}` and obtain an independent grant. The link and pixel share-list endpoints have the same authorization behavior. Websites also expose the latest sibling slug through `GET /api/websites/{websiteId}`: `canViewSharedWebsite` accepts share tokens, and `getWebsite` attaches that slug as `shareId`. These existing disclosures undermine the new per-share restrictions.

    Fix: Require account-based authorization for share listings and omit unrelated share slugs from responses accessible through share tokens.

    Issue, medium. Reported as high.

  • medium: Public share password endpoint allows unlimited guessing and runs synchronous bcrypt

    src/app/api/share/[slug]/route.ts:142

    Anyone with a protected share URL can submit unlimited password guesses and distinguish success from failure. This public branch applies no attempt limit, lockout, or delay, and the supplied proxy provides no throttling. Because owners can choose short passwords, the new protection permits unrestricted online dictionary attacks. Each valid-length guess also calls `checkPassword`, which uses `bcrypt.compareSync` with cost-10 hashes. Verification therefore blocks the request thread while computing each comparison, adding avoidable contention for other requests on that process.

    Fix: Throttle unlock attempts per share and client before verification, and use asynchronous password comparison.

    Issue, medium. Reported as medium.

  • medium: A single failed 5-second poll tears down an open shared dashboard

    src/components/hooks/queries/useShareTokenQuery.ts:38

    A transient network failure or server error during any poll clears the active grant and replaces the dashboard with `This share link is unavailable.` The next successful poll restores access and remounts the page, losing component-local viewer state. `useApi` directly exports TanStack's `useQuery`, whose [documented result contract](https://tanstack.com/query/latest/docs/framework/react/reference/functions/useQuery) exposes refetch failures as errors. With polling every five seconds and `retry: false`, this branch treats a single temporary failure as loss of access even when the existing grant remains valid.

    Fix: Allow bounded retries for transient refresh failures before clearing access, while immediately handling definitive revocation and expiry responses.

    Issue, medium. Reported as low.

  • medium: Every open share tab repeats full share resolution every five seconds

    src/components/hooks/queries/useShareTokenQuery.ts:15

    Every open tab now performs 720 full share resolutions per hour, including hidden tabs and shares without restrictions. Each resolution reads the share from the primary database and reloads its entity; unlocked password shares additionally revalidate their grant through another primary read. Board resolution also repeats ownership filtering and entity lookups. Refreshing access serves the intended revocation behavior, but repeating the entire resolution adds database work even when nothing changed. A lightweight access-validation path could preserve that behavior without repeatedly rebuilding unchanged share data.

    Fix: Use lightweight access validation for unchanged grants and avoid full share resolution on every background interval.

    Issue, medium. Reported as low.

  • False positive: Reschedule expiration deadlines beyond the timer limit

    src/components/hooks/queries/useShareTokenQuery.ts:24

    A page opened more than approximately 24.9 days before expiration never installs its deadline timer. This branch returns without scheduling another attempt, and identical polling responses retain their data reference through TanStack Query's default [structural sharing](https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults). Stable tokens make those identical responses the normal unchanged-share path. The effect therefore does not rerun as the deadline approaches. Analytics remain visible after the deadline until a subsequent poll reports expiration, although server-side data requests are rejected.

    Fix: Schedule long deadlines in bounded timer intervals and recalculate the remaining duration after each interval.

    False positive. Claims an expiry more than about 24 days away is never detected because its timer is skipped. The share page also polls the server every 5 seconds, so it shows the expired state within one poll.

  • low: Preserve expiration precision when editing unrelated settings

    src/components/share/ShareAccessFields.tsx:6

    Renaming a share can silently move its expiration earlier. `shareAccessValues` truncates the stored timestamp to the minute, and both edit forms always submit that value through `shareAccessPayload`. For example, an API-created expiration of `2030-01-01T12:00:59Z` becomes `2030-01-01T12:00:00Z` when an owner saves an unrelated change in a UTC browser.

    Fix: Preserve the original expiration timestamp unless the owner changes the expiration field.

    Issue, low. Reported as low.

  • low: Explain and validate the password byte limit in the form

    src/components/share/ShareAccessFields.tsx:33

    Owners can enter passwords that the form accepts but cannot save, without receiving an explanation. The input limits character count, while `shareAccessParams` additionally rejects values exceeding 72 UTF-8 bytes. For example, 25 Chinese characters fit this input but exceed the server limit. The server includes validation details, but `useApi.handleResponse` retains only the generic error message, code, and status. The forms then display the translated `Bad request` message through `getErrorMessage`, hiding the byte-limit explanation.

    Fix: Validate the UTF-8 byte limit in the shared field and display a specific validation message before submission.

    Issue, low. Reported as low.

  • low: Handle rejected password submissions

    src/app/share/ShareProvider.tsx:95

    A failed unlock request leaves the viewer without submission feedback. `unlock` awaits `post`, which rejects on network failures and unsuccessful HTTP responses, but this event handler does not catch the rejection. Neither `setInvalidPassword` nor the password reset executes in that case. The hook's `error` belongs to the separate GET query, so successful polling does not report or repair the failed POST submission.

    Fix: Catch unlock failures and display a retryable submission error distinct from an incorrect password.

    Issue, low. Reported as low.

  • low: Localize the new share-access messages

    src/components/share/ShareAccessFields.tsx:24

    Selecting another supported language leaves the new expiration, password, and removal instructions in English. These hardcoded labels are embedded in share forms whose existing controls use `useMessages`, backed by the selected locale's message catalog. The new protection and expiration text in `SharedSharesTable`, plus password and access-status messages in `ShareProvider`, have the same issue.

    Fix: Add message keys and render the new share-access text through the existing translation mechanism.

    Issue, low. Reported as low.

GitHub Copilot · 3 findings

  • medium: Add bounded password attempt limits without Redis

    src/app/api/share/[slug]/route.ts:148

    The public password endpoint has no attempt limit before `checkPassword`. Anyone who knows a share URL can make unlimited valid-length guesses, and each guess runs synchronous bcrypt on the server's event loop. This permits online password guessing and can degrade service under repeated requests. Add a bounded, expiring per-share/per-client attempt limit before bcrypt and return 429 when exceeded. The protection must also work without Redis.

    Issue, medium. Reported as high.

  • False positive: Schedule expiry timers for deadlines beyond 24 days

    src/components/hooks/queries/useShareTokenQuery.ts:34

    An expiry more than about 24.8 days away gets no deadline timer. React Query structurally shares unchanged polling responses, so `query.data` can retain its identity and this effect does not rerun merely because the deadline approaches. A continuously open page can then keep showing already-loaded analytics past expiry until a poll completes, although the server rejects new analytics requests. Schedule a capped timer and re-arm it until the actual deadline instead of returning here.

    False positive. Claims an expiry more than about 24 days away is never detected because its timer is skipped. The share page also polls the server every 5 seconds, so it shows the expired state within one poll.

  • low: Preserve expiry seconds and milliseconds on unrelated edits

    src/components/share/ShareAccessFields.tsx:25

    Truncating the expiry to minutes silently changes an existing deadline when the owner saves an unrelated edit, because the edit forms always submit `shareAccessPayload`. For example, an API-created expiry of `12:34:56.789Z` round-trips as `12:34:00.000Z` in UTC. This can expire the share nearly a minute early, or immediately if saved during its final minute. Keep seconds and milliseconds in the form value and allow that precision in the input, or omit unchanged expiry values from updates.

    Issue, low. Reported as medium.

Exclude IP addresses per website

HeyDeer · 5 findings

  • medium: Keep excluded IP addresses out of public sharing responses

    src/app/api/websites/[websiteId]/route.ts:37

    Anyone with a website share link can now retrieve its saved IP addresses and CIDR ranges. `/api/share/[slug]` issues a share token, `canViewSharedWebsite()` accepts that token, and this endpoint returns the complete website object. Because `getWebsite()` retrieves all scalar fields, adding `excludedIps` to the model exposes potentially private office, VPN, or personal addresses to public analytics viewers.

    Fix: Omit `excludedIps` from share-authorized responses and align the response schema with that restriction.

    Issue, medium. Reported as medium.

  • low: Apply exclusions to the actual persistence destination

    src/app/api/send/route.ts:161

    An excluded client can still write performance events for the website by supplying its UUID as `payload.pixel` or `payload.link`. The schema accepts these alternatives without checking their entity tables, this guard runs only for `payload.website`, and persistence uses `sourceId = websiteId || pixelId || linkId`. The performance branch passes that identifier to `saveEvent()`, which inserts it directly as `websiteId`; `createSession()` likewise uses the same destination. Thus changing the payload field bypasses the new restriction while writing to the same website.

    Fix: Apply exclusions to the resolved persistence destination or validate alternate entity identifiers before allowing them to bypass the website check.

    Issue, low. Reported as medium.

  • medium: Every tracked website event now adds an uncached primary-database read

    src/app/api/send/route.ts:161

    Every website request reaching this guard now awaits a PostgreSQL primary query, including websites with no exclusions and requests carrying valid session tokens. Previously, those tokens skipped `fetchWebsite()`, and other website lookups could use Redis. `getWebsiteExcludedIps()` explicitly selects the primary and has no cache. `/api/batch` invokes this handler sequentially for up to 500 entries, adding up to 500 primary round trips per batch. This adds collection latency and makes primary read traffic scale with all qualifying website events, including ClickHouse deployments.

    Fix: Cache exclusion settings with update-time invalidation while ensuring existing session tokens still consult current restrictions.

    Issue, medium. Reported as medium.

  • low: Record route queries the website twice per request

    src/app/api/record/route.ts:183

    Recording and heatmap requests reaching this check pay for another website query after `getWebsite(websiteId)` already loaded the complete row at line 149, including `excludedIps`. The second lookup forces a primary round trip solely for that field. Reading the complete website from the primary once would preserve the freshness requirement and remove this redundant query.

    Fix: Fetch the website from the primary once and reuse its `excludedIps` for this check.

    Issue, low. Reported as low.

  • low: New form field uses hardcoded English strings instead of i18n messages

    src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx:58

    Users selecting another supported language still see this setting's label, instructions, and validation errors in English. Neighboring fields use `t(labels.*)` and `t(messages.*)`, but the new field uses literal strings and returns the shared schema's English errors directly, bypassing the existing translation mechanism.

    Fix: Add translation keys for the field text and map validation failures to localized form messages.

    Issue, low. Reported as low.

GitHub Copilot · 2 findings

  • low: Check exclusions against persisted source ID

    src/app/api/send/route.ts:161

    An excluded caller can send the website UUID as `payload.link` or `payload.pixel` instead of `payload.website`. Both pass the request schema, but leave `websiteId` unset and skip this check. The route still uses that UUID as `sourceId` for session and event writes, so excluded traffic is persisted under the website and can affect its statistics without a cache token. Look up exclusions using `sourceId`, which is the ID used for persistence, and add regression cases for both alternate payload keys.

    Issue, low. Reported as high.

  • medium: Redact excluded IPs from public share responses

    prisma/schema.prisma:110

    Adding this field also exposes the exclusion list to public share viewers. `GET /api/websites/[websiteId]` authorizes website and board share tokens through `canViewSharedWebsite`, then returns the complete `getWebsite` record, including `excludedIps`. Anyone with a matching share link can therefore retrieve configured office/VPN or individual IP addresses without a user account. Omit this field from responses authorized only by a share token, retain it for authorized settings access, and align the response schema and generated client types with that redaction.

    Issue, medium. Reported as medium.

Claude Sonnet 5.5

Expiring and password-protected share links

HeyDeer · 11 findings

  • medium: Prevent share viewers from discovering alternative access links

    src/app/api/boards/[boardId]/shares/route.ts:27

    A restricted share recipient can obtain sibling share URLs and bypass their link's expiry or password revocation. `canViewBoard` accepts the recipient's share token, and this endpoint returns every sibling share's slug. If one sibling is unprotected and expires later or never, the recipient can exchange its slug for a separate token before their original access ends. The link and pixel share listings expose the same route. Website responses also disclose the latest sibling slug through `getWebsite` → `attachShareIdToWebsite`. Per-link restrictions require keeping these alternative access credentials out of share-viewer responses.

    Fix: Require authenticated entity permissions for share listings and omit sibling share URLs from responses available to share-token viewers.

    Issue, medium. Reported as high.

  • low: Reject passwords that bcrypt silently truncates

    src/lib/schema.ts:115

    Changing a password's suffix after its first 72 UTF-8 bytes leaves the old password usable to unlock the share. This schema accepts 200 characters, while `getShareAccessData` passes the complete value directly to `bcrypt.hashSync`. Bcrypt silently ignores bytes beyond its limit, as documented in its [security considerations](https://github.com/dcodeIO/bcrypt.js/blob/v3.0.2/README.md#security-considerations). Although the new salted hash invalidates existing tokens, the old password can obtain fresh ones. Multibyte passwords reach the limit with fewer characters.

    Fix: Validate passwords against bcrypt's UTF-8 byte limit or use a consistent hashing scheme that preserves the complete input.

    Issue, low. Reported as medium.

  • medium: Limit unauthenticated password attempts before synchronous verification

    src/app/api/share/[slug]/route.ts:235

    Anyone with a protected share URL can make unlimited password guesses and distinguish success by whether the endpoint returns a token. Neither this handler nor `verifySharePassword` counts or throttles failures, and creation permits short passwords. Each string attempt also calls `checkPassword` → `bcrypt.compareSync`, occupying the request process's JavaScript thread during verification. Repeated requests therefore enable unrestricted online guessing while adding synchronous CPU work.

    Fix: Throttle failed password attempts before verification and use asynchronous password comparison.

    Issue, medium. Reported as medium.

  • low: Define the unlock endpoint's API contract

    src/app/api/share/[slug]/route.ts:221

    Generated clients cannot represent the new password-unlock request correctly. The colocated contract defines only GET, so POST falls back to source inference. `source-analysis.ts` recognizes request schemas through `parseRequest`, but this handler reads `request.json()` directly; consequently, the inferred operation has no password request body. The existing generated client also exposes only `getShareBySlug`. The explicit-contract validator rejects the missing POST contract.

    Fix: Add an explicit unlock contract covering its password body and responses, and regenerate the API client.

    Issue, low. Reported as medium.

  • medium: Board share creation form is missing the new expiry and password fields

    src/app/(main)/boards/[boardId]/BoardShareCreateForm.tsx:35

    Board owners cannot set expiry or password protection when creating a share through the UI. `BoardShareDialog` renders this dedicated form, which submits only name, filtering, and theme settings. They must create an unrestricted link and edit it afterward with `SimpleShareEditForm`. The linked requirements explicitly include access settings at creation for all four share types; updating `SimpleShareCreateForm` leaves this separate implementation uncovered.

    Fix: Add the shared access fields, defaults, and payload conversion to `BoardShareCreateForm`.

    Issue, medium. Reported as low.

  • low: Password form reports "expired" for unrelated failures

    src/app/share/SharePasswordForm.tsx:22

    A network interruption, server error, or deleted share displays “This share link has expired.” The catch block maps every rejection except `share-password-invalid` to that message, even though these failures do not establish expiry. A temporary connection failure therefore tells visitors that a valid link is permanently unusable.

    Fix: Show the expiry message only for `share-expired` and use the normal error presentation for other failures.

    Issue, low. Reported as low.

  • low: Extending expiry leaves already-issued tokens with the old cutoff

    src/app/api/share/[slug]/route.ts:174

    Visitors with an open page lose data access at the original expiry even if the owner extends or removes it beforehand. The JWT embeds the original deadline, so `parseToken` rejects it before the database check can recognize the extension. `useShareTokenQuery` has no renewal timer, window-focus refetching is disabled in `Providers.tsx`, and analytics 401 responses do not renew it. Access resumes after obtaining a fresh token, such as by reloading the page.

    Fix: Renew tokens when their embedded expiry is reached or rely on database expiry validation without fixing the original deadline into the token.

    Issue, low. Reported as low.

  • low: A JSON null body causes an uncontrolled server error

    src/app/api/share/[slug]/route.ts:223

    An unauthenticated request containing literal JSON `null` throws before the handler can return a validation response. `request.json()` successfully resolves to `null`, so its catch handler does not run, and destructuring `password` throws a `TypeError`. This client-input error becomes an uncontrolled server failure.

    Fix: Validate the unlock body with an object schema before accessing its password.

    Issue, low. Reported as low.

  • low: Detect expiry while the share page remains open

    src/app/share/ShareProvider.tsx:98

    A page left open across expiry keeps displaying its dashboard instead of the required expired message. This branch needs a 410 from the share query, but that query has no polling or expiry timer, and window-focus refetching is disabled. A stale-time setting does not schedule periodic requests under [TanStack Query's documented behavior](https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults). Later analytics requests are rejected, but their errors do not update this provider's expiry state.

    Fix: Revalidate access at expiry and propagate the expired state into the share provider.

    Issue, low. Reported as low.

  • low: Allow password entry again after an unlocked token is revoked

    src/app/share/ShareProvider.tsx:93

    After the owner changes a password, an already-unlocked visitor cannot enter the new password without clearing the page's cached state. Even when a reconnect refetch returns `share-password-required`, `!share` remains false because [TanStack Query retains successful data on a later error](https://raw.githubusercontent.com/TanStack/query/main/packages/query-core/src/query.ts). This branch therefore suppresses the password form while analytics requests use the revoked token. A full reload restores the prompt.

    Fix: Clear cached unlock state and show the password form when the current token requires reauthentication.

    Issue, low. Reported as low.

  • low: Preserve an unchanged expiry when saving other settings

    src/components/share/ShareAccessFields.tsx:20

    Editing only a share's name can silently shorten its expiry by nearly a minute. The API accepts timestamps with seconds and fractional seconds, but this conversion drops both. Both edit forms then unconditionally submit the converted timestamp through `getShareAccessPayload`. For example, in UTC, `10:00:59Z` becomes `10:00:00Z`, potentially expiring the share immediately when an unrelated setting is saved.

    Fix: Preserve the original expiry unless the user changes the expiry field.

    Issue, low. Reported as low.

GitHub Copilot · 7 findings

  • medium: Throttle share password attempts before bcrypt verification

    src/app/api/share/[slug]/route.ts:236

    Add attempt throttling before verifying a share password. This anonymous endpoint allows unlimited guesses, and each string attempt reaches `bcrypt.compareSync` through `verifySharePassword`, blocking the server's event loop. Someone with a protected share URL can brute-force its password and consume CPU needed by unrelated requests. Enforce a bounded per-share/client attempt limit before bcrypt and return HTTP 429 when exceeded; ensure the protection also works in deployments without Redis.

    Issue, medium. Reported as high.

  • low: Show expired-link message only for share-expired errors

    src/app/share/SharePasswordForm.tsx:24

    Only show the expired-link message for `share-expired`. This fallback reports expiry for every other failure, including network outages, server errors, and deleted shares. That misleads visitors about whether retrying a valid link can help. Preserve the specific password and expiry messages, and use the existing generic server-error message for other failures.

    Issue, low. Reported as medium.

  • low: Clear cached share data when token authorization is revoked

    src/app/share/ShareProvider.tsx:95

    Cached share data does not establish that its token is still valid. After an owner adds or changes the password, a refetch returns `share-password-required`, but React Query retains the previous `share`, so this condition suppresses the password prompt. Analytics requests then fail with the revoked token, and the visitor cannot enter the new password without reloading. Validate the cached token and clear stale authorization/show the prompt when it is revoked. Simply removing `!share` is not sufficient: the GET endpoint also returns password-required for still-valid unlocked tokens.

    Issue, low. Reported as medium.

  • low: Transition open shares to expired state after deadline

    src/app/share/ShareProvider.tsx:98

    An already-open share does not transition to this expired screen when its deadline passes. `useShareTokenQuery` has no expiry timer or refetch interval, and `Providers.tsx` disables refetch-on-window-focus. The server rejects subsequent analytics requests, but those generic 401 responses do not refetch the share or unmount the cached dashboard. Expose the expiry in the public share response and schedule revalidation or an expired-state transition that clears active share authorization and replaces the dashboard with this message.

    Issue, low. Reported as medium.

  • low: Prioritize password removal over replacement password

    src/components/share/ShareAccessFields.tsx:34

    Give `removePassword` precedence over a populated password. If a user enters a replacement and then selects “Remove password,” retained form state can contain both values. `getShareAccessPayload({ password: 'replacement', removePassword: true })` currently sends the replacement instead of `null`, leaving the share protected despite the explicit removal selection.

    Issue, low. Reported as medium.

  • medium: Add expiry and password fields to board share creation

    src/components/share/SimpleShareCreateForm.tsx:89

    Board creation does not use this form. `src/app/(main)/boards/[boardId]/BoardShareDialog.tsx` renders `BoardShareCreateForm`, whose fields and submit payload still omit expiry and password. Users therefore cannot choose protection when creating a board share; they must create an unrestricted link and edit it afterward, contrary to the requirement for every share type. Add the shared access fields/defaults/payload to the board-specific form, or switch the dialog to `SimpleShareCreateForm`.

    Issue, medium. Reported as medium.

  • low: Reject passwords exceeding bcrypt's 72-byte limit

    src/lib/schema.ts:115

    The 200-character limit exceeds bcrypt's 72-byte input limit. `getShareAccessData` and `verifySharePassword` pass passwords directly to bcrypt, so accepted passwords with the same first 72 UTF-8 bytes are equivalent. Changing only the suffix therefore still allows the old password to unlock the share, even though existing tokens are revoked. Reject passwords exceeding 72 UTF-8 bytes, or consistently prehash the full password before bcrypt when setting and verifying it. Keep validation and the API contracts aligned with the chosen approach.

    Issue, low. Reported as medium.

Exclude IP addresses per website

HeyDeer · 5 findings

  • low: Team website lists expose excluded IPs to view-only members

    src/app/api/teams/[teamId]/websites/route.ts:27

    A `team-view-only` member can retrieve office/VPN addresses through this endpoint. `canViewTeam` accepts any team member, while `getTeamWebsites` retrieves all website scalar fields and returns them without filtering. Adding `excludedIps` therefore exposes it in these responses, even though the new single-website GET explicitly removes it for the same user because `canUpdateWebsite` returns false.

    Fix: Apply the same edit-permission-based filtering to team website list responses.

    Issue, low. Reported as medium.

  • medium: Avoid an extra database lookup on every collection request without Redis

    src/lib/excluded-ips.ts:21

    Without Redis, every website collection request with a client IP that reaches the new check executes another `website.findUnique`, including websites with an empty exclusion list. Previously, requests carrying a valid cache token skipped the website lookup. This adds a sequential PostgreSQL round trip to returning visitors' page views, events, identify calls and performance beacons; `/api/batch` repeats it for each item. The cost applies even when this feature is unused.

    Fix: Avoid repeated database reads for unchanged exclusion lists while preserving immediate invalidation when settings are saved.

    Issue, medium. Reported as medium.

  • medium: IPv4-mapped IPv6 exclusions are accepted but never match

    src/lib/ip.ts:162

    Saving `::ffff:203.0.113.7` succeeds, but traffic from that address is still recorded. `normalizeIp` converts the client to IPv4, while `parseIpEntry` leaves the exclusion as IPv6, so this family comparison rejects it. Mapped CIDRs such as `::ffff:203.0.113.0/120` fail for the same reason in both collection routes. The dependency's [documented API](https://raw.githubusercontent.com/whitequark/ipaddr.js/v2.4.0/README.md) distinguishes ordinary parsing from mapped-address conversion.

    Fix: Normalize mapped addresses consistently on both sides and translate mapped CIDR prefixes appropriately.

    Issue, medium. Reported as medium.

  • medium: Cache invalidation can leave the previous exclusions active for an hour

    src/lib/excluded-ips.ts:18

    A concurrent collection request can restore the old exclusion list after a successful save. `redis.client.fetch` performs GET, database query and SET separately: a cache miss can read the previous row, the settings request can commit and delete the key, and the earlier lookup can then SET its stale result. The default TTL is 3,600 seconds. Subsequent requests can therefore record newly excluded traffic or discard traffic whose exclusion was removed for up to an hour.

    Fix: Coordinate cache population and settings updates so an older lookup cannot repopulate the cache after invalidation.

    Issue, medium. Reported as medium.

  • medium: Read exclusion settings from the primary database

    src/lib/excluded-ips.ts:8

    With a configured database replica, a request immediately after saving can cache the previous exclusions for an hour. `src/lib/prisma.ts` installs the read-replica extension, whose [routing implementation](https://raw.githubusercontent.com/prisma/extension-read-replicas/v0.5.0/src/extension.ts) sends this `findUnique` to the replica. If replication has not caught up after the update and cache deletion, Redis stores that stale result for 3,600 seconds, extending the enforcement delay beyond replication lag. `/api/record` also obtains exclusions through replica-backed `getWebsite`, delaying its enforcement until replication catches up.

    Fix: Use primary-database reads for exclusion enforcement in both collection routes while preserving operation without a replica.

    Issue, medium. Reported as medium.

GitHub Copilot · 4 findings

  • low: Redact excluded IPs for read-only team members

    src/app/api/websites/[websiteId]/route.ts:41

    This redaction can be bypassed through `GET /api/teams/:teamId/websites`. That route checks only `canViewTeam`, and `getTeamWebsites` returns all Website scalar fields, including `excludedIps`. A `team-view-only` member therefore receives the office/VPN ranges even though they cannot edit the website and this endpoint hides them. Apply the same edit-permission-based redaction to the team listing, preferably through shared response filtering, and add a read-only-member regression test.

    Issue, low. Reported as high.

  • medium: Read exclusions from primary to avoid replica staleness

    src/lib/excluded-ips.ts:8

    When `DATABASE_REPLICA_URL` is configured, `prisma.client` uses `readReplicas`, so this `findUnique` can read the previous exclusion list after a successful save. Redis can then retain that list for an hour. `/api/record` has the same consistency problem: it checks `website.excludedIps` from the replica-backed `getWebsite`, allowing recordings and heatmaps until replication catches up. Use a primary-backed exclusion lookup for both ingestion routes, independent of the general website read. This is needed even if the Redis cache-fill race is fixed.

    Issue, medium. Reported as medium.

  • medium: Prevent stale cache fills from restoring excluded IPs

    src/lib/excluded-ips.ts:22

    An in-flight cache fill can undo save-time invalidation. `redis.client.fetch` reads the old database list, the settings POST commits the new list and deletes the key, then the earlier fetch writes the old list back. Subsequent send requests can record newly excluded traffic for the default 3,600-second TTL. Bypassing this cache, as suggested below, removes that race at the cost of a database read per request. If caching is required, coordinate fills and updates with a revision check or locking instead.

    Issue, medium. Reported as medium.

  • medium: Handle IPv4-mapped IPv6 exclusions correctly

    src/lib/ip.ts:168

    IPv4-mapped IPv6 exclusions pass validation but never match. For example, `::ffff:203.0.113.7` fails to exclude the identical client because `normalizeIp` converts only the client to IPv4; the saved entry stays IPv6 and fails this family check. Mapped CIDRs such as `::ffff:203.0.113.0/120` fail too. Compare IPv4 clients in mapped form when the exclusion is mapped IPv6, preserving the CIDR prefix, and add exact-address and CIDR regression tests.

    Issue, medium. Reported as medium.

Grok 4.7

Expiring and password-protected share links

HeyDeer · 10 findings

  • False positive: Separate server cryptography from browser share helpers

    src/lib/share.ts:4

    This import introduces a Node-only dependency into the browser compilation. `ShareProvider`, `SharePage`, and dashboard controls import `share.ts`, which now imports `crypto.ts` and its `node:crypto` dependency. There is no browser replacement configured in `next.config.ts`, so the application cannot compile this client dependency graph. Next.js documents this pattern as a [module-resolution failure](https://nextjs.org/docs/messages/module-not-found).

    Fix: Move password hashing and fingerprint generation into a server-only module and update their server callers.

    False positive. Claims that importing node:crypto into client code breaks the build. next build compiles it, replacing node:crypto with Next.js's crypto-browserify polyfill.

  • medium: Prevent share visitors from discovering alternate access links

    src/app/api/boards/[boardId]/shares/route.ts:33

    A visitor with a temporary share can obtain a permanent sibling URL before their access expires. `canViewBoard` accepts the visitor's share token, and this handler returns every share's slug through `getSharesByEntityId`. If a passwordless, non-expiring sibling exists, the visitor can use it after the original share expires or its password changes. The link and pixel share-list endpoints have the same authorization pattern. Introducing per-link restrictions makes this existing disclosure a bypass of the new access controls.

    Fix: Authorize board, link, and pixel share listing using the authenticated user's entity permissions, excluding share-token grants.

    Issue, medium. Reported as high.

  • high: Keep the dashboard mounted during periodic token refreshes

    src/components/hooks/queries/useShareTokenQuery.ts:12

    Passwordless dashboards lose local component state every five seconds. Each poll calls `buildUnlockedShare`, which signs another token; `jsonwebtoken` includes the current issuance timestamp by default, so successive responses contain different tokens. See its [signing contract](https://github.com/auth0/node-jsonwebtoken/blob/v9.0.3/README.md). Before the effects copy the new token into the store, `ShareProvider` sees `isShareReady === false` and replaces its children with `Loading`. This unmounts the dashboard, resetting state such as `EventsDataTable.view` and open dialogs. The previous query did not continuously trigger this transition.

    Fix: Refresh access metadata and credentials without unmounting an already-authorized dashboard when only its token changes.

    Issue, high. Reported as medium.

  • medium: Limit repeated share-password guesses

    src/app/api/share/[slug]/route.ts:261

    Anyone holding a protected URL can submit password guesses without an attempt limit or cooldown and receive a token when one matches. This public handler, `parseRequest`, and the repository proxy contain no throttling or failed-attempt tracking; the supplied Docker deployment exposes the application directly. Password creation also permits short passwords. Every guess additionally invokes synchronous `bcrypt.compareSync`, occupying the request process during verification.

    Fix: Enforce per-share and per-client attempt limits before password verification and use asynchronous comparison.

    Issue, medium. Reported as medium.

  • low: Reject passwords beyond bcrypt's effective input length

    src/lib/share.ts:30

    Changing a long password only after its first 72 UTF-8 bytes leaves the previous password valid for unlocking the share. This schema accepts 200 characters, while `hashPassword` and `checkPassword` pass the input directly to bcrypt. The locked bcryptjs implementation [truncates inputs beyond 72 bytes without rejecting them](https://raw.githubusercontent.com/dcodeIO/bcrypt.js/v3.0.3/README.md). The new stored fingerprint invalidates existing tokens, but submitting the previous password still obtains a fresh token. Multibyte passwords reach this boundary with fewer characters.

    Fix: Reject share passwords whose UTF-8 encoding exceeds bcrypt's 72-byte limit.

    Issue, low. Reported as medium.

  • medium: Use a fixed clock for the unlocked-share assertion

    src/lib/share.test.ts:167

    This assertion becomes permanently false on October 21, 2026. Its fixture expires at `2026-10-21T00:00:00.000Z`, but `resolveShareView` uses the real clock because no `now` argument is supplied. Neither this test nor the shared setup freezes time. At or after that deadline, the function returns `password` rather than the expected `ready`, producing a date-dependent test failure.

    Fix: Pass an explicit pre-expiry timestamp to the assertion.

    Issue, medium. Reported as medium.

  • low: Normalize passwords consistently when saving and unlocking

    src/app/api/share/[slug]/route.ts:261

    A recipient entering exactly the password the owner supplied can receive `incorrect-password`. Both access-saving helpers trim surrounding whitespace before hashing, and the create/edit form payload also trims it. However, `SharePasswordForm` submits the entered password unchanged and this endpoint compares that raw value. For example, saving `" secret "` protects the link with `"secret"`, then rejects the original value when entered by a visitor.

    Fix: Apply the same password normalization when saving and unlocking.

    Issue, low. Reported as low.

  • low: Refresh expiry metadata for unlocked protected shares

    src/lib/share.ts:211

    Extending or removing a protected share's expiry still forces its current visitor back to the password prompt at the original deadline. Polling returns only `passwordRequired`, `shareId`, and `passwordVersion`, so `unlocked.expiresAt` retains the deadline captured during password entry. This branch checks that stale deadline even though the server accepts the token against the updated expiry. Re-entering the password repairs the view.

    Fix: Return current expiry metadata during polling and use it to refresh the unlocked share's access state.

    Issue, low. Reported as low.

  • low: Preserve unchanged expiry precision when editing a share

    src/lib/share.ts:140

    Editing only a share's name can silently move its expiry earlier. The API accepts timestamps containing seconds and fractional seconds, but this conversion discards both. Both edit forms initialize their expiry field through this helper and unconditionally resubmit its reconstructed value through `toShareAccessPayload`. An expiry ending in `:59` therefore becomes 59 seconds earlier after an unrelated edit.

    Fix: Preserve the original expiry unless the expiry field changes, or retain its full precision in the form.

    Issue, low. Reported as low.

  • low: Avoid duplicate share lookups across parallel dashboard requests

    src/lib/auth.ts:184

    Each share-authenticated stats or metrics request now waits for an additional database query before executing its analytics query. `getShare` performs an uncached Prisma `findUnique`, and dashboard panels issue separate requests carrying the same share token. Consequently, opening a dashboard or changing its date range repeats the same access-record read across its parallel requests. Revalidation is necessary, but the duplicate reads add database work and a serial round trip to each request.

    Fix: Coalesce concurrent share-record reads while preserving expiry checks and invalidation when access settings change.

    Issue, low. Reported as low.

GitHub Copilot · 5 findings

  • medium: Anonymous password endpoint lacks brute-force throttling

    src/app/api/share/[slug]/route.ts:262

    This anonymous endpoint has no password-attempt limit: `parseRequest` with `skipAuth` validates the request but does not throttle it. Anyone with the slug can make unlimited online password guesses, and each attempt runs `bcrypt.compareSync` through `checkPassword`, blocking the server's event loop. Add per-share/per-client throttling before verification and return 429 when the limit is reached. Ensure the protection also works when Redis is disabled.

    Issue, medium. Reported as high.

  • medium: Test uses real clock past fixture expiry

    src/lib/share.test.ts:167

    This assertion uses the real clock, but `unlocked.expiresAt` is fixed at October 21, 2026. On or after that deadline, including the review date, the resolver returns `password` rather than `ready`, so the test fails. Pass a fixed timestamp before the fixture's expiry to make the assertion deterministic.

    Issue, medium. Reported as high.

  • False positive: Server-only crypto import breaks client bundling

    src/lib/share.ts:4

    `ShareProvider` is a client component and imports this module, as do the share forms. This new import brings `src/lib/crypto.ts`, which imports `node:crypto`, into the browser dependency graph and breaks client bundling. Move password hashing, fingerprinting, and server-side access helpers into a separate server-only module, and update their server callers. Keep the form, filter, and view helpers browser-safe.

    False positive. Claims that importing node:crypto into client code breaks the build. next build compiles it, replacing node:crypto with Next.js's crypto-browserify polyfill.

  • high: Polling refresh unmounts authorized dashboard

    src/components/hooks/queries/useShareTokenQuery.ts:12

    For an unprotected share, each poll calls `buildUnlockedShare`, which signs a fresh JWT with a new `iat`. The response token then differs from the token in the store until the effects synchronize it. During that mismatch, `ShareProvider` renders `Loading` instead of its children, unmounting the dashboard and resetting local UI state every five seconds. Keep polling for access changes, but reuse an unchanged valid token or synchronize token refreshes without unmounting an already-authorized dashboard.

    Issue, high. Reported as medium.

  • low: Unlocked view uses stale link expiry

    src/lib/share.ts:216

    This checks the expiry captured when the visitor entered the password, not the link's current expiry. If the owner extends or removes the expiry, the protected GET response supplies the same password fingerprint but no updated `expiresAt`. Once the original deadline passes, the visitor is prompted for the password again even though the server still accepts their token. Include the current expiry in the protected-link status response and use it when resolving and refreshing the unlocked view.

    Issue, low. Reported as medium.

Exclude IP addresses per website

HeyDeer · 7 findings

  • high: Separate browser-safe IP helpers from database access

    src/lib/excluded-ips.ts:3

    The website settings client now imports an eagerly initialized database module. `WebsiteEditForm` imports this module beneath the client component `WebsiteSettingsPage`, while `src/lib/prisma.ts` imports the PostgreSQL adapter and calls `getClient()` at module scope. That initialization requires the server-only `DATABASE_URL` and throws when it is unavailable in the browser. Consequently, the settings client cannot safely build or load, even though the form only uses validation and formatting helpers.

    Fix: Move database lookup functions into a server-only module and keep the form’s helpers free of Prisma imports.

    Issue, high. Reported as high.

  • medium: Compare exact IP entries by address rather than spelling

    src/lib/excluded-ips.ts:33

    Valid excluded addresses can silently fail to exclude traffic. Validation stores entries unchanged apart from trimming, but `getIpAddress` compresses IPv6 addresses and converts IPv4-mapped IPv6 addresses to dotted IPv4. For example, a saved `2001:0db8:0000:0000:0000:0000:0000:0001` is compared against `2001:db8::1`. This equality fails, and entries without `/` have no further matching path, so the visitor’s events and recordings continue to be saved.

    Fix: Normalize parsed addresses consistently before comparing exact entries, including IPv4-mapped IPv6 addresses.

    Issue, medium. Reported as medium.

  • medium: Redact exclusions when access comes only from a share token

    src/app/api/websites/[websiteId]/route.ts:37

    A share-link recipient with an unrelated account can read the website’s excluded addresses by supplying both their login token and the share token. `checkAuth` preserves both, and `canViewSharedWebsite` delegates to `canViewWebsite`, which accepts the share token before checking ownership or team membership. Because `auth.user` exists, this redaction is skipped. Authentication alone therefore exposes settings that the share response is intended to hide.

    Fix: Require website access independently of the share token before including `excludedIps`.

    Issue, medium. Reported as medium.

  • low: Read exclusions from the primary to enforce saved changes immediately

    src/lib/excluded-ips.ts:109

    With `DATABASE_REPLICA_URL` configured, an exclusion saved successfully can still allow traffic to be permanently recorded while replication catches up. `src/lib/prisma.ts` installs the read-replicas extension, whose ordinary reads use replicas while updates use the primary, as documented in [Prisma’s read-replica contract](https://www.prisma.io/docs/orm/v7/prisma-client/setup-and-configuration/read-replicas). This lookup therefore can read the old list. `/api/record` has the same issue through `getWebsite`; bypassing Redis does not establish the promised immediate application.

    Fix: Use primary-database reads for exclusion decisions in both collection endpoints while supporting deployments without replicas.

    Issue, low. Reported as medium.

  • medium: Avoid an uncached database lookup for every collection request

    src/app/api/send/route.ts:159

    Every website collection request with a client IP that reaches this check now waits for an additional relational-database query, including visitors with valid cache tokens and websites with empty exclusion lists. Previously, cached requests skipped the website lookup; a simple relational pageview needed its event insert, whereas it now also needs this SELECT. ClickHouse-backed collection also gains a relational-database round trip. The immediate-update requirement explains the freshness goal, but refreshing or invalidating cached exclusions on update would preserve that goal without adding one database read per event.

    Fix: Cache exclusion metadata and refresh or invalidate it on website updates while checking requests with existing cache tokens.

    Issue, medium. Reported as medium.

  • low: Regenerate API contracts and client types for excludedIps

    src/app/api/websites/request-schema.ts:44

    The checked-in API client still omits `excludedIps` from `UpdateWebsiteRequest` and `Website`, so consumers lack its input typing and cannot read it from typed website responses. The generated website-list contracts also retain the old properties. Unknown input keys are accepted by the client, so sending the field is possible, but its declared contract remains stale. The CI workflow generates OpenAPI before comparing generated client files, which will also expose this drift.

    Fix: Regenerate the website contracts and API client with the new exclusion field.

    Issue, low. Reported as low.

  • low: Share the IP matching implementation with hasBlockedIp

    src/lib/excluded-ips.ts:26

    The same matching algorithm now needs to be edited and verified in two places: this function and `hasBlockedIp` in `src/lib/detect.ts:150–179`. Both trim entries, compare exact strings, parse CIDR ranges, and check address kinds. Keeping separate implementations creates a concrete maintenance burden when correcting address handling and risks inconsistent behavior between server-wide and website exclusions.

    Fix: Extract one browser-safe matching implementation and reuse it from both exclusion paths.

    Issue, low. Reported as low.

GitHub Copilot · 4 findings

  • high: Move database lookups out of the client dependency graph

    src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx:12

    This form is under the `use client` boundary in `WebsiteSettingsPage.tsx`, but `excluded-ips.ts` imports `@/lib/prisma` at module scope. That module imports the PostgreSQL adapter and eagerly calls `getClient()`, which requires the server-only `DATABASE_URL`. This pulls database code into the browser dependency graph and prevents the settings page from building or loading correctly. Keep the parsing, validation, and formatting helpers browser-safe, and move the database lookup functions into a separate server-only module.

    Issue, high. Reported as high.

  • medium: Require website access before exposing excluded IPs

    src/app/api/websites/[websiteId]/route.ts:37

    A signed-in share-link recipient can still receive the excluded IP list. `checkAuth()` can return both a user and a share token, and `canViewSharedWebsite()` accepts that token even when the user has no ownership or team access. Checking only whether a user exists therefore exposes the owner's office/VPN addresses to an unrelated account with a valid share link. Require website access independently of the share token before including this field, and cover the combined-credentials case in the route tests.

    Issue, medium. Reported as high.

  • medium: Normalize standalone IP addresses before comparison

    src/lib/excluded-ips.ts:35

    Standalone addresses are compared only as strings, while `getIpAddress()` normalizes incoming IPv6 addresses. Saving `2001:0db8:0000:0000:0000:0000:0000:0001` succeeds, but the same visitor is resolved as `2001:db8::1`, so both collectors still record their traffic. Compare parsed, normalized addresses for non-CIDR entries, including IPv4-mapped addresses, and add regression tests for equivalent spellings.

    Issue, medium. Reported as high.

  • low: Use primary-backed exclusion lookups in both collectors

    src/lib/excluded-ips.ts:109

    With `DATABASE_REPLICA_URL` configured, `prisma.client` uses the read-replicas extension, so this `findUnique` can read a replica that has not received the settings update. Newly excluded visitors can therefore keep creating sessions and events during replication lag; removed exclusions can also keep blocking traffic. `/api/record` has the same issue at line 186 because its `website` comes from the replica-backed `getWebsite()`. Use a shared primary-backed exclusion lookup in both collectors so saved changes apply to subsequent requests, including existing cache-token holders.

    Issue, low. Reported as medium.

DeepSeek V4.1 Flash

Expiring and password-protected share links

HeyDeer · 10 findings

  • medium: Prevent restricted share visitors from discovering alternate share links

    src/app/api/boards/[boardId]/shares/route.ts:27

    A visitor can bypass their link's expiration or subsequent password change by obtaining another share for the same resource. `canViewBoard` accepts the visitor's share token, and this endpoint returns all share records, including their slugs. If another link has no password or expiration, the visitor can obtain its grant before the original link is revoked and continue accessing the board afterward. The link and pixel share-list endpoints have the same permission pattern. The website equivalent already requires authenticated access.

    Fix: Require authenticated resource access before listing board, link, or pixel share links.

    Issue, medium. Reported as high.

  • low: Declare the unlock request body before generating the client

    packages/api-client/src/generated/operations.ts:2948

    `client.postShareSlug({ slug, password })` sends the password in the URL query and cannot unlock a protected share. This operation has `hasBody: false`, so `splitInput` routes the password into the query string and `sendRequest` sends no JSON body. The POST handler reads passwords exclusively from the body. Besides breaking the generated method, this unnecessarily exposes the password in request URLs.

    Fix: Define an explicit password request-body contract for the unlock endpoint and regenerate its client.

    Issue, low. Reported as medium.

  • medium: Throttle public password checks and avoid synchronous bcrypt

    src/app/api/share/[slug]/route.ts:153

    Anyone who knows a protected share's slug can submit unlimited password guesses through POST or the GET password header. Neither path records failures or imposes a cooldown, and the owner schema permits short PINs and common passwords. Every string guess also invokes `checkPassword`, which uses synchronous `bcrypt.compareSync` with cost 10, blocking unrelated request processing during the comparison.

    Fix: Throttle both password entry paths before verification and use asynchronous password comparison.

    Issue, medium. Reported as medium.

  • medium: Avoid continuous five-second refreshes in background tabs

    src/components/hooks/queries/useShareTokenQuery.ts:15

    Every mounted share page now makes 720 refresh requests per hour, including hidden tabs and shares without access restrictions. Each refresh repeats the primary share lookup and resource lookup; board shares also repeat entity permission filtering, and unlocked protected shares perform another primary lookup when validating their grant. This adds continuous database and network work while the viewer is inactive. Data requests already validate grants independently.

    Fix: Pause refreshes in background tabs and reduce or condition the polling needed to update the visible access state.

    Issue, medium. Reported as low.

  • low: Keep a loaded dashboard mounted during transient refresh errors

    src/app/share/ShareProvider.tsx:105

    A single failed background refresh replaces an already loaded dashboard with the unavailable message until a later refresh succeeds. The query polls every five seconds with retries disabled, and this branch handles refetch errors even when cached share data remains available, consistent with [TanStack Query's refetch-error behavior](https://tanstack.com/query/v5/docs/framework/react/reference/useQuery). Unmounting the dashboard also resets component-local state, such as hidden chart labels.

    Fix: Keep loaded content mounted during transient refetch errors and reserve the unavailable state for initial or definitive failures.

    Issue, low. Reported as low.

  • low: Avoid a separate primary lookup for every share-authenticated request

    src/lib/auth.ts:182

    Each share-authenticated API request now awaits an additional primary-database lookup before performing its existing work. For example, every stats or metrics request passes through `parseRequest` and `checkAuth` to this lookup, and `findShare` explicitly selects the primary. Dashboard requests therefore add one primary round trip per metric and cannot offload that validation to configured read replicas.

    Fix: Use a revocation-state cache invalidated on share updates and deletion while preserving immediate access revocation.

    Issue, low. Reported as low.

  • low: Match client password validation to the UTF-8 byte limit

    src/components/share/ShareAccessFields.tsx:35

    The form accepts passwords that the server rejects without explaining the actual limit. `maxLength={72}` limits UTF-16 code units, whereas `shareAccessParams` limits UTF-8 bytes. For example, 25 CJK characters fit in the input but occupy 75 UTF-8 bytes. Saving fails, and `getErrorMessage` displays the generic bad-request message instead of the byte-limit validation detail.

    Fix: Validate UTF-8 byte length in the owner forms and display the password limit before submission.

    Issue, low. Reported as low.

  • low: Preserve expiration precision when the field is unchanged

    src/components/share/ShareAccessFields.tsx:8

    Editing only a share's name can silently shorten its expiration. The API accepts timestamps with seconds, but `.slice(0, 16)` truncates them when either edit form loads, and both forms always submit the reconstructed expiration. In a UTC browser, an expiration of `2030-01-01T12:00:45Z` becomes `2030-01-01T12:00:00Z` after saving an unrelated edit.

    Fix: Preserve the stored expiration unless the field is edited or round-trip its full supported precision.

    Issue, low. Reported as low.

  • low: Expose owner access settings in the API request contracts

    src/app/api/share/contract.generated.ts:23

    The published creation and update schemas omit `expiresAt` and `password`, so API documentation and generated request types do not describe how to set, change, or remove the restrictions. The source analyzer resolves known fragments and local declarations but cannot resolve the imported `shareAccessParams` object. This affects generic creation, updates, and all four entity-specific creation endpoints. The local client transmits these fields only through its untyped catch-all properties.

    Fix: Provide discoverable request contracts containing the optional nullable access fields and regenerate the affected schemas.

    Issue, low. Reported as low.

  • low: Represent nullable expiration accurately in public share responses

    src/app/api/share/contract.generated.ts:255

    Ordinary non-expiring shares return `expiresAt: null`, but both published response alternatives require a string, and the generated client likewise declares a non-null string. The inferred union also includes a branch requiring `passwordHash` without `hasPassword`, although `publicShare` removes the hash and adds the boolean for every returned record. These mismatches affect owner read, list, create, and update contracts.

    Fix: Define an explicit public share response schema with nullable expiration, required `hasPassword`, and no password hash, then regenerate the contracts.

    Issue, low. Reported as low.

GitHub Copilot · 5 findings

  • medium: Restrict share listings to authenticated resource viewers

    src/app/api/boards/[boardId]/shares/route.ts:37

    Restrict the board, pixel, and link share listings to authenticated users with access to the resource. Their `canViewBoard`, `canViewPixel`, and `canViewLink` checks accept public share grants, so visitors can enumerate sibling share slugs. If a sibling is passwordless and has a later or no expiry, a visitor can obtain that grant before their original link is revoked and retain access afterward. Hash redaction does not prevent this. Pass user-only auth (`{ user: auth.user }`) to each listing's permission check, matching the website listing, and add regression tests rejecting share-only callers.

    Issue, medium. Reported as high.

  • medium: Throttle public share password attempts server-side

    src/app/api/share/[slug]/route.ts:154

    Add server-side attempt throttling before verifying public share passwords. Both the GET password header and POST body reach this comparison without an attempt limit, and `checkPassword` uses synchronous bcrypt. Anyone with a slug can make unlimited guesses and consume event-loop time for each comparison. Apply a bounded share/client-scoped limit to both methods and return 429 when exhausted. Ensure protection also works when Redis is disabled, rather than relying on the browser's disabled submit button.

    Issue, medium. Reported as medium.

  • low: Test revocation of already-issued share grants

    src/lib/auth.ts:184

    Add regression tests for revocation of an already-issued grant in `src/lib/auth.test.ts`. The new slug-route tests mock `parseShareToken`, and the helper tests check expiry and password fingerprints separately, so neither exercises this database revalidation. Accept a valid grant, then change the mocked current record to an expired share, a different password hash, or a deleted share, and verify that the same grant is rejected on the next request. This directly protects the central revocation requirement.

    Issue, low. Reported as medium.

  • low: Include password and expiry fields in share request contracts

    src/lib/share-access.ts:8

    The generated create/update request contracts omit both fields from `shareAccessParams`. For example, `createWebsiteShare` still exposes only `name` and `parameters`, and `updateShare` only `name`, `slug`, and `parameters`. OpenAPI consumers therefore have no request schema for setting or removing password/expiry restrictions. Provide source request schemas or explicit contracts that the generator can resolve, including the nullable removal values, then regenerate the affected contracts and API-client types.

    Issue, low. Reported as medium.

  • low: Remove raw share type from sanitized response contracts

    src/lib/share-access.ts:42

    Returning the generic input in this branch preserves the raw share type in the inferred response union. This has propagated into the generated contracts and client types: they advertise a `passwordHash` branch without `hasPassword`, so typed callers cannot read the promised `hasPassword` field without narrowing. This is a contract error, not evidence that the runtime leaks hashes. Current callers pass non-null records; remove the unnecessary branch, or model nullable inputs separately, and regenerate the response contracts/client so successful share responses have only the sanitized shape.

    Issue, low. Reported as medium.

Exclude IP addresses per website

HeyDeer · 3 findings

  • medium: Excluded IP addresses are exposed to public share viewers

    src/app/api/websites/[websiteId]/route.ts:37

    Anyone holding a public website share can obtain a token from `/api/share/[slug]` and pass `canViewSharedWebsite`. This handler then serializes the complete `getWebsite()` result, which now includes `excludedIps` through the new Prisma field. Configured office or VPN addresses and network ranges therefore become readable by anonymous dashboard viewers. These addresses were absent from the merge-base response.

    Fix: Exclude `excludedIps` from public share responses and distinguish settings access in the response contract.

    Issue, medium. Reported as medium.

  • low: Accepted mapped IPv6 ranges shorter than /96 fail to exclude traffic

    src/lib/excluded-ips.ts:75

    Saving `::ffff:203.0.113.0/95` passes validation, but traffic from `::ffff:203.0.113.5` is still recorded. `getIpAddress()` normalizes the client to IPv4, and `ipaddr.process()` also converts mapped clients to IPv4, as its [implementation confirms](https://raw.githubusercontent.com/whitequark/ipaddr.js/master/lib/ipaddr.js). The exclusion stays IPv6 because its prefix is below 96, so the family comparison returns false. Widening the same exclusion from `/96` to `/95` thus stops excluding previously matched traffic in both collection routes.

    Fix: Compare mapped clients and IPv6 CIDRs in a common address family, including prefixes shorter than `/96`.

    Issue, low. Reported as medium.

  • medium: Every website collection request now awaits an uncached primary query

    src/app/api/send/route.ts:161

    Every non-blocked website collection request now awaits `getWebsiteExcludedIps`, including cached sessions and websites with empty exclusion lists. That helper bypasses Redis and explicitly selects the primary; the [replica extension implementation](https://raw.githubusercontent.com/prisma/extension-read-replicas/main/src/extension.ts) confirms this routing. Previously, cached `/api/send` requests skipped the website query, and ClickHouse collection could avoid PostgreSQL entirely. This adds a serial database round trip and primary read load to every such event. `/api/record` adds the same lookup after already loading the website, adding another query there as well. Immediate application of saved settings should be preserved while avoiding these repeated uncached reads.

    Fix: Use a primary-backed cache refreshed when exclusions are saved, and consolidate the recording route's website and exclusion reads.

    Issue, medium. Reported as medium.

GitHub Copilot · 2 findings

  • medium: Exclude sensitive IP ranges from public share responses

    prisma/schema.prisma:110

    [P1] Keep excluded addresses out of public share responses. Adding this scalar makes `getWebsite()` return `excludedIps`, and `GET /api/websites/[websiteId]` serializes that entire object after `canViewSharedWebsite` permits share-token-only access. Anyone with a public dashboard link can therefore retrieve the team's saved office/VPN addresses and internal CIDR ranges. Omit this field for share-only callers, keep it available to authorized authenticated callers, and update the response schema and generated client contract to reflect the omission. Add a regression test for the shared GET response.

    Issue, medium. Reported as high.

  • low: Handle IPv4-mapped IPv6 exclusion CIDRs below /96

    src/lib/excluded-ips.ts:84

    [P2] Handle IPv4-mapped IPv6 CIDRs below /96. For example, `hasExcludedIp('::ffff:203.0.113.5', ['::ffff:0:0/95'])` returns false even though the client belongs to that accepted range. `ipaddr.process` converts the client to IPv4, but the prefix guard leaves the range as IPv6, so this family comparison fails and collection continues. Compare the IPv4 client's mapped IPv6 representation when the exclusion remains IPv6. Add regression cases for prefixes below /96, including the equivalent canonical network `::fffe:0:0/95`.

    Issue, low. Reported as medium.

GLM-5.3-Flash

Expiring and password-protected share links

HeyDeer · 15 findings

  • medium: Reject blank passwords when enabling protection

    src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx:96

    Enabling password protection with an empty password still saves successfully. Creation sends `password: null`; editing an unprotected share omits the password, leaving it unprotected. `SimpleShareEditForm` has the same editing behavior. The dialog closes even though the selected protection was never applied, leaving the data accessible to anyone with the link.

    Fix: Require a nonempty password when protection is enabled unless the share already has a password.

    Issue, medium. Reported as medium.

  • medium: Preserve the existing expiry timestamp during unrelated edits

    src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx:94

    Changing only a share’s name or other settings rewrites its existing expiry to the end of the editor’s local day. A share created through the API to expire at noon is therefore extended until 23:59:59.999, potentially reopening an expired link. `SimpleShareEditForm` line 80 does the same. The update endpoint supports preserving omitted fields, so an untouched expiry need not be rewritten.

    Fix: Omit `expiresAt` when its setting is unchanged and normalize only a newly selected date.

    Issue, medium. Reported as medium.

  • medium: Use a lossless transport for share passwords

    src/components/hooks/queries/useShareTokenQuery.ts:13

    The creation endpoints accept Unicode and surrounding spaces through JSON, but unlocking sends the raw password as a header. Fetch header values use `ByteString`, which rejects characters above U+00FF, and header normalization removes surrounding whitespace. Consequently, accepted passwords containing emoji or CJK characters cannot be submitted, and passwords with surrounding spaces are changed before verification. See the [Fetch header contract](https://fetch.spec.whatwg.org/#headers-class) and [ByteString conversion](https://webidl.spec.whatwg.org/#es-ByteString).

    Fix: Send passwords in a JSON request body or encode the header value and decode it on the server.

    Issue, medium. Reported as medium.

  • low: Enforce the password hashing limit in bytes

    src/lib/schema.ts:119

    The schema accepts 200 characters, but `hashPassword` and `checkPassword` use bcrypt, which only considers the first 72 UTF-8 bytes. The locked [bcryptjs implementation](https://raw.githubusercontent.com/dcodeIO/bcrypt.js/v3.0.3/index.js) exposes this limit through `truncates`. If an owner changes a long password only after that boundary, the previous password still passes verification and obtains a fresh token despite the password change.

    Fix: Reject passwords exceeding bcrypt’s 72-byte limit or use a hashing scheme that preserves the complete accepted password.

    Issue, low. Reported as medium.

  • medium: Limit password guesses on the public share endpoint

    src/app/api/share/[slug]/route.ts:137

    Anyone who knows a protected share’s URL can submit repeated password guesses without an application-level attempt limit. This public handler verifies every supplied password and reports whether it was invalid, without recording failures or imposing a lockout. That permits unrestricted online dictionary attacks against owner-chosen passwords. The existing authentication-challenge limiter is not applied here.

    Fix: Apply a bounded password-attempt policy before verification and record failed attempts.

    Issue, medium. Reported as medium.

  • medium: Expose access settings in the actual board creation form

    src/app/(main)/boards/[boardId]/BoardShareCreateForm.tsx:35

    `BoardShareDialog` renders `BoardShareCreateForm`, which has neither expiry nor password inputs and sends neither field. It does not use the modified `SimpleShareCreateForm`. Board owners therefore cannot configure protection at creation, although the feature requirement explicitly includes boards; they must first create an unrestricted share and then edit it.

    Fix: Add access settings to `BoardShareCreateForm` or route board creation through the shared form.

    Issue, medium. Reported as medium.

  • medium: Keep the paginated share data type as an array

    src/queries/prisma/share.ts:44

    `PageResult<T>` declares `data: T`, but the replacement annotation supplies one sanitized share rather than an array. Runtime data still comes from `result.data.map(...)`. This propagates into all four generated share-list contracts and client types, changing `data` from an array to an object. Typed consumers lose array operations, while property access permitted by the new type disagrees with the runtime response.

    Fix: Use an array of sanitized shares as the `PageResult` parameter and regenerate the affected contracts and client types.

    Issue, medium. Reported as medium.

  • low: Refresh access state when an open share loses authorization

    src/app/share/ShareProvider.tsx:67

    An already-open share does not transition to the expiry notice or a new password prompt when access is revoked. These states depend on the share-resolution query, which has no polling or expiry timer; global defaults also disable refetching on window focus. Subsequent data requests correctly return 401, but `useApi` only rejects them and never refreshes share access state. The page retains its prior dashboard state or displays individual request errors until reloaded.

    Fix: Refresh share access state on authorization failures and schedule an expiry transition for an already-open share.

    Issue, low. Reported as medium.

  • medium: Create a website fixture inside the new test suite

    tests/api/shares.spec.ts:293

    `websiteId` is declared inside the earlier `test.describe('Shares', ...)` callback and is outside this sibling suite’s scope. The first new test throws a `ReferenceError` while constructing its request, and the serial suite cannot reach its remaining cases. Moving the variable alone would not fix the fixture lifecycle because the earlier suite deletes its website in `afterAll`.

    Fix: Give the new suite its own website variable, creation hook, and cleanup hook.

    Issue, medium. Reported as medium.

  • low: Restore error handling and pending state in the simple share editor

    src/components/share/SimpleShareEditForm.tsx:72

    Rejected updates no longer populate this form’s error state because the previous `try/catch/finally` was replaced with a bare `await post(...)`. For example, a password exceeding the server’s 200-character limit produces a rejected request without updating `error={getErrorMessage(error)}`. `isPending` also never becomes true, leaving Cancel enabled during the write. This affects link, pixel, and board share editing.

    Fix: Restore pending-state updates and catch request failures into the form’s error state.

    Issue, low. Reported as low.

  • low: Avoid an uncached database lookup for every share-token request

    src/lib/share-access.ts:75

    `checkAuth` now awaits `verifyShareToken` for every request carrying a share token, and each verification performs a separate database lookup. Shared dashboards issue distinct requests for statistics, charts, panels, and active-user refreshes, so the same share state is repeatedly read before those requests proceed. Previously this authentication path required only token verification. This adds one database read and round trip per request.

    Fix: Reuse cached share state with update/delete invalidation while enforcing expiry on every request.

    Issue, low. Reported as low.

  • low: Include access fields in the published request contracts

    src/lib/schema.ts:117

    `shareAccessParams` is absent from the OpenAPI analyzer’s `knownFragments`, and imported declarations are not resolved by its local expression inference. Consequently, spreading this object contributes no properties to inferred request schemas. The generated creation/update contracts and API-client request types omit both `expiresAt` and `password`, including the null values used to remove protection.

    Fix: Teach contract inference about `shareAccessParams` or provide explicit request schemas, then regenerate the contracts and client.

    Issue, low. Reported as low.

  • low: Represent non-expiring shares as nullable in API responses

    packages/api-client/src/generated/types.ts:5927

    The response declares `expiresAt: string`, but the database field is nullable and creation explicitly stores null when expiry is omitted. `sanitizeShare` preserves that null. The mismatch appears throughout the share response contracts and client types, allowing consumers to use string operations on a value that is null for ordinary non-expiring shares.

    Fix: Preserve `expiresAt` nullability in response schemas and regenerate the API-client types.

    Issue, low. Reported as low.

  • low: Match the actual missing-password response

    tests/api/shares.spec.ts:322

    Without a supplied password, the route returns `{ passwordRequired: true, passwordInvalid: false }`. This exact-object assertion excludes the second property, so it fails once the separate fixture-scope problem is corrected and prevents the serial suite from reaching later cases.

    Fix: Include `passwordInvalid: false` in the expected response.

    Issue, low. Reported as low.

  • low: Add the missing default-language Access label

    src/components/messages.ts:374

    The new desktop shares-table heading requests `label.access`, but `public/intl/messages/en-US.json` contains no matching message. The default English catalog therefore cannot supply the intended Access heading.

    Fix: Add the `access` label to the default English message catalog.

    Issue, low. Reported as low.

GitHub Copilot · 9 findings

  • medium: Reject protected shares without an existing or new password

    src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx:91

    Enabling password protection without entering a password still successfully creates a public share: this branch sends `password: null`. The same problem occurs when enabling protection on an existing unprotected share, where the password is omitted. Reject submission when protection is enabled and there is neither an existing password nor a new value; only allow a blank field to preserve an already-protected share.

    Issue, medium. Reported as high.

  • medium: Preserve stored expiry unless the expiry date is changed

    src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx:94

    Saving any edit rewrites the existing expiry to the end of the viewer's local day, even when the expiry control was untouched. An API-created share that expired earlier today can therefore become accessible again just by renaming it; editing from another time zone can also shift its deadline. Preserve the stored timestamp unless the owner explicitly selects a different expiry date.

    Issue, medium. Reported as high.

  • medium: Rate-limit share password verification attempts

    src/app/api/share/[slug]/route.ts:137

    This public endpoint now verifies unlimited password guesses without any rate limit or failed-attempt tracking. Each guess invokes synchronous bcrypt via `getShareAccessState`, allowing both unrestricted online guessing and repeated blocking of the server's event loop. Apply a per-share/client attempt limit before password verification and return a throttling response when exceeded.

    Issue, medium. Reported as high.

  • low: Revalidate share access after expiry or password revocation

    src/components/hooks/queries/useShareTokenQuery.ts:14

    Access is checked only when this query runs, with no interval or expiry timer. The application's QueryClient also disables window-focus refetching, and data-request 401s do not invalidate this query. Consequently an already-open share keeps rendering its cached dashboard after expiry/password revocation and never reaches the new expiry notice or password prompt. Revalidate active share access and clear the share token/rendered content when it becomes restricted.

    Issue, low. Reported as high.

  • medium: Define an independent website fixture for the new test suite

    tests/api/shares.spec.ts:288

    `websiteId` is declared inside the preceding `test.describe('Shares', ...)` callback, so it is out of scope throughout this new sibling describe block. The first new test throws ReferenceError before issuing its request, and the serial dependency tests are skipped. Give this describe block its own website fixture and cleanup rather than referencing the previous suite's variable (that website is also deleted by its afterAll hook).

    Issue, medium. Reported as high.

  • medium: Encode passwords safely for transport without altering their values

    src/components/hooks/queries/useShareTokenQuery.ts:13

    Passwords are accepted and hashed as arbitrary strings by the creation/update APIs, but sending them verbatim in a Fetch header cannot round-trip those strings. For example, `秘密🔒` throws a ByteString TypeError before the request is sent, and ` secret ` is normalized to `secret`, so these valid passwords permanently fail the share-page prompt. Encode the password for transport and decode it server-side, or submit it in a JSON body.

    Issue, medium. Reported as medium.

  • low: Expose new share settings in generated API contracts

    src/lib/schema.ts:120

    The OpenAPI source analyzer does not expand this imported object spread into the request schemas. The regenerated `POST /api/share` contract and `packages/api-client/src/generated/types.ts` still list only entityId, shareType, name, slug and parameters, so typed API-client callers cannot pass either new setting. Expose these fields through a schema composition the analyzer supports, or add explicit contracts, then regenerate the contracts and client.

    Issue, low. Reported as medium.

  • medium: Declare paginated result data as an array

    src/queries/prisma/share.ts:44

    [P2] Keep the array in the paginated result type. `PageResult<T>` declares `data: T` (`src/lib/types.ts:193-194`), not `data: T[]`. This signature now advertises a single sanitized share even though the implementation returns an array from `result.data.map(...)`, giving typed callers and inferred API contracts the wrong response shape.

    Issue, medium. Reported as medium.

  • low: Include passwordInvalid false in the missing-password response

    tests/api/shares.spec.ts:322

    The missing-password response from `GET /api/share/[slug]` includes `passwordInvalid: false` as well as `passwordRequired: true`. This exact-equality assertion therefore fails once the fixture setup is fixed. Include the false flag in the expected response.

    Issue, low. Reported as medium.

Exclude IP addresses per website

HeyDeer · 6 findings

  • medium: Excluded IP lists are exposed to public dashboard viewers

    src/app/api/websites/[websiteId]/route.ts:35

    Anyone with a public dashboard link can read its configured exclusion addresses. `/api/share/[slug]` supplies a share token, `canViewSharedWebsite` accepts that token, and this handler returns the complete `getWebsite` result. The new scalar field is therefore included without an administrative permission check. Configured personal addresses, office addresses, or VPN ranges become public alongside the shared analytics.

    Fix: Omit `excludedIps` from public share responses while preserving access for authorized settings users.

    Issue, medium. Reported as medium.

  • medium: Repeat tracking requests now require two additional database queries without Redis

    src/app/api/send/route.ts:133

    Every website tracking request now performs a full website lookup when Redis is disabled, including requests carrying a valid cache token. Previously those repeat requests skipped the lookup. `fetchWebsite` calls `getWebsite`, which executes `website.findUnique` and then `attachShareIdToWebsite` executes `share.findFirst`. This adds two sequential database round trips to each repeat event, identify call, or performance report, even when the website has no exclusions. The share lookup supplies data ingestion does not use.

    Fix: Use a bounded cache or a minimal settings lookup that avoids fetching share metadata on every tracking request.

    Issue, medium. Reported as medium.

  • medium: Normalize exact IP filters before comparing addresses

    src/lib/detect.ts:165

    A valid expanded IPv6 exclusion can save successfully while its traffic continues to be recorded. For example, `2001:0db8:0000:0000:0000:0000:0000:0001` passes validation and is stored unchanged, but `getIpAddress` normalizes the incoming address to `2001:db8::1`. This exact-string comparison fails, and an entry without a slash never reaches parsed CIDR matching. Both `/api/send` and `/api/record` consequently accept the excluded visitor. The new validated website setting exposes this existing matcher limitation through supported input.

    Fix: Compare exact filters and client addresses using consistent parsed normalization, including IPv4-mapped IPv6 addresses.

    Issue, medium. Reported as medium.

  • medium: In-flight cache fills can restore old exclusions after a successful save

    src/queries/prisma/website.ts:208

    Excluded traffic can continue being recorded for up to 24 hours after saving the list. On a cache miss, `fetchWebsite` reads the website and then awaits the separate share lookup inside `getWebsite`. A settings update can commit and perform this Redis refresh while that earlier lookup is pending. When the earlier request resumes, `redis.fetch` unconditionally overwrites the refreshed value with its old snapshot using the 86,400-second TTL. Subsequent `/api/send` requests then use the stale exclusions.

    Fix: Make cache population and updates version-aware so an older website snapshot cannot overwrite a newer one.

    Issue, medium. Reported as medium.

  • low: Preserve session property filters in the narrowed segment update type

    packages/api-client/src/generated/types.ts:19805

    Typed client calls updating a segment with `parameters: { sessionPropertyFilters: [...] }` now fail TypeScript checking. This change replaces the previous arbitrary-key parameter type with an explicit object that omits `sessionPropertyFilters`. The actual `segmentParamSchema` accepts this field, and the update route persists it for segments. The omission comes from the incomplete `segmentParamSchema` mapping in `src/openapi/source-analysis.ts`; regenerating from that mapping narrowed a supported API request out of the client contract.

    Fix: Include `sessionPropertyFilters` in the source-analysis schema mapping and regenerate the affected contracts and client types.

    Issue, low. Reported as medium.

  • low: Represent nullable exclusion lists in response contracts

    packages/api-client/src/generated/types.ts:4719

    The new response field is declared as `string`, although migrated and newly created websites return null, and clearing the list explicitly stores null. Website queries return that value unchanged. The generated client therefore permits string operations on an unset list that fail at runtime. The same mismatch appears in the other newly generated website response types.

    Fix: Preserve `excludedIps` nullability in response schema generation and regenerate the affected contracts and client types.

    Issue, low. Reported as low.

GitHub Copilot · 4 findings

  • medium: Shared website responses expose sensitive exclusion ranges

    prisma/schema.prisma:112

    Adding the exclusion list to the full Website record also exposes it through `GET /api/websites/{websiteId}`. That route returns `getWebsite()` without redaction and allows share-token access through `canViewSharedWebsite`. A holder of a public dashboard link can therefore read configured office/VPN addresses and internal CIDR ranges, not just analytics. Redact `excludedIps` from shared website responses while retaining it for authorized settings access and internal ingestion.

    Issue, medium. Reported as high.

  • low: Website response schema omits the supported excludedIps field

    src/app/api/websites/request-schema.ts:44

    The request supports `excludedIps`, but `websiteSchema` in `src/app/api/websites/response-schema.ts` still omits it. The generated `Website` type used by `getWebsite`, `getWebsites`, and `updateWebsite` therefore cannot expose the saved list to typed API-client callers, even though the routes return it. Add the nullable string field to authenticated website responses and regenerate the client; if shared responses redact it, model that distinction rather than requiring it there.

    Issue, low. Reported as medium.

  • medium: Exact IPv6 exclusions fail for equivalent address spellings

    src/lib/detect.ts:162

    Exact IPv6 exclusions can silently fail because non-CIDR entries are compared as raw strings. The API accepts `2001:0db8:0000:0000:0000:0000:0000:0001`, but `getIpAddress` normalizes that visitor to `2001:db8::1`, so their events and recordings are still stored. Compare canonicalized addresses for exact entries as well, including client IPs supplied in the payload, and add a regression test for equivalent IPv6 spellings.

    Issue, medium. Reported as medium.

  • medium: Stale cache fill can overwrite updated exclusion settings

    src/queries/prisma/website.ts:209

    An in-flight cache miss can overwrite this saved record with the previous exclusion list. `fetchWebsite` uses `redis.client.fetch`, which reads the database and then unconditionally caches the result. If this settings update commits and refreshes Redis between those steps, the cache fill writes the old list back for 86,400 seconds. Subsequent `/api/send` requests can therefore keep recording excluded visitors after a successful save. Coordinate cache fills and updates with version-aware writes or locking, or read current exclusions outside this cache.

    Issue, medium. Reported as medium.