Responsible collection

Keep feedback private by design

How the widget takes enough to act on, refuses the rest twice, and what that refusal costs you.

The request arrives from a prospective customer's security reviewer, and it is the fair one: send us the script you want on our pages, and the list of everything it reads. The widget script is 120 lines long, and its first line is a claim the rest of the file has to earn: “GreatFeedback widget v2. No dependencies, no cookies, no passive recording.”

A reviewer looks for what third-party scripts tend to contain: a cookie write, a storage key, a listener on every keystroke, a beacon that fires without anyone pressing anything. What is there instead is one request for configuration and one for a submission, both sent without credentials, a shadow root whose styles begin from a full reset, and a single click listener that lives only in the seconds after a visitor asks to point at something.

A feedback widget is a guest on a page it does not own, in front of visitors who agreed to nothing with the vendor whose logo sits in the corner. The interesting question is never what such a script could take, since it runs inside the page and could take nearly everything; it is what it declines to take, and where that refusal is enforced again in case the first one is bypassed.

Each of those refusals costs something: less context in the inbox, fewer questions an investigation can close without asking a person. The section near the end prices that loss rather than apologising for it.

A security reviewer reading closely at a desktop monitor
A deliberate submission is not observation.

Decide What Never Leaves The Page

The instinctive design is to capture generously and filter later, since context you did not take is context you cannot recover: the full address with every parameter, a cookie to recognise a returning visitor, a recording of the last few seconds. That misreads whose data it is. The widget sits in front of someone who came to read a pricing page rather than to be measured, so the payload is fixed in advance: a message of 10 to 5,000 characters, an optional email, an optional rating from 1 to 5, a viewport size clamped to the range 100 through 16,000, and the page address with the query string and the fragment cleared before the body is assembled. Both requests go out without credentials, so no cookie travels either way, and the configuration that comes back holds four values: the site name, the prompt, the colour and the position.

The optional location is the clearest case, since it is the one place where a widget could justify reading the page structure. When the visitor asks to point at something, the widget records the lowercased tag name, the element's accessible label truncated to 140 characters when one exists, and two coordinates expressed as fractions of the viewport between 0 and 1. It takes no element text, no identifier, no class list and no path back through the document, so a button reaches the inbox as “button: Start free trial” and nothing more.

None of that protects a visitor who cannot see it, so the dialog closes with a list labelled “Context included with your feedback” naming the three attachments: the page address as it will be sent, the screen size, and the selected location, which reads “No location selected” until one is chosen. Under it sits a sentence readable before pressing send: “Your message, optional email/rating and the context shown above go to the website team. Query parameters and fragments are removed. No session recording.” The privacy notice repeats it in the same terms rather than softer ones: the widget “does not silently record browsing sessions, capture screenshots, read form values, or set tracking cookies”.

Diagram distinguishing deliberate feedback from excluded session and form capture

Check The Origin And Strip The Address Again

Guarantees that live in the browser are suggestions rather than controls: the script is public, the site key is public, and anyone can post a hand-written body to the same endpoint with a full query string in the page address field. Treating the widget's stripping as the enforcement point would leave a property that holds for ordinary visitors and dissolves for anyone willing to open a terminal.

So the intake route repeats the work instead of trusting it. Every widget request must carry a browser origin listed among the site's allowed origins, which hold the site's own origin plus up to ten more; an origin that is missing, sent as the literal string null, or absent from the list is refused with a 403 and “This website is not enabled for this feedback widget.” Only that one origin is named in the cross-origin response header. The server then re-reads the submitted page address and refuses it with a 422 and “The feedback page must belong to this website.” unless the scheme is http or https, the origin matches the verified header exactly, and no username or password is embedded; only then does it clear the query string and the fragment a second time. The stripping the visitor was shown is the stripping the database receives, whether or not the request came from the widget.

A five-step flow showing one feedback submission passing the same narrowing twice, once in the browser where the visitor can see it and once in the server where a hand-written request would arrive, ending at row level security beneath the application code.
Each refusal is written where it is enforced.

Store The Least Identifying Thing That Still Works

Abuse protection is where privacy designs collapse, because the obvious way to stop a flood of submissions is to keep the address that sent them, and once that column exists it answers questions nobody meant to ask: which visitor, how often, from where. The requirement is narrower: rate limiting needs to know that two requests share a source, not what the source is.

The counter is therefore keyed on a hash of the address, computed with HMAC-SHA-256 under a server-side secret, and the buckets are explicit: 20 submissions per 3,600 seconds for one hashed identity on one site, 200 per 60 seconds for the site as a whole, and 180 configuration fetches per 3,600 seconds. What is stored is a hash and a count, and nobody reading the inbox can work backwards to an address. Deduplication makes the same trade: each submission carries an identifier generated in the browser, a retry returns the original entry rather than a second record or a second unit of the allowance, and that bookkeeping row expires 24 hours later, because its only job is to survive a flaky connection. The email stays optional, and the inbox reads “Anonymous visitor” when there is none.

Put The Last Refusal Below The Application Code

Every check so far lives in application code, which puts each of them one forgotten filter away from not applying. The answer is a refusal underneath the code, in the database: every tenant table has row level security enabled and forced, under a policy comparing the row's workspace against an identifier set on the connection, and each request opens a transaction that sets that identifier before any query runs. The application signs in as a role that may only read and write rows in those tables, while the database and its schema are owned by a different role; the application role holds no superuser rights and no permission to bypass row level security, so it can neither switch the policies off nor sit outside them as the owner would. Above it, membership is resolved per request, site access is checked on every call, and session caching is switched off, so removing a teammate stops their next request rather than the one after a cached session expires.

The outbound side is held to the same standard, because a service that fetches a URL on request is an open door into private networks unless it refuses to be. A review target is rejected if it runs past 2,048 characters, contains control bytes, carries an embedded credential, or names a non-standard port: “Only standard web ports are supported.” Every address the hostname resolves to must be publicly routable, with loopback, private and reserved addresses refused as “Private, local, and reserved network addresses cannot be reviewed.”, and the request goes to that resolved address with the certificate still checked against the original hostname. A page may redirect at most 4 times, a redirect that leaves the reviewed website fails with “This link leaves the website being reviewed.”, the content type must be readable HTML or text, and the response is cut off at 1,048,576 bytes.

What This Does Not Tell You

The stripping has a bill, and it arrives during investigations. A visitor writes that the results list came back empty, and the stored address is the bare path, cleaned twice; the filter state, the sort order and the search term that would have reproduced the failure were all in the query string that both the widget and the server removed. You ask a follow-up question, and if the visitor left the optional email blank you cannot even do that: the entry reads “Anonymous visitor” and the investigation proceeds from the message alone. That is not a defect to be fixed later. It is the same decision, seen from the side where it costs something.

The other omissions bite the same way. There is no session replay, no screenshot and no capture of form values, so you cannot watch what the visitor did or read what they typed; the selected element arrives as a tag name and a label of at most 140 characters, so two buttons carrying the same label are indistinguishable; the coordinates are fractions of a viewport whose layout you reconstruct yourself. Removing the query string also does not make every path safe, since a path can carry an identifier of its own, and the product does not touch the path.

The AI reviewer carries its own limits and states them. It reads public page text only, and every report stores that boundary verbatim as: “Public-page content review. Visual layout, live interactions, page speed, accessibility compliance, and logged-in workflows were not tested.” It takes no screenshots, runs no interactions, submits no forms, and cannot reach a page behind a login. The review screen says what the findings are before you read one: “These are AI interpretations, not accounts from real visitors. Use them to form better questions and decide what to test.” They are suggestions to verify, and a finding about a page the reviewer never reached is not a finding at all.

First Steps

Confirm these properties on your own installation rather than reading about them. It takes one page load, one test submission and one deliberately rejected request, and it leaves the evidence for the security reviewer who eventually asks.

  1. Install the widget on a page whose address carries both a query string and a fragment, open the dialog, and read the page value in the context list: it must show the bare address before you press send.
  2. Submit a test entry, then confirm that the widget wrote no cookie and no persistent storage entry, and that neither request carried credentials.
  3. Keep the supplied origins inside their limit of ten exact entries, then post one submission from an origin that is not on the list: the response must be a 403 carrying “This website is not enabled for this feedback widget.”

Write The Refusals Down Where They Are Enforced

A privacy claim survives a security review when the same sentence appears in the dialog the visitor reads, in the notice the customer's counsel reads, and in the code a reviewer can open; it does not survive when the notice is written by one team and the capture by another. The widget's header comment, the line under the context list and the privacy notice say the same three things about cookies, recording and query strings.

Underneath is one pattern worth borrowing: enforce each refusal at the lowest layer that can enforce it, and repeat it higher only where the lower layer cannot see the problem. The query string goes in the widget so the visitor can watch it happen and again on the server because the widget is public; the origin is checked on the server because it is the one part of the request a caller cannot forge; tenant isolation lives in database policies under a role that cannot bypass them, because application code is where mistakes live. When you add a field to your own capture, decide first what would still refuse it if the code forgot to.