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:
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* The consent dialog tells the operator that this connection only ever runs
|
||||||
|
* outwards: PicPeak sends, and reads nothing back but the acknowledgement for
|
||||||
|
* the packet it just sent. That is a security claim — it is the reason a
|
||||||
|
* compromised collector cannot use this path to push code, configuration or
|
||||||
|
* content into an installation — so it is guarded here rather than left to
|
||||||
|
* review.
|
||||||
|
*
|
||||||
|
* These are source-inspection assertions on purpose. A behavioural test only
|
||||||
|
* proves the calls that exist today behave; this fails the moment someone adds
|
||||||
|
* a "check the collector for messages" fetch, a polling job, or an endpoint the
|
||||||
|
* collector could call.
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SRC = path.resolve(__dirname, '../../src');
|
||||||
|
const service = fs.readFileSync(path.join(SRC, 'usage/UsageService.js'), 'utf8');
|
||||||
|
const route = fs.readFileSync(path.join(SRC, 'routes/adminUsage.js'), 'utf8');
|
||||||
|
const server = fs.readFileSync(path.resolve(__dirname, '../../server.js'), 'utf8');
|
||||||
|
|
||||||
|
test('the collector is contacted from exactly one place, and only by POST', () => {
|
||||||
|
// One transport helper. Anything else reaching for the network here would
|
||||||
|
// bypass the size cap, the redirect ban and the timeout as well.
|
||||||
|
const callSites = service.match(/this\.fetch\(/g) || [];
|
||||||
|
expect(callSites).toHaveLength(1);
|
||||||
|
|
||||||
|
const post = service.slice(service.indexOf('async post('));
|
||||||
|
expect(post).toContain('method: \'POST\'');
|
||||||
|
// A redirect is an instruction from the collector about where to go next.
|
||||||
|
expect(post).toContain('redirect: \'error\'');
|
||||||
|
expect(post).toContain('AbortSignal.timeout(');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only the two known collector paths are ever requested', () => {
|
||||||
|
const paths = [...service.matchAll(/this\.post\(\s*'([^']+)'/g)].map((m) => m[1]);
|
||||||
|
expect(paths.sort()).toEqual(['/api/envelopes', '/api/participant/lookup']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing is read from a reply except the acknowledgement, checked field by field', () => {
|
||||||
|
// Every field of the receipt is compared against the packet that was sent.
|
||||||
|
for (const field of ['packet_id', 'installation_id', 'packet_digest', 'action', 'sequence', 'status'])
|
||||||
|
expect(service).toMatch(new RegExp(`receipt\\.${field} !==`));
|
||||||
|
expect(service).toContain('throw new Error(\'Invalid collector receipt\')');
|
||||||
|
|
||||||
|
// The stored copy drops the one value that is not an echo of what we sent,
|
||||||
|
// and no read path hands it back out again.
|
||||||
|
expect(service).toContain('delete storedReceipt.session_token');
|
||||||
|
expect(service).not.toMatch(/last_receipt:\s*state\.last_receipt/);
|
||||||
|
const status = service.slice(service.indexOf('async status()'), service.indexOf('async locked('));
|
||||||
|
expect(status).not.toContain('last_receipt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the collector has no way in: no inbound route and no scheduled pull', () => {
|
||||||
|
// Every usage route is mounted behind adminAuth on the admin surface.
|
||||||
|
expect(server).toContain('app.use(\'/api/admin/usage\', require(\'./src/routes/adminUsage\'))');
|
||||||
|
expect(route).toContain('router.use(adminAuth)');
|
||||||
|
// No public/gallery/webhook mount for anything usage-related.
|
||||||
|
const publicMounts = [...server.matchAll(/app\.use\('\/api\/(public|gallery|customer|invite)[^']*',[^\n]*\)/g)]
|
||||||
|
.map((m) => m[0]);
|
||||||
|
for (const mount of publicMounts) expect(mount).not.toMatch(/[Uu]sage/);
|
||||||
|
|
||||||
|
// Nothing schedules a collector call; the daily rollup is driven only by an
|
||||||
|
// authenticated admin hitting /activity.
|
||||||
|
expect(service).not.toMatch(/setInterval|setTimeout\s*\(\s*\(\)\s*=>\s*this\.tick/);
|
||||||
|
const dir = path.join(SRC, 'services');
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith('.js')) continue;
|
||||||
|
if (entry.name === 'productUsageService.js') continue;
|
||||||
|
expect(fs.readFileSync(path.join(dir, entry.name), 'utf8'))
|
||||||
|
.not.toContain('productUsageService');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -39,6 +39,30 @@ somewhere the operator did not choose.
|
|||||||
|
|
||||||
Local development can use an HTTP loopback collector outside production. The
|
Local development can use an HTTP loopback collector outside production. The
|
||||||
collector URL is never writable through generic settings or request payloads.
|
collector URL is never writable through generic settings or request payloads.
|
||||||
|
|
||||||
|
### The connection only runs outwards
|
||||||
|
|
||||||
|
PicPeak sends; it never pulls. There is exactly one place in the service that
|
||||||
|
reaches the network, it is a POST, and it makes requests to exactly two paths:
|
||||||
|
`/api/envelopes` and — only when an operator asks for their own data export —
|
||||||
|
`/api/participant/lookup`. There is no scheduled job that contacts the
|
||||||
|
collector (the daily rollup is driven solely by an authenticated admin hitting
|
||||||
|
`/activity`), no route the collector could call, and `redirect: 'error'` so the
|
||||||
|
collector cannot even redirect a request elsewhere.
|
||||||
|
|
||||||
|
From a reply the service reads only the acknowledgement for the packet it just
|
||||||
|
sent, and compares `packet_id`, `installation_id`, `packet_digest`, `action`,
|
||||||
|
`sequence` and `status` against that packet before accepting it; a mismatch is
|
||||||
|
an error and nothing else in the response is looked at. The stored copy drops
|
||||||
|
the session token, and no read path hands it back to the UI. A requested data
|
||||||
|
export is streamed to the operator as a file attachment and is never
|
||||||
|
interpreted or executed.
|
||||||
|
|
||||||
|
The consequence is the point, and it is stated in the consent dialog: this
|
||||||
|
channel cannot deliver code, configuration or content into an installation —
|
||||||
|
not even from a collector that has been taken over. It is a one-way path by
|
||||||
|
design, not by convention, and `__tests__/services/usageOutboundOnly.test.js`
|
||||||
|
fails if that ever stops being true.
|
||||||
Keep the encryption material stable and protected; losing it makes the old
|
Keep the encryption material stable and protected; losing it makes the old
|
||||||
identity unable to sign deletion requests. Note that `USAGE_ENCRYPTION_KEY`
|
identity unable to sign deletion requests. Note that `USAGE_ENCRYPTION_KEY`
|
||||||
defaults to `JWT_SECRET`, so rotating `JWT_SECRET` without setting a dedicated
|
defaults to `JWT_SECRET`, so rotating `JWT_SECRET` without setting a dedicated
|
||||||
|
|||||||
@@ -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
|
type ProductFeedback
|
||||||
} from '../../../services/productUsage.service';
|
} from '../../../services/productUsage.service';
|
||||||
import {
|
import {
|
||||||
|
ArrowUpFromLine,
|
||||||
Globe,
|
Globe,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@@ -31,6 +32,10 @@ const DISCLOSURE: {
|
|||||||
{ key: 'fields', heading: 'sectionFields', Icon: ListChecks },
|
{ key: 'fields', heading: 'sectionFields', Icon: ListChecks },
|
||||||
{ key: 'excluded', heading: 'sectionExcluded', Icon: ShieldOff },
|
{ key: 'excluded', heading: 'sectionExcluded', Icon: ShieldOff },
|
||||||
{ key: 'transport', heading: 'sectionTransport', Icon: Send },
|
{ 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: 'visibility', heading: 'sectionVisibility', Icon: Globe },
|
||||||
{ key: 'deletion', heading: 'sectionDeletion', Icon: Trash2 },
|
{ key: 'deletion', heading: 'sectionDeletion', Icon: Trash2 },
|
||||||
{ key: 'feedbackDisclosure', heading: 'sectionFeedback', Icon: MessageSquare }
|
{ key: 'feedbackDisclosure', heading: 'sectionFeedback', Icon: MessageSquare }
|
||||||
|
|||||||
@@ -386,6 +386,8 @@
|
|||||||
"sectionFeedback": "Feedback ist getrennt",
|
"sectionFeedback": "Feedback ist getrennt",
|
||||||
"excluded": "Automatische Berichte enthalten keine Galeriebesucher, Klickverläufe, Foto- oder Galerieanzahlen, Namen, E-Mail-Adressen, Domains, Dateinamen oder Zugangsdaten.",
|
"excluded": "Automatische Berichte enthalten keine Galeriebesucher, Klickverläufe, Foto- oder Galerieanzahlen, Namen, E-Mail-Adressen, Domains, Dateinamen oder Zugangsdaten.",
|
||||||
"transport": "Ihr PicPeak-Backend verwahrt den Signaturschlüssel und sendet einmal pro UTC-Tag bei Admin-Nutzung signierte Nutzungsberichte an {{collector}}. Sie können Berichte vorab ansehen und jeden eindeutig angenommenen Bericht genau wie beim ersten Empfang herunterladen; Übertragungswiederholungen werden zusammengeführt. Abgelehnte Versuche und getrennt gesendetes Feedback gehören nicht zu diesem Berichtsexport.",
|
"transport": "Ihr PicPeak-Backend verwahrt den Signaturschlüssel und sendet einmal pro UTC-Tag bei Admin-Nutzung signierte Nutzungsberichte an {{collector}}. Sie können Berichte vorab ansehen und jeden eindeutig angenommenen Bericht genau wie beim ersten Empfang herunterladen; Übertragungswiederholungen werden zusammengeführt. Abgelehnte Versuche und getrennt gesendetes Feedback gehören nicht zu diesem Berichtsexport.",
|
||||||
|
"sectionOneWay": "Nur senden — kein Rückkanal",
|
||||||
|
"oneWay": "PicPeak sendet ausschließlich. Es ruft beim Collector nichts ab, holt sich keine Anweisungen und stellt ihm keinen Endpunkt bereit, den er aufrufen könnte — auf diesem Weg gibt es weder einen geplanten Job noch eine eingehende Route. Aus einer Antwort wird nur die Bestätigung für das eben gesendete Paket gelesen, und jedes ihrer Felder wird gegen dieses Paket geprüft, bevor sie angenommen wird; alles andere wird verworfen. Ein von Ihnen angeforderter Datenexport wird Ihnen als Datei übergeben und niemals ausgewertet oder ausgeführt. Über diesen Kanal können also weder Code noch Konfiguration oder Inhalte in Ihre Installation gelangen — auch nicht von einem übernommenen Collector.",
|
||||||
"visibility": "Nur teilnehmende Installationen können den Funktionsdatensatz und aggregierte Ergebnisse einsehen, auch Gruppen mit nur einer Installation. Schema und Quellcode sind öffentlich; geprüfte Funktionswünsche und Empfehlungen werden nur mit Erlaubnis ihrer Verfasser veröffentlicht. Ihr Fingerabdruck ist pseudonym, nicht anonym. Bewahren Sie Ihren Abfrage-Hash vertraulich auf: Er ermöglicht lesenden Zugriff auf Ihre eigenen Berichte und den Teilnehmerdatensatz.",
|
"visibility": "Nur teilnehmende Installationen können den Funktionsdatensatz und aggregierte Ergebnisse einsehen, auch Gruppen mit nur einer Installation. Schema und Quellcode sind öffentlich; geprüfte Funktionswünsche und Empfehlungen werden nur mit Erlaubnis ihrer Verfasser veröffentlicht. Ihr Fingerabdruck ist pseudonym, nicht anonym. Bewahren Sie Ihren Abfrage-Hash vertraulich auf: Er ermöglicht lesenden Zugriff auf Ihre eigenen Berichte und den Teilnehmerdatensatz.",
|
||||||
"deletion": "Deaktivieren stoppt die Erfassung sofort und fordert die Löschung der Berichte, Aggregatbeiträge, Rückmeldungen, Veröffentlichungen, Stimmen und Sitzungen an. Bei einem Ausfall bleiben nur die zur Löschung nötigen Zugangsdaten erhalten; die Oberfläche zeigt die ausstehende Löschung. Nach Bestätigung werden Hash und Schlüssel lokal gelöscht; eine erneute Teilnahme erzeugt eine neue Identität. Der Collector behält einen Einweg-Sperrwert und kurzlebige Missbrauchszähler ohne Installationsbezug. PicPeak speichert eine herunterladbare lokale Löschquittung ohne den alten Hash, Schlüssel oder Inhalte.",
|
"deletion": "Deaktivieren stoppt die Erfassung sofort und fordert die Löschung der Berichte, Aggregatbeiträge, Rückmeldungen, Veröffentlichungen, Stimmen und Sitzungen an. Bei einem Ausfall bleiben nur die zur Löschung nötigen Zugangsdaten erhalten; die Oberfläche zeigt die ausstehende Löschung. Nach Bestätigung werden Hash und Schlüssel lokal gelöscht; eine erneute Teilnahme erzeugt eine neue Identität. Der Collector behält einen Einweg-Sperrwert und kurzlebige Missbrauchszähler ohne Installationsbezug. PicPeak speichert eine herunterladbare lokale Löschquittung ohne den alten Hash, Schlüssel oder Inhalte.",
|
||||||
"feedbackDisclosure": "Feedback wird getrennt von automatischen Berichten und nur beim Absenden übertragen. Jeder Beitrag ist anonym, sofern Sie keinen Namen angeben, und nur für Betreuer sichtbar, sofern Sie die Veröffentlichung nicht ausdrücklich erlauben. Öffentliche Beiträge werden geprüft. Die Verwendung einer Empfehlung für Marketing benötigt eine zusätzliche Erlaubnis.",
|
"feedbackDisclosure": "Feedback wird getrennt von automatischen Berichten und nur beim Absenden übertragen. Jeder Beitrag ist anonym, sofern Sie keinen Namen angeben, und nur für Betreuer sichtbar, sofern Sie die Veröffentlichung nicht ausdrücklich erlauben. Öffentliche Beiträge werden geprüft. Die Verwendung einer Empfehlung für Marketing benötigt eine zusätzliche Erlaubnis.",
|
||||||
|
|||||||
@@ -386,6 +386,8 @@
|
|||||||
"sectionFeedback": "Feedback is separate",
|
"sectionFeedback": "Feedback is separate",
|
||||||
"excluded": "No gallery visitors, clickstreams, photo or gallery counts, names, emails, domains, filenames, or configuration secrets are included in automatic usage reports.",
|
"excluded": "No gallery visitors, clickstreams, photo or gallery counts, names, emails, domains, filenames, or configuration secrets are included in automatic usage reports.",
|
||||||
"transport": "Your PicPeak backend keeps the signing key and sends signed usage reports to {{collector}} once per UTC day during admin use. Preview reports and download each unique accepted report exactly as first received; transport retries are deduplicated. Rejected attempts and separately submitted feedback are not part of this report export.",
|
"transport": "Your PicPeak backend keeps the signing key and sends signed usage reports to {{collector}} once per UTC day during admin use. Preview reports and download each unique accepted report exactly as first received; transport retries are deduplicated. Rejected attempts and separately submitted feedback are not part of this report export.",
|
||||||
|
"sectionOneWay": "Sending only — no return channel",
|
||||||
|
"oneWay": "PicPeak only sends. It never fetches anything from the collector, never asks it for instructions, and exposes no endpoint the collector could call — there is no scheduled job and no inbound route on this path. From a reply it reads only the acknowledgement for the packet it just sent, and checks every field of that acknowledgement against the packet before accepting it; anything else is discarded. A data export you request yourself is handed to you as a file and is never interpreted or executed. So this channel cannot deliver code, configuration or content into your installation — not even from a collector that has been taken over.",
|
||||||
"visibility": "Only participating installations can inspect the feature dataset and aggregate results, including groups of one. The schema and source are public; approved feature requests and testimonials are public only with their authors’ permission. Your fingerprint is pseudonymous, not anonymous. Keep your lookup hash private: it grants read-only access to your own reports and the participant dataset.",
|
"visibility": "Only participating installations can inspect the feature dataset and aggregate results, including groups of one. The schema and source are public; approved feature requests and testimonials are public only with their authors’ permission. Your fingerprint is pseudonymous, not anonymous. Keep your lookup hash private: it grants read-only access to your own reports and the participant dataset.",
|
||||||
"deletion": "Disabling immediately stops collection and requests deletion of reports, aggregate contributions, feedback, published items, votes and sessions. During an outage, only credentials needed to finish deletion remain and the UI shows deletion pending. After confirmation, the local hash and key are erased; rejoining creates a new identity. The collector retains a one-way revocation digest and short-lived identity-free abuse counters. PicPeak keeps a downloadable local deletion receipt without the old hash, key or payloads.",
|
"deletion": "Disabling immediately stops collection and requests deletion of reports, aggregate contributions, feedback, published items, votes and sessions. During an outage, only credentials needed to finish deletion remain and the UI shows deletion pending. After confirmation, the local hash and key are erased; rejoining creates a new identity. The collector retains a one-way revocation digest and short-lived identity-free abuse counters. PicPeak keeps a downloadable local deletion receipt without the old hash, key or payloads.",
|
||||||
"feedbackDisclosure": "Feedback is separate from automatic reports and is sent only when you submit it. Each item is anonymous unless you include a name, and private to maintainers unless you explicitly permit publication. Public items require maintainer review. Marketing use of a testimonial requires separate permission.",
|
"feedbackDisclosure": "Feedback is separate from automatic reports and is sent only when you submit it. Each item is anonymous unless you include a name, and private to maintainers unless you explicitly permit publication. Public items require maintainer review. Marketing use of a testimonial requires separate permission.",
|
||||||
|
|||||||
Reference in New Issue
Block a user