Reviewed: 2026-10-19 (round one, the code up to N6) and 2026-10-20 (round two, the modules added since - see §7). The findings and the fixes are below. This is a working document, not a one-off: new findings go here.
Every finding is pinned by a test, so it cannot come back unnoticed.
| # | Severity | Finding | Status |
|---|---|---|---|
| S1 | High | The control token was stored in chrome.storage.sync |
Fixed |
| S2 | Medium | The control server sent Access-Control-Allow-Origin: * |
Fixed |
| S3 | Medium | The control server did not validate the Host header (DNS rebinding) |
Fixed |
| S4 | Low–Med | The extension requested the unused webRequest permission |
Fixed |
| S5 | Low | The control server returned internal error text to the client | Fixed |
| S6 | Low | dest_dir from the extension payload was not validated |
Fixed |
| S7 | Low | "Open File" ran a downloaded executable/script without asking | Fixed |
| S8 | Low–Med | A Cookie: header set by hand was dropped on every redirect |
Fixed |
| S9 | Info | The proxy password and the token are stored in plain text | Documented |
| S10 | High | A playlist could send the user's Authorization header to any host, and could point FreeDM at the local network |
Fixed (round two) |
| S11 | Medium | The native host accepted a call from any installed extension | Fixed (round two) |
| S12 | Medium | A category name was used as a folder name without sanitising | Fixed (round two) |
| S13 | Low | A proxy URL that already had credentials in it was mangled | Fixed (round two) |
| S14 | Low | The update check could be pointed at any URL | Fixed (round two) |
The most security-sensitive component: it listens on 127.0.0.1 and accepts
requests from the browser extension.
| Check | Result |
|---|---|
| Binds to loopback only | Good — 127.0.0.1, never 0.0.0.0 |
| Token strength | Good — secrets.token_urlsafe(32) (256 bits) |
| Token comparison | Good — hmac.compare_digest (constant time) |
| Unauthenticated requests | Good — rejected before the body is parsed |
| Request body size | Good — capped at 1 MiB |
| SQL injection | Good — the queue store uses bound parameters only |
| CORS | S2 — fixed |
Host header |
S3 — fixed |
| Error messages | S5 — fixed |
dest_dir from the payload |
S6 — fixed |
S1 (High) — the token was in chrome.storage.sync.
chrome.storage.sync uploads the value to the user's Google account and to
every browser signed in to it, and it is not end-to-end encrypted. The control
token authorises queueing arbitrary downloads in FreeDM, so it should not
leave the device. Fixed: the extension now uses chrome.storage.local,
which never leaves the machine.
S2 (Medium) — Access-Control-Allow-Origin: * on every response.
Any web page the user opened could make cross-origin requests to
http://127.0.0.1:6801 and read the answers: it could tell whether FreeDM was
running (and which port and version it used), and the endpoint behaved like a
public, cross-origin-reachable service on the machine. Fixed: the CORS
headers are now only sent for the extension's own origins
(chrome-extension://, moz-extension://); an ordinary web page gets no CORS
headers at all, so the browser blocks the response. The extension does not
need CORS — an MV3 service worker with host_permissions talks to loopback
directly.
S3 (Medium) — no Host header validation (DNS rebinding).
A web page could point a hostname it controls at 127.0.0.1 and have the
browser send requests to the local control server with an attacker-chosen
Host header. The token still protected /add, but a localhost service
should not answer requests addressed to another name. Fixed: requests whose
Host is not 127.0.0.1, localhost or [::1] (with any port) are
rejected with 403.
S5 (Low) — internal error text was returned to the client.
A failure inside the handler produced {"error": "<python exception>"}, which
can leak paths and internals to any local process. Fixed: the client now
gets a generic message; the detail stays in the app.
S6 (Low) — dest_dir from the payload was used unchecked.
A payload could point a download at any folder. It required the token, so it
was inside the trusted-extension boundary, but there is no reason to accept a
path that is not a folder. Fixed: the value is ignored unless it is an
existing directory.
| Check | Result |
|---|---|
| Token storage | S1 — fixed (storage.local, not storage.sync) |
| Permissions used | downloads (cancel/erase), cookies (handoff), storage (settings) — all needed |
| Permissions unused | S4 — fixed (webRequest removed) |
| Script injection in the options page | Good — the status text is set with textContent, never innerHTML |
| Intercepts its own downloads | Good — item.byExtensionId is ignored |
| Incognito downloads | Good — left alone on purpose |
| Redirect target of the handoff | Good — the browser's copy is only cancelled after FreeDM accepted the job |
S4 (Low–Medium) — the webRequest permission was requested but never used.
webRequest lets an extension observe and modify every request the browser
makes. The code only uses chrome.downloads and chrome.cookies. An
unnecessary permission is unnecessary risk. Fixed: removed.
host_permissions stays <all_urls> because the cookies API can only read
the cookies of a site the extension has host permission for — that is the
point of the handoff.
| Check | Result |
|---|---|
| Path traversal through the file name | Good — / and \ are replaced, .. cannot escape the destination (regression tests added) |
| Overwriting an existing file | Good — renames by default |
| Atomic state writes | Good — tmp + os.replace |
| TLS verification | Good — on by default |
| Credentials on redirect | Good — requests 2.34 strips Authorization on a host change and pops Cookie on any redirect |
| Redirects to another protocol | Good — requests will not follow a redirect to file:// |
User-set Cookie: header |
S8 — fixed |
S8 (Low–Medium) — a hand-written Cookie: header was dropped on every
redirect. The Headers... dialog lets the user set Cookie: (and the
extension hands over cookies for the browser's session). requests removes the
Cookie header when it follows a redirect and rebuilds it from the cookie
jar — but a header set by hand is not in the jar, so it was silently lost and
the download could fail with a 401. Fixed: a hand-written Cookie: header
is now put into the session's cookie jar, so it survives redirects and is
scoped to the host it belongs to.
| Check | Result |
|---|---|
| Shell injection | Good — subprocess is always called with an argument list, never shell=True |
| The power command | Good — a fixed list, no user input |
| The proxy password | S9 — documented (plain text in the queue database) |
| Opening a downloaded file | S7 — fixed |
S7 (Low) — "Open File" ran executables and scripts without asking.
A download manager by nature handles dangerous files, but launching one should
not be a single click. Fixed: opening a file with an executable or script
extension (.exe, .bat, .cmd, .com, .ps1, .vbs, .js, .jar,
.scr, .msi, .lnk) asks for confirmation first.
| Check | Result |
|---|---|
| SQL injection | Good — every statement uses bound parameters |
| Database location | Good — %APPDATA%\FreeDM\freedm.db, per user |
| Token file | Good — created atomically, per user; read only by that account |
| Dependency licences | Good — all permissive (Apache-2.0 / MIT / BSD-3 / MPL-2.0); PyInstaller is a build tool only |
The dist/ build |
Good — the published SHA-256 is in the release notes |
- The threat model assumes a single-user, non-shared Windows account. On a shared account the plain-text token, proxy password and queue database are readable by the other users of that account.
- No audit log: there is no record of who queued which download.
- The app does not verify TLS certificates out of the box — it does, but the API allows turning it off, and the GUI does not expose that switch.
Round one did not cover the modules added afterwards (the network options, the native host, the update check, the log file, streaming, the AES code and the category rules). They are reviewed in §3.
The first review covered the code up to N6. Seven modules were added after it
and had never been looked at: network.py, native_host.py, update.py,
logsetup.py, stream.py, aes.py and categories.py. They are reviewed
here.
A playlist is data from a third party: the user chooses the playlist's address, but the playlist decides where the segments come from. That is a trust boundary, and two things could go wrong.
S10 (High) — the user's own headers followed the playlist to any host.
The engine sent self._session_headers() — which includes any Authorization
or Referer the user set — to every segment URL. A hostile playlist could
therefore point one segment at a host the user never chose, and FreeDM would
hand that host the user's Authorization header. It could also point
FreeDM at an address on the user's own network (a router, a NAS, the app's own
control server) and make it fetch it.
Fixed in two parts:
- The user's headers only go to the playlist's own host. A segment on any
other host is fetched without them (
_stream_headers). The session's own default headers (the user agent) still go everywhere, which is harmless. - A URL policy for the playlist (
check_segment_url): onlyhttpandhttpsare allowed (afile://orftp://URL is refused with a clear message), and a segment on a private address (loopback, link-local,10/8,172.16/12,192.168/16, or a name that resolves to one) is refused unless the user has ticked "Allow segments from private addresses". The playlist's own host is always fine — the user chose it. A name is resolved once and the answer cached, so a host that points at the local network is caught too.
The host is started by the browser and can do exactly one thing: start FreeDM.
| Check | Result |
|---|---|
| What it can do | Only ping and launch — it never touches the queue |
| The message format | The browser's own (a length-prefixed JSON) |
| Who may call it | S11 — fixed |
S11 (Medium) — any installed extension could call the host. The manifest
listed chrome-extension://* and moz-extension://*, so every extension on
the machine could start FreeDM. The wildcard is the only option for an
unpacked extension (its id is different on every install), but it should be a
choice, not an accident.
Fixed: the extension now sends chrome.runtime.id with every handoff,
FreeDM remembers it, and the manifest is then written with that extension
only. The wildcard remains as the fallback, and the manifest carries a
_note saying so - so anyone reading the file can see that the host is
open until the extension has talked to FreeDM once.
| Check | Result |
|---|---|
| When it runs | Only when the user presses the button |
| What it fetches | The project's own VERSION.json |
| What it sends | Nothing about the user - one plain GET |
S14 (Low) — the URL was a parameter. check_for_update(current, url=...)
accepted any URL, so a mistake (or a bad call) could have pointed it anywhere.
Fixed: the URL is fixed to the project's own file; the function no longer
takes one.
| Check | Result |
|---|---|
| Is it on by default | No — the user ticks the box in the About dialog |
| Where does it go | %APPDATA%\FreeDM\freedm.log (per user) |
| How big does it get | 2 MB, then it rotates; three old files are kept |
| What does it hold | The app's own events - never URLs, file names, cookies or tokens (a test checks the source) |
| Check | Result |
|---|---|
| Where the password lives | The settings database, in plain text (S9) - documented |
| Is the password ever shown | No - summary() leaves it out, and a test checks it |
| Is the proxy URL ever logged | No |
| Does the URL survive a password in it | S13 — fixed |
S13 (Low) — a proxy URL that already had credentials in it was mangled.
http://user:pass@proxy:8080 had its user:pass thrown away when a user
name was also configured, and the path was rebuilt by hand. Fixed: the URL
is parsed properly, any existing userinfo is replaced rather than appended
to, and the path is kept.
S12 (Medium) — a category name was used as a folder name, unsanitised.
The rules are the user's own, but the name becomes a folder, so a rule
called ..\..\Windows\System32 would have placed a download outside the
chosen folder, and a name with : or * would have broken the download with
a confusing error.
Fixed: safe_folder_name() replaces the characters that are illegal on
Windows, removes .. and falls back to Other. A test checks that the
destination always stays inside the base folder.
| Check | Result |
|---|---|
| Is it correct | Yes - it matches the FIPS-197 test vector and a reference implementation (both are in the tests) |
| Does it touch the disk or the network | No - it is pure arithmetic |
| Is a wrong answer dangerous | No - a wrong decryption produces a file that fails the size/checksum verification, it does not produce a valid wrong file by accident |
- The control server must never send
Access-Control-Allow-Origin: *, and must reject aHostheader that is not loopback. - The control token belongs in
chrome.storage.local, never insync. - Do not add a browser permission that the code does not use.
- Anything that reaches the file system must go through
sanitize_filename(orcategories.safe_folder_namefor a folder name). - Secrets belong in the cookie jar, not in raw headers.
- Executables are never launched without an explicit confirmation.
- Data that comes from a server (a playlist, a manifest) must not decide where FreeDM connects, and must never carry the user's own headers to a host the user did not choose.
- A name that becomes a path must be sanitised, and a URL that comes from elsewhere must be checked before it is fetched.
- The native host must name the extension that may call it, or say plainly that it does not know yet.
- A secret (a proxy password, a token) is never written to a log, and never shown in full in the interface.