Skip to content

Wizard persists its whole state (including a literal webhook URL) in localStorage under forgelm.wizard.state with no expiry; the privacy policy says only two values are stored (CR-A18-016) #367

Open

Description

@cemililik

Wave 1 · theme D1-11: Other high-severity packages · highest severity High · 4 finding(s) · effort small

Summary

A user pastes a Slack webhook URL into the wizard on a shared machine. It stays in localStorage, and the privacy page says only language and theme are stored.

Primary finding

CR-A18-016 — Wizard persists its whole state (including a literal webhook URL) in localStorage under forgelm.wizard.state with no expiry; the privacy policy says only two values are stored

severity Medium · reachability default · intent contract-violation · confidence confirmed · independently confirmed · reproduced end to end · already tracked: KNOWN-OPEN DOCS-02/14/15/16 (S15) — docs/roadmap/phase-16-trust-surface-hardening.md:772,780-781

What happens. saveState writes the full state under STORAGE_KEY 'forgelm.wizard.state' on every input (wizard.js:67, 313-317, 2660-2663), including webhookUrl (a bearer secret when pasted literally), monitoring endpoint, provider/system names and risk text; it is cleared only by Reset (wizard.js:3001-3007). privacy.body in all six locales (translations.js:4345-4390) says only forgelm-lang and forgelm-theme are stored, and no guard compares site/js localStorage keys with the privacy text.

Where.

Evidence.

  • node h/storage.js: saveState stores webhookUrl 'https://hooks.slack.com/services/T000/B000/XXXX' verbatim under forgelm.wizard.state.
  • Code reading only; privacy text and wizard persistence both located and compared.
  • main.js:133, i18n.js:26 and wizard.js:67 are the only localStorage keys in site/js. The privacy.body text enumerates only the first two. The webhook value is persisted via persist() in the urlInput 'input' listener (wizard.js:2660-2663).

Recommended fix. Add forgelm.wizard.state to the privacy text in all locales, and either avoid persisting a literal webhook URL or warn next to the field.

Other findings with the same root cause

These close with the same fix; update them in the same pull request.

CR-A18-037 — Contact-form promises ('not shared with third parties', 'not retained after the conversation is resolved', 'deletion: we delete the email thread') ignore the copy held by Formspree

severity Medium · reachability default · intent contract-violation · confidence confirmed · independently confirmed · not reproduced (code trace) · already tracked: KNOWN-OPEN DOCS-02/14/15/16 (S15 privacy) — docs/roadmap/phase-16-trust-surface-hardening.md:772

What happens. The form posts every field to a third-party endpoint: contact.html:136 sets action="https://formspree.io/f/xnjlejbb", and main.js:237-241 sends fetch(form.action, {method:'POST', body:new FormData(form)}), which includes name, email, organisation, topic, subject, message and _subject. The browser also sends its IP and User-Agent. The notice names Formspree as the conduit, but every locale then promises that the email is not shared with third parties and is not retained after the conversation is resolved, and that deletion 'is straightforward: we delete the email thread' (rights section). Those promises cover only the maintainer's mailbox. Whatever submission archive Formspree keeps on its side is not covered, and the notice offers no deletion or retention commitment for it. The notice never says that Formspree acts as a processor under a DPA, and that is the only reading under which 'not shared with third parties' would hold (a processor is excluded from 'third party' by Art. 4(10)). It also does not say that Formspree receives the visitor's IP and User-Agent, because it lists only 'the values you typed'. The form is also the only documented channel for security disclosures (contact.channel.security_body, translations.js:4162), so vulnerability details go into the same storage, with the same undisclosed retention. Whether and for how long Formspree keeps submissions is an external fact I could not verify offline (see research questions). The defect is that the notice's retention and deletion statement has no mechanism behind it for the processor copy.

Failure scenario. A visitor submits the form and later requests erasure. The maintainer deletes the mailbox thread as the notice describes, the visitor is told the data is gone, and the submission (with IP) may stay with Formspree under Formspree's own retention. The visitor relied on the notice's 'not retained' promise when deciding to submit.

Where.

Evidence.

  • main.js:237 fetch(form.action, { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } }); EN body: 'We do not add it to a mailing list, do not share it with third parties, and do not retain it after the conversation is resolved.' and 'deletion is straightforward: we delete the email thread.' The TR/DE/FR/ES/ZH bodies make the same promises.

Recommended fix. State that Formspree processes submissions as a processor (and cite the DPA if one is signed). Disclose that IP and User-Agent reach Formspree. Give a retention period that covers the Formspree copy, and add a step to delete the Formspree record to the erasure procedure (or turn off submission archiving in Formspree, if the plan allows it).

TECH-T16-003 — Site and browser stack: Formspree form endpoint and the site's contact-form privacy statements (site/contact.html:138, site/js/main.js:237, site/js/translations.js:4345) (incorrect-claim)

severity Next release

Item. Formspree form endpoint and the site's contact-form privacy statements (site/contact.html:138, site/js/main.js:237, site/js/translations.js:4345) · verdict: incorrect-claim · urgency: Next release

State in ForgeLM. site/contact.html:138 posts to https://formspree.io/f/xnjlejbb, and site/js/main.js:237 sends the form by fetch POST as FormData (name, email, topic, organization, subject, message). The form is the advertised security-disclosure channel: contact.html:159 and 220-221, translations.js:4162. The repo has no SECURITY.md (none at the root or under .github/). privacy.body (translations.js:4345 plus 5 mirrors) says the values are 'sent to Formspree, which forwards them to the project maintainer', defers to Formspree's policy, and says ForgeLM does 'not share it with third parties, and do[es] not retain it after the conversation is resolved' ('deletion is straightforward: we delete the email thread'). It also says only two localStorage values are stored, but site/js/wizard.js:67 and :316 persist a third key, forgelm.wizard.state, on quickstart.html. contact.form.intro (translations.js:4141) says 'no list, no tracking'.

State of the ecosystem on 2026-10-01. These were re-fetched today. Formspree's Privacy Policy is unchanged ('Last updated: April 24, 2022'). It collects names and emails plus IP address, browser type, domain names, access times and referring website, and 'may combine' them with partner data. It uses information 'in the United States'. Retention is 'as long as is necessary' or while the account is active, and log files are retained. Form owners are called 'independent controllers' under European law. The Security page says Formspree is hosted on AWS in the United States, uses AES-256 at rest, holds SOC 2 Type 2, and 'We rely on Standard Contractual Clauses (SCCs) as a data processor'. The subprocessor list has 22 entities, all in the US except Chartmogul (DE); among them are Amazon, Cloudflare, Google, Mailgun, OpenAI, Posthog, SendGrid and Sentry. The plans page gives a Submission Archive of 30 Days on Free and Unlimited on paid plans, and the help docs updated 2025-12-22 say the same. No public DPA exists: /legal/dpa, /legal/data-processing-addendum and /legal/data-processing-agreement all return 404. New today: the US Department of Commerce participant record shows Formspree, Inc. as an Inactive Participant. Its EU-U.S. Data Privacy Framework status is 'Inactive - Withdrawal' (originally certified 4/23/2019, inactive since 4/26/2022), so the DPF adequacy decision does not cover transfers to Formspree. They rest on SCCs alone. The Terms of Service were last updated 2022-12-20.

Impact. The privacy text is wrong or incomplete on what happens to contact-form data. Formspree keeps every submission in its US archive (30 days or unlimited, depending on the plan) and keeps logs with IP and browser metadata. Its transfer basis is SCCs, not the DPF. No public Art. 28 DPA exists, and Formspree's own documents call itself both 'processor' and the form owner an 'independent controller'. 'We delete the email thread' therefore does not delete the processor-held copy. The localStorage list also omits the wizard state key. Security-vulnerability reports go to the same US third-party archive (a design-risk under TM-v1, family 11), and the repo has no SECURITY.md alternative.

Recommendation. Rewrite the contact-form and storage sections of privacy.body in all 6 locales. State that Formspree (US, AWS) stores submissions and technical metadata, the archive retention of the plan actually used, and the transfer basis (SCCs). Add the forgelm.wizard.state localStorage key. Either purge the Formspree archive when a conversation closes and say so, or drop 'do not retain'. Obtain and record Formspree's DPA/SCC terms by request. Move security disclosures to GitHub private vulnerability reporting with a SECURITY.md, and tell reporters not to send exploit details through the form. Consider mailto or a self-hosted endpoint if a no-third-party stance is the goal.

Sources.

DOC-D4B09-014 — site/js/translations.js: false claim — 'Nothing [is collected]. No cookies are set. Two values are stored in localStorage on your device' (forgelm-lang, forgelm-theme)

severity High · already tracked: KNOWN-OPEN DOCS-03 docs/roadmap/phase-16-trust-surface-hardening.md:771-772,780-781

Where. site/js/translations.js:4345 (privacy.body, 'From normal browsing') · type: false claim

The document says. 'Nothing [is collected]. No cookies are set. Two values are stored in localStorage on your device' (forgelm-lang, forgelm-theme)

What the code does. A third localStorage key, forgelm.wizard.state, is written by the in-browser wizard on every input change (site/js/wizard.js:67,316; loaded by quickstart.html). It holds everything typed into the wizard: model, dataset paths, governance text, provider and system names, webhook and monitoring URLs. It persists until the user resets the wizard or clears site data.

Code references. site/js/wizard.js:67, site/js/wizard.js:310-320, site/js/wizard.js:1247

User impact. The privacy policy under-states what is stored. Values that can include webhook URLs (secrets) and organisation data sit in the browser indefinitely, without disclosure. The data never leaves the device, but the policy is inaccurate.

Proposed correction. Change to 'Three values are stored in localStorage on your device', and add: 'forgelm.wizard.state: your in-progress answers in the quickstart wizard (model, dataset, compliance fields, any webhook URL you type), kept so a refresh does not lose progress. Use Reset wizard or clear site data to remove it.' Also advise not to type secrets into the wizard.

Acceptance criteria

  • A regression test reproduces the failure scenario and asserts the corrected behaviour.
  • Documentation listed above is corrected in English and in its Turkish mirror in the same PR.
  • Every finding in this issue is resolved or explicitly closed with a reason.

Found by the October 2026 ForgeLM code and documentation review against commit cab2947. Code links point at that commit; line numbers may have moved since. Finding IDs (CR-…, DOC-…, TECH-…) are stable references for this backlog.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: siteCode area: sitebacklog: knownAlready tracked in docs/roadmapbugSomething isn't workingdependenciesDependency or ecosystem currencydocumentationImprovements or additions to documentationseverity: highWrong behaviour or a false claim users rely onsource: review-2026-10Found by the October 2026 code and documentation reviewurgency: next-releaseTechnology currency: fix in the next releasewave: 1Roadmap wave 1: high severity, small effort

    Type

    No type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions