docs(usage): state in the consent dialog that the connection only runs outwards

The dialog described what is sent and where it goes, but never said which way
the connection runs. That is the part an operator is actually being asked to
accept: opening an outbound path to someone else's service.

PicPeak sends and never pulls. One place in the service reaches the network,
it is a POST, and it requests exactly two paths — /api/envelopes, and
/api/participant/lookup only when an operator asks for their own export. No
scheduled job contacts the collector; the daily rollup is driven solely by an
authenticated admin hitting /activity. There is no route the collector could
call, and redirect: 'error' means it cannot even point a request somewhere
else. From a reply only the acknowledgement for the packet just sent is read,
with every field compared against that packet before it is accepted; the
stored copy drops the session token and no read path hands it back to the UI.
A requested export is streamed to the operator as a file and never
interpreted.

The consequence is why it belongs in the consent text and not only in the
docs: this channel cannot deliver code, configuration or content into an
installation, not even from a collector that has been taken over. It is a
security property by design rather than by convention.

usageOutboundOnly.test.js guards it by source inspection rather than
behaviour, because a behavioural test only proves that today's calls behave.
It fails the moment someone adds a second fetch, a poll for messages, a
scheduled pull, or a public route touching the usage service — verified by
injecting each of those.
This commit is contained in:
Paul Nothaft
2026-09-06 17:58:19 +02:00
parent 1e8b6f1b0f
commit c741dc22c5
6 changed files with 119 additions and 0 deletions
@@ -304,3 +304,16 @@ it('returns focus to the control that opened the consent dialog', async () => {
)
);
});
// A security property, not a nicety: the consent dialog is where an operator
// decides whether to open a connection at all, so it has to say which way that
// connection runs. UsageService makes exactly two outbound POSTs and reads
// nothing but the acknowledgement for the packet it just sent.
it('states in the consent dialog that the connection only runs outwards', async () => {
mount();
fireEvent.click(await screen.findByText('productUsage.review'));
const dialog = await screen.findByText('productUsage.consentTitle');
expect(dialog).toBeTruthy();
await screen.findByText('productUsage.sectionOneWay');
await screen.findByText('productUsage.oneWay');
});
@@ -6,6 +6,7 @@ import {
type ProductFeedback
} from '../../../services/productUsage.service';
import {
ArrowUpFromLine,
Globe,
ListChecks,
MessageSquare,
@@ -31,6 +32,10 @@ const DISCLOSURE: {
{ key: 'fields', heading: 'sectionFields', Icon: ListChecks },
{ key: 'excluded', heading: 'sectionExcluded', Icon: ShieldOff },
{ key: 'transport', heading: 'sectionTransport', Icon: Send },
// Directly after transport, because it is a property of the transport and
// the reason the transport is shaped this way: the connection only ever
// runs outwards, so this cannot become a way to push anything in.
{ key: 'oneWay', heading: 'sectionOneWay', Icon: ArrowUpFromLine },
{ key: 'visibility', heading: 'sectionVisibility', Icon: Globe },
{ key: 'deletion', heading: 'sectionDeletion', Icon: Trash2 },
{ key: 'feedbackDisclosure', heading: 'sectionFeedback', Icon: MessageSquare }