Security
Every security defect we have found in Cloqd, what it could have done, and the commit that fixed it.
“We found and fixed this” is a stronger claim than “we have never had a problem” — and it is the only one of the two that can be true. Software nobody is auditing has no findings because nobody looked.
Found something? Tell us privately.
Email security@aliasvault.io. Please give us a chance to fix it before publishing. We will not threaten you, and we will credit you here if you want the credit.
You will hear back from a person within 5 working days. After 90 days from your report, publishing is your call whether or not the fix has shipped. Any reward is credits, at our discretion — there is no cash bounty. The full policy, with what is in and out of scope, is the SECURITY.md file at the root of the code repository; it states the same numbers.
The machine-readable version is at /.well-known/security.txt. That address bounced for a while, which is itself an entry below.
What the severities mean
Deliberately coarser than a numeric score: three words a reader can hold in their head beat a decimal whose inputs are not shown.
- Critical
- Could have let someone take over an account, read another user’s data, or create money in the product — without needing anything they were not freely given.
- High
- Could have exposed data or defeated a protection, but needed an account, a specific setup, or a user action first.
- Moderate
- Weakened a control or leaked low-sensitivity information. Real, worth fixing quickly, not an emergency.
What we found
904b483A password-reset link would have been written to the server log on a self-hosted copy
- What it was
- When no email provider is set up, the site is supposed to refuse loudly rather than pretend a message was sent. That refusal was decided by asking whether one setting said “production”, so a copy of the site running somewhere that never set that setting at all took the other branch instead: it printed the reset link — a working, single-use way into the account — to the server’s own log, returned normally, and told the person their email was on its way. This is the third place the same question was asked the wrong way round, and the first two were fixed a day earlier; this one was asked in the opposite direction and the search that found them missed it.
- What it could have done
- On a host in that state, anyone able to read the server logs could use a reset link to take over an account, and the person who asked for the reset would wait for an email that was never going to arrive. Logs are routinely shipped to other systems and read by people who are not administrators, so “it is only in the log” is not the reassurance it sounds like.
- How it was fixed
- Both places now ask the positive question: the link is only ever printed where the setting explicitly says “development”. Anything else, including a host that never sets it, refuses and raises an error the operator can see. A test asserts there are exactly two such branches and that no version of the older, wrong-way-round question survives anywhere in that file.
- What we know about real-world impact
- Not exposed on this site. Our hosting platform sets that setting itself, so the live site has always taken the refusing branch, and no reset link has been printed to a log. Published for the same reason as the two entries it belongs with: the condition that triggers it is running this software on our own server, which we have actively considered.
Found by: A fresh-context reviewer re-reading the previous day’s fixes, who noticed the same mistake written backwards
a05d6e8There was no way to sign a lost device out without changing your password
- What it was
- A sign-in lasts seven days and every visit pushes that deadline out again, so a browser that stays in use never expires. The only thing that ended one was changing the password, which ends all of them at once. The settings page did show an “Active Sessions” panel, but it was fixed text: it read “Current Session — This device” to everyone, whether one device was signed in or nine. It was not showing you anything.
- What it could have done
- Someone whose laptop or phone was lost, or who signed in on a machine belonging to someone else, had no way to cut that access off short of a password change — and no way to find out it was still open, because the panel that appeared to answer that question was not connected to anything. The stale sign-in stayed valid for as long as it kept being used.
- How it was fixed
- The panel now lists the sign-ins that are actually live, with the kind of device and when each was last active, and each one can be ended on its own. There is also a single control that ends every other sign-in and leaves the one you are using. Ending a session takes effect immediately and does not touch your password. What is deliberately not shown: the sign-in’s own secret, which is never sent to the page at any point; and the network address, because we only ever keep a scrambled form of it, which would look like information without being any. The device is described as a phone, tablet or computer — from today that is all we store about it, where before we kept the full browser identification string. That was the last place in the product still holding one.
- What we know about real-world impact
- We have no reports of a stale sign-in being misused, and no way to have detected one. The change also means the full browser identification strings previously recorded against sessions stop being collected; existing ones are reduced to a device kind when they are shown.
Found by: Internal review, working through what an account can and cannot do about its own security
a05d6e8The switch that turns two-factor off could be attacked far faster than the rest
- What it was
- Turning two-factor authentication off requires your password. The endpoint that does it had no specific limit on how often it could be tried, so it fell back to the general one — thirty attempts a minute — while the endpoints that check a two-factor code allow ten in five minutes. Someone holding a stolen sign-in but not the password had, in that one request, the fastest way to guess it anywhere in the system, and success would have removed the second factor entirely. A separate setting in the two-factor system could also mark a browser as trusted for thirty days, skipping the second factor on every later sign-in. Nothing in our interface has ever offered that, but the setting could be reached by sending the request directly.
- What it could have done
- Both require an attacker to already hold a valid sign-in for the account. From there, the first was a fast way to grind at the password with the second factor as the prize, and the second was a way to switch that factor off for a month on their own machine without needing the password at all.
- How it was fixed
- Turning two-factor on or off is now limited to three attempts per fifteen minutes, in line with the other password-protected operations. The trusted-browser setting is now refused outright rather than quietly ignored — a request asking for it gets a clear error. Refusing rather than ignoring is deliberate: had we simply dropped the setting, an interface offering a “remember this device” option later would have appeared to work and silently done nothing.
- What we know about real-world impact
- No evidence of either being used. We can see that our own interface has never sent the trusted-browser setting, because it contains no code that could.
Found by: Internal review, checking which endpoints the rate limits actually reached
bf34194A safety check on where our server would connect could be stepped around
- What it was
- Two features make our server fetch something from an address a person supplied — the media proxy that serves a profile page’s background and music from our own domain, and the header-inspection tool. Both looked up the name first and refused it if it pointed anywhere inside our own network. But the connection that followed looked the name up a second time, independently, and used whatever came back. Someone who controls a domain’s name records can answer the first lookup with an ordinary public address and the second, moments later, with an internal one.
- What it could have done
- A successful attempt would have made our server fetch something from inside its own hosting network rather than from the internet, and hand back whatever came out. That is the class of problem that ends in reading a server’s own configuration, so it is worth taking seriously — while being clear about the difficulty: it requires controlling a domain and winning a race measured in milliseconds, and the internal address that would be worth reaching is separately blocked by name as well as by address.
- How it was fixed
- The check now decides the connection instead of merely preceding it. The addresses it approves are the only ones the connection is permitted to reach, and a name it never approved cannot open a connection at all. Each step of a redirect chain is approved and pinned separately, so a redirect cannot inherit the previous step’s permission. The code that both files had carried for months said this could not be done with the tools available; that was wrong, and the note saying so has been replaced with the mechanism. It is verified by a test that opens a real connection to a name with no address records anywhere and confirms it arrives where the check said — which could only fail if the pinning were not in effect.
- What we know about real-world impact
- No evidence of any attempt, and we would not necessarily have seen one. Two related paths are still unfixed and are named in our own notes rather than quietly left: the certificate-inspection tool, which connects by a different mechanism this fix does not reach, and the inbound-attachment fetcher, whose addresses come from our mail provider’s own authenticated interface rather than from anyone who can send us mail.
Found by: Internal review, working through the residual risks this codebase had written down and accepted
bf34194One account could accumulate an unlimited number of permanent fetch links
- What it was
- When a signed-in person types a media address into the profile builder, we hand back a link on our own domain that fetches it for them. Those links never expire and anyone who has one can use it. There was a limit on how FAST an account could create them — thirty a minute — and no limit at all on how many it ended up holding. Sustained, that is roughly forty thousand permanent links a day, none of which ever goes away.
- What it could have done
- An account behaving this way could have built a large standing collection of links that make our server fetch third-party addresses on demand — using our bandwidth and our name, and letting whoever holds the links fetch things without revealing themselves. It is an abuse-of-service problem rather than an exposure of anyone’s data.
- How it was fixed
- There is now a ceiling on how many distinct addresses one account has live at once, over a rolling window, separately from the speed limit — the two measure genuinely different things and neither implies the other. Re-using an address the account already has is always allowed, because it produces the same link and adds nothing. Only what an account previews in the builder is counted; the links generated when someone’s own profile page is displayed are not, because those come from what they already saved and are limited by the page itself. What is recorded is a short fingerprint of the address, never the address.
- What we know about real-world impact
- No account has approached the ceiling. We did not previously count this, so we cannot say anything about the period before the counting existed.
Found by: Internal review; the speed limit was added earlier and the gap it left was written down at the time
9a7fc13Two cross-site guards would have trusted a program on your own computer, on a self-hosted copy of the site
- What it was
- The switch described in the entry below — “is anything other than the word production” instead of “does it say development” — turned out to be in two more places, and these two were not about page-rendering rules. Both name the web addresses the site will accept a request from: one decides who may read a reply that was produced while you were signed in, and the other is the check that stops another site driving an account change on your behalf. On a copy of the site running somewhere that never set that setting, both lists silently gained the two addresses a developer’s own machine serves the site on while working on it.
- What it could have done
- Only a program already running on your own computer, listening on one of those two addresses, could have used this — not an ordinary web page from somewhere else. Such a program could have read a reply meant only for you, and, on the second list, driven a change of email address or password, or the deletion of the account, using the sign-in you already had. It required something to be running locally that you did not put there, which is a serious situation on its own; this removed one of the barriers that would still have stopped it.
- How it was fixed
- The question is asked once now, in a single place, and asked positively: these relaxations apply only where the setting explicitly says “development”. All four switches that had this shape — the two here, the page-rendering one below, and one that was already correct — read that one answer, so a future fix cannot reach one and miss the others. That was the actual failure the first time: the same mistake existed in three places, one was fixed, and the other two went on being written the old way. A search for the pattern across the whole codebase now returns nothing but the comments explaining it.
- What we know about real-world impact
- Not exposed on this site. Our hosting platform sets that setting itself, so the live site has never carried the extra addresses, and no request has ever reached the site from them. As with the entry below, it is published because the condition that triggers it is a move we have actively considered.
Found by: Internal review, sweeping for every remaining copy of a defect after fixing the first one
9a7fc13Loading a profile page could have made unrelated parts of that domain unreachable in your browser
- What it was
- Profile pages are served on several domains that are not this product — one of them carries the operator’s own email. Every response the site sent, on every domain, carried an instruction telling your browser “refuse plain, unencrypted connections to this domain and everything under it, for the next year”. That is a fair claim for our own site. On the other domains it is a claim about someone else’s subdomains, and the page itself already sent a narrower version — but the small script and style files the page loads afterwards were served by a different path in our code that never got the correction, and the last instruction a browser receives is the one it keeps.
- What it could have done
- A visitor who opened one profile page could have been left unable to reach any subdomain of that domain over a plain connection for a year — a device’s web panel, an older tool, anything not yet on encrypted connections. There is no way to undo it from our side: shortening the period only reaches a browser that makes another encrypted request first. It is not a data exposure. It is us making a durable promise, in your browser, about domains where we had no standing to make it.
- How it was fixed
- The instruction is now attached only to the domains this product actually is, named one at a time, rather than to every domain the deployment answers on. The other domains receive the narrower form from the page and nothing re-widens it afterwards. Verified by requesting the same file with each domain in turn and reading the header that came back.
- What we know about real-world impact
- We cannot tell how many browsers received it, because the header leaves no trace on our side. Anyone affected is affected until the year elapses or their browser data is cleared.
Found by: Internal review; the gap was written down in our own code as a known remaining one and had not yet been closed
9a7fc13One page could be embedded invisibly inside another site
- What it was
- Every page on the site tells browsers who is allowed to display it inside a frame on another site; the answer is essentially “nobody but us”. One page was missing that instruction entirely — the plain list of profiles on a given domain — because of how our request-handling rules are matched: covering it would also have caught every image and icon the site serves, so it had been left out.
- What it could have done
- Another site could have loaded that page invisibly over its own and tricked a visitor into clicking something on ours while believing they were clicking something else. The page in question only lists public profiles and takes no action, so there was little of value to steal a click for; the defect is that the protection every other page has was absent here.
- How it was fixed
- The instruction is now sent for that page shape directly by the server, separately from the rule that could not reach it. Only that one instruction is sent, because the others need a value generated fresh for each visit that this mechanism cannot carry — and sending a weakened version of them would have been worse than the honest gap. Verified by requesting the page and reading the header.
- What we know about real-world impact
- No evidence of any attempt. We have no logging that would show one, and say so rather than claiming none happened.
Found by: Internal review, working through header coverage page class by page class
8473273A browser protection would have been relaxed on a self-hosted copy of the site
- What it was
- The site tells your browser a strict set of rules about what code it may run and how it must fetch things. Two of those rules were deliberately loosened while a developer works on the site locally. The switch that decided “are we local?” asked whether one setting was anything other than the word “production”, rather than asking whether it said “development” — so a copy of the site running somewhere that never set that setting at all would have counted as local and been served the loosened rules.
- What it could have done
- On a host in that state, the browser would have been allowed to build and run code from text at page load, and would no longer have been told to upgrade any insecure link to a secure one. Both make an injected-script bug easier to exploit than the strict rules allow. It changes nothing on its own — it removes a layer that exists to contain a different mistake.
- How it was fixed
- The switch now asks the positive question: the relaxations apply only where the setting explicitly says “development”. Anything else, including a host that never sets it, gets the strict rules. The identical mistake had already been fixed on a neighbouring switch in an earlier release, and the comment explaining why sat directly beneath this one. The test that covered this could not have caught it — it asked the same question the code asked, so it agreed with the code whatever the code said. It now checks the actual rules produced for each value of that setting, including when it is missing, and was confirmed to fail against the old behaviour before being kept.
- What we know about real-world impact
- Not exposed on this site. Our hosting platform sets that setting to “production” itself, so the live site has always served the strict rules, and we have no evidence any visitor ever received the loosened ones. It is published because it was a real defect and because the condition that triggers it is a move we have actively considered: running this software on our own server, where nothing sets that setting for you.
Found by: Internal review, working through the list of controls that fail open rather than closed
3f3f71cSign-in and session checks answered an error for everyone for about seventeen hours
- What it was
- Every request to the authentication endpoints — signing in, checking a session, the second-factor step, password reset — failed with a server error. A hardening change earlier in the week had wrapped those endpoints to re-verify the visitor’s address on each request, and did so by constructing a new request object from the one the framework hands us. When the hosting platform moved to a newer runtime under the unchanged code, that construction began to throw before the authentication library ran, because the runtime and the framework no longer shared one request class.
- What it could have done
- An availability defect on the sign-in path, not a data one: nobody could sign in, and anyone already signed in saw the dashboard fail to load their session. Published here for the same reason as the bouncing security contact — a door that will not open is a defect in the product even when nothing behind it was exposed.
- How it was fixed
- The wrapper edits the headers of the request the framework built and hands that same object on, constructing nothing, so there is no second class for a constructor to reject. A test runs the wrapper against the framework’s real request type for both request shapes and pins that the route never builds a request from another request again. The condition itself cannot be reproduced on the development machine, whose runtime is older; the proof was the live endpoints answering correctly after the deploy, and a real sign-in.
- What we know about real-world impact
- Measured: the platform’s error table records the first failure at 2026-09-05T21:06Z and the last at 2026-09-06T14:19Z, sixteen occurrences across four visitors, on every request to those endpoints in that window; the fix deployed at 2026-09-06T14:24Z. Every request failed closed with an error, so nothing was signed in that should not have been and nothing was exposed. The development gate did not catch it because the gate runs on an older runtime than production, which is a gap in the gate and recorded as one.
Found by: The operator, unable to sign in; confirmed from the hosting platform’s error table
dfffbb2Server error logs carried the alias addresses of failed deliveries
- What it was
- When a database write failed, server code logged the whole error object. The database layer puts the query parameters into that error text, so a failed inbound delivery wrote the alias address it was for — the local part and the domain — into the hosting platform’s log. The same shape existed at dozens of other logging sites, each one a different piece of data away from doing the same.
- What it could have done
- The one pairing this product exists to keep out of any log — which alias a real person receives mail at — was written into a log the operator and the hosting platform can read. It was not exposed publicly, but a log is a copy we do not control the retention of and had promised not to make.
- How it was fixed
- One logging helper that writes the error’s name, a classified reason, the database or system error code and a correlation id — never the message or the stack unless an operator turns detail on explicitly. A source-level test walks every server module and fails on the old shape, so the class stays closed rather than the four sites that were found.
- What we know about real-world impact
- This one was demonstrably exercised: the log from the 2026-08-29 outage carried the alias addresses of every delivery that failed during it. Only the operator and the hosting platform could read that log. The platform’s log retention is short and we cannot recover what it held, so we cannot say how many addresses were written over the whole period the shape existed.
Found by: Internal review of the log written during the 2026-08-29 delivery outage
8e5f39aDeleting an account required no password
- What it was
- The account-deletion dialog sent no proof of possession. Being signed in was enough, so anyone holding a valid session — a stolen cookie, a shared machine left signed in — could delete the account and everything in it.
- What it could have done
- Irreversible loss of the account, its aliases and its stored mail, by someone who had a session but not the password. Deletion is the one action where a hijacked session is worse than a stolen password: there is nothing to reset afterwards.
- How it was fixed
- Deletion now requires the account password, and an account set up without one proves possession with its recovery key instead. Both paths are pinned by tests, and the authentication library’s own docstring was rewritten to say what the interface now sends rather than what it used to.
- What we know about real-world impact
- No deletion is known to have been performed by someone other than the account holder. We cannot tell retroactively: a deletion leaves no record of whether the person at the keyboard was the owner.
Found by: Internal audit
8e5f39aA moderation takedown could be undone by flipping one flag back
- What it was
- A takedown sets two flags on a bio link: inactive and revoked. The public page and the domain directory checked only the first. The earlier fix that made takedowns stick stopped the owner from editing or deleting a revoked link, but a revoked link whose active flag was set again by any other path would have rendered.
- What it could have done
- Moderated content republished by one boolean, with the takedown record still saying it was down. A control that is enforced in one place and assumed everywhere else is a control with a gap the size of everywhere else.
- How it was fixed
- Both public render paths filter on the revoked flag in the database query itself, beside the active flag, so a revoked page cannot render whatever the other flag says. A test renders a revoked link through the public route and asserts nothing comes back.
- What we know about real-world impact
- No revoked page is known to have been shown. Moderation volume on the product to date is very low, and no path in the product sets the active flag on a revoked link, so this needed a second defect to be reachable.
Found by: Internal audit, re-reading the earlier takedown fix for the paths it did not cover
8e5f39aThe audit trail recorded whatever address the audited person claimed
- What it was
- Every sensitive action writes an audit row with a digest of the caller’s address. That address was read from the first hop of a forwarding header, which is the one element of it a client fully controls. Every other place that reads the client address had already moved to the helper that applies the trust chain; this one had not.
- What it could have done
- Someone doing something they should not could choose what the trail said about where they were, and could make two of their own actions look like they came from different places or a stranger’s look like it came from theirs. The row itself was still written — this was the location field, not the record.
- How it was fixed
- The audit writer reads the address through the same trusted helper as every other site, and a source-level test pins the writer against reading the raw forwarding header again.
- What we know about real-world impact
- We cannot tell whether any recorded address was forged; a forged value is indistinguishable from a real one after the fact. Independently, the addresses recorded during this period were largely the proxy’s rather than the visitor’s, which is a separate published limit and not a forgery.
Found by: Internal audit — a census of every place the client address is read
8e5f39aA newline in a user-supplied value could split a mail header
- What it was
- The sanitizer applied to values that end up in outbound mail stripped tags and trimmed whitespace but left carriage-return and line-feed characters in place. A header field is terminated by exactly those characters, so a value containing one could end the field early and start another.
- What it could have done
- Header injection on mail we send: an additional header, and in the worst shape an additional recipient, appended by whoever controlled the injected value. The messages this product sends on a user’s behalf are exactly the ones where a forged header would carry our domain’s reputation.
- How it was fixed
- Line breaks are stripped from every string the sanitizer passes, and again at the single function every outbound message leaves through, so a value that reaches a header cannot contain one whichever path it took. Nested structures now keep their shape through the sanitizer as well, which an earlier version had been silently flattening.
- What we know about real-world impact
- No injected message is known. Outbound mail is logged by identifier, not by content, so this cannot be checked retroactively — and we are not going to start keeping message content in order to be able to.
Found by: Internal audit
e98fe03The AI endpoints could be made to fetch a URL of the caller’s choosing
- What it was
- Three AI routes — including the unauthenticated guest chat — accepted message parts of any shape, and only rewrote the text ones before handing them to the model library. A caller could include a file or image part carrying an arbitrary address, and a version of the library then in use would fetch it from our server. Two published advisories described exactly this.
- What it could have done
- Server-side request forgery from a public endpoint: our server could be pointed at internal addresses or at a third party, on the caller’s instruction, with our identity. A separate advisory in the same library covered resource exhaustion through the same part.
- How it was fixed
- The library was upgraded past both advisories the day they were assessed, and then the class was closed rather than the version: one schema for all three routes allowlists message parts by type, and any file, source or data part — or any part carrying a URL at all — is refused with a 400 before the model library sees it.
- What we know about real-world impact
- We have no request logs that record message parts, so we cannot say whether it was ever used. The guest route sits behind a per-address rate limit, which bounds volume but would not have prevented a single crafted request.
Found by: A dependency scanner flagged the advisory; internal review found the routes were reachable rather than dormant
cb8d62aAnother website could spend a signed-in user’s credits
- What it was
- The session cookie is sent on cross-site requests by design, so it reaches us from any page a signed-in user visits. Five routes that spend credits or upload files read the request’s origin only to build response headers and never checked it. The one route that did check accepted any origin on the hosting platform’s shared domains, which anyone can deploy to.
- What it could have done
- A page anywhere on the web could make a visitor’s browser call our paid generation endpoints with that visitor’s session, spending credits bought with real money, or upload an avatar into their account. The visitor would see nothing.
- How it was fixed
- Every state-changing API route checks that the request’s origin is ours before doing anything else, where ours means the configured origins, a configured brand host, or the request’s own host — never a platform-wide wildcard. Bodies must declare themselves as JSON before they are read, which closes the form-post shape that needs no preflight. A census test names a guard class for every API route and fails on a route that appears without one.
- What we know about real-world impact
- We cannot tell retroactively: the records we keep of a credit spend do not include which site the request came from. The credit ledger is reconciled nightly against recorded charges, which rules out free spending but not spending someone else did not intend.
Found by: Internal audit
e98fe03The guest chatbot’s only limit was per address, and the address was shared
- What it was
- The unauthenticated support chatbot was rate-limited per client address and nothing else. Behind the proxy that fronts the site, the address the limiter saw was the proxy’s own, shared by every visitor on that edge — so the limit was both easy to exhaust for everyone at once and, from another direction, no ceiling on the total bill at all.
- What it could have done
- An unbounded inference bill on the one AI endpoint that needs no account, and a denial of the chatbot to every visitor sharing an edge address with someone abusing it. A cost and availability problem rather than a data one.
- How it was fixed
- A global per-day cap counted in a shared store, checked before the request body is even parsed. Past the cap the route returns a fixed answer in the same shape as a real one, so the widget shows a reply rather than an error. An unset cap means the default, never unlimited.
- What we know about real-world impact
- We did not notice abnormal spend, but we have not checked the provider’s billing for this window against a baseline, and a billing-level check would in any case show sustained abuse and miss a small amount. That is an absence of alarm, not a measurement.
Found by: Internal audit
e98fe03The bounce-notification webhook accepted signed messages from anyone’s topic
- What it was
- The endpoint that will receive bounce and complaint notifications from our outbound mail provider verified the notification’s signature correctly, then compared the sending topic against a configured value only when that value was set. It was not set, so any genuinely signed notification — which anyone with an account at the same cloud provider can produce from their own topic — was accepted and recorded.
- What it could have done
- A stranger could write bounce or complaint records into our audit log for any address they named. Today those records are only recorded; the day a complaint disables forwarding, the same hole would have let a stranger disable anyone’s. The subscription-confirmation path two blocks above it already failed closed, so the file was safe in one branch and open in the next.
- How it was fixed
- An unset topic refuses every notification, matching the confirmation path. The fail-open comparison shape — check only if configured — is named in the code as the thing never to write on a secret or an identifier, and a test exercises the unset case directly.
- What we know about real-world impact
- Measured on 2026-09-06: the production audit log holds no row written by this endpoint, so it was never exercised. Outbound mail through that provider is not yet live, which is why nothing had reason to find the endpoint.
Found by: Internal audit
134e280Background images and music on bio pages sent every visitor to a third party
- What it was
- A bio page rendered two URLs the page owner chose and the visitor did not — a background image and a music track — straight to the browser, so the visitor’s browser fetched them from wherever they were hosted. The built-in music library made this the default rather than an edge case: every one of its tracks was hosted on one third-party site, so simply enabling music sent every visitor there. Our own public sample page did the same to a different host.
- What it could have done
- Every visitor’s IP address and browser identity reached a host the page owner picked, on every view, in a product whose reason to exist is not doing that. The avatar half of this had been closed a few days earlier by refusing external hosts; these two had no upload path to refuse toward, so they had gone on leaking.
- How it was fixed
- Both are now fetched by our server through a signed proxy, so the visitor’s identity stays here. The proxy is not open: it serves the built-in library and URLs signed at render time under a server secret, checks every resolved address against the private and reserved ranges, follows redirects by hand re-checking each hop, caps the size twice and refuses anything that is not an image or audio. The render side also refuses any media URL that is not already same-origin, so a future caller that forgets to proxy shows a blank rather than a leak. A census test lists every place in the bio render tree that becomes a browser fetch and fails on an unapproved one.
- What we know about real-world impact
- This was in use by default. Any bio page with music enabled had been sending its visitors to the library’s host for as long as the feature existed, and the public sample page sent them to another. We do not know which visitors, because the requests never touched our servers — which is the point.
Found by: Internal audit, following the avatar fix to the two sinks it had not covered
2a700b8Setting a bio-link avatar by URL skipped image moderation entirely
- What it was
- The avatar upload endpoint is hardened — it sniffs the actual file bytes instead of trusting the declared type, refuses SVG because it is an active document format, caps the size, and runs image moderation before storing anything. None of that ran if you wrote an image URL straight into the bio-link config instead of using the uploader. The editor UI offers no URL field; the server accepted one anyway.
- What it could have done
- Any image could be placed on a public bio page without passing moderation. Separately, and worse for a privacy product: pointing an avatar at a third-party host leaks every visitor’s IP address and browser string to that host on every page view.
- How it was fixed
- One implementation, wired into both the create and update paths, matching on the parsed hostname with a label-boundary check — a substring test is bypassed both by putting the allowed host in the path and by using it as a prefix of an attacker domain. Values written before the rule are grandfathered, so nobody is locked out of saving edits by an avatar they did not choose.
- What we know about real-world impact
- The bypass was in use. One production bio link carried an externally-hosted avatar — set by its own owner picking an image, not by an attacker — and it had been leaking visitor IPs to that host for as long as it was live.
Found by: Internal audit of the gap between what the UI offers and what the server accepts
f683336Our published vulnerability-reporting address did not exist
- What it was
- We published security@aliasvault.io at /.well-known/security.txt as the address to report vulnerabilities to. No mailbox or alias was ever created behind it, so every message sent there bounced.
- What it could have done
- A researcher trying to report a vulnerability privately would have had their mail rejected. That is worse than an ordinary broken address: a bouncing security contact is the standard reason a finder gives up on private disclosure and publishes instead — the exact outcome the file exists to prevent.
- How it was fixed
- The address now exists and is verified by reading the record back. It is listed here rather than quietly fixed because a reporting channel nobody can reach is a security defect in its own right, even though no code was vulnerable.
- What we know about real-world impact
- No reports are known to have been lost. We cannot prove none were: a bounce leaves no trace on our side, which is precisely the problem.
Found by: Internal audit — checking that a published claim was backed, rather than assuming it
2e02b84Open redirect after completing the human-verification challenge
- What it was
- The rule that decides whether a “return to this page afterwards” value is safe existed in three copies. One of them was a hand-copy that dropped the check on the normalised path, so a crafted value passed every test — one leading slash, no backslash, unchanged origin — and then resolved to a protocol-relative URL pointing off-site.
- What it could have done
- Someone could be sent from a genuine URL on our own site to a site of an attacker’s choosing, immediately after completing a challenge. That is the dangerous shape of an open redirect: the visitor has just checked the domain, seen a real one, and is then handed onward.
- How it was fixed
- One implementation both pages import. It applies the shape rules to the raw value and, separately, to the normalised path, and refuses redirects back into authentication flows. The test no longer extracts the function out of a page by counting braces — it imports the module.
- What we know about real-world impact
- The flaw was introduced and fixed the same day, before it had been deployed for any meaningful period. It is published because its cause generalises: three copies of one security rule existed at once, and the copy that drifted was the one that failed open.
Found by: Internal audit, while closing an unrelated dead-code finding — not by testing the feature it was in
88f2f0eYou could register an account using an email address we host
- What it was
- Nothing stopped someone signing up with an account email on one of our own domains. Alias ownership and account ownership are separate systems, so one person could hold the alias while a different person registered an account at the very same address.
- What it could have done
- Account takeover with no credential ever leaking. Every password-reset message for the account would be delivered to whoever held the matching alias. The race runs both directions — register the account first, and whoever later claims the alias inherits the reset channel.
- How it was fixed
- Refused at the database write rather than in the form, so it holds for email/password signup, for social providers, and for later email changes alike — a client-side check is not a control when the signup endpoint is public. Applied across the whole domain catalogue, not just the domains carrying mail today, because a domain that cannot receive mail yet is exactly one expected to later; allowing signups there would plant the takeover and detonate it months after anyone remembered why.
- What we know about real-world impact
- One existing account sits on a hosted domain — the operator’s own, which also owns the matching alias. No other account was affected.
Found by: Internal audit
d86d184Referral credits could be created by anyone, without an account
- What it was
- The action that awards referral credits performed no authentication at all and took the account to credit straight from the caller. Referral codes are public by design — they are the links people share.
- What it could have done
- Anyone could award themselves credits, repeatedly. Deduplication was per referrer-and-referee pair, so a single account could be “referred” once by every referral code in existence and farm the reward in a loop. Credits are bought with real money.
- How it was fixed
- The credited account now comes from the session, which a caller cannot forge, and the one-referral-ever rule is enforced by a conditional database update rather than a read-then-write check that a race could defeat. Both awards now write ledger rows — they previously did not, which is why balances had drifted above the sum of recorded transactions.
- What we know about real-world impact
- The credit ledger was reconciled afterwards and every historical discrepancy was traced to a known internal path, not to this. No balance was reduced during that reconciliation: the record was corrected, never the money.
Found by: Internal audit — an adversarially-reviewed survey of every publicly-reachable server action
d86d184Any account could read another account’s bio-link analytics
- What it was
- The analytics read checked that you were signed in, then trusted the bio-link id you supplied. The list of links you actually own was only a fallback for when you supplied nothing — it was never a permission check.
- What it could have done
- Any account holding at least one bio link could pass someone else’s link id and receive that link’s clicks, views, referrers, countries, device mix and per-visitor identifiers.
- How it was fixed
- Ownership is verified before the id is used, and an id you do not own is refused rather than silently replaced with your own. This is the defect class this codebase produces most often — proving who is calling, and then trusting whatever they named — so ownership now belongs in the database query’s WHERE clause, not in a check standing beside it.
- What we know about real-world impact
- We have no access logs that could establish whether this was ever used. We are not claiming it was not.
Found by: Internal audit
d86d184A destructive maintenance job was callable by anyone
- What it was
- The nightly cleanup routine authorised nothing itself. The shared secret that protects it lived only in the scheduled route that wrapped it — but the routine was independently reachable, so the gate protected the wrapper while the bulk deletion sat exposed.
- What it could have done
- An anonymous caller could trigger deletion of analytics, bio-link click history and soft-deleted mail on demand, destroying data that was still inside its restore window.
- How it was fixed
- The secret is verified inside the routine itself, with a constant-time comparison, so it holds however the routine is reached. Two unauthenticated traffic-writing endpoints found in the same review were deleted outright rather than guarded — they had no callers at all, and could have been used to fabricate visit counts, countries and referrers on anyone’s bio link.
- What we know about real-world impact
- No unexplained data loss was found. The scheduled job’s own history is consistent with only its scheduled runs.
Found by: Internal audit
7d153f6The credit-audit job was a public endpoint that returned other users’ details
- What it was
- A module of maintenance routines was marked in a way that turns each of its exports into a publicly callable HTTP endpoint. One of them returned the audit’s findings; another wrote to the database.
- What it could have done
- An anonymous caller could retrieve user ids, email addresses and credit balances for every flagged account — a cross-tenant data and financial dump — and could also cause support records to be written, unbounded, by repeating the request.
- How it was fixed
- The routine is no longer an endpoint. It runs from the scheduled job, which enforces a shared secret, and its results are deliberately not echoed into that job’s HTTP response — returning them there would have reopened the disclosure path that removing the endpoint had just closed.
- What we know about real-world impact
- We have no request logs that could establish whether this was ever called. Fixing it also corrected a comparison bug that had been reporting almost every account as discrepant.
Found by: Internal audit. It had been hiding behind an internal allowlist entry whose stated justification named a caller that did not exist.
a03420dThe image-generation refund could be called for any amount
- What it was
- The refund took the amount from the caller and added it to the caller’s own balance, with no upper bound, no check that a matching charge had ever occurred, and no protection against being called repeatedly.
- What it could have done
- Any signed-up account could set its own credit balance to essentially whatever it asked for. Credits are bought with real money. A negative amount was also accepted, which would have permanently defeated the free-tier limits, since those test for a balance of exactly zero.
- How it was fixed
- The amount is no longer a parameter. A refund is derived from the charge it reverses — the recorded charge for that reference, belonging to that user — refunded once, inside a transaction. A reference with no charge behind it refunds nothing, which makes a forged one inert. A related flaw was closed at the same time: a repeated request was being reported as a fresh charge, so replaying one could yield unlimited paid generations for a single payment.
- What we know about real-world impact
- The credit ledger reconciliation that followed found no balance inconsistent with recorded internal grants.
Found by: Internal audit — a large parallel security sweep, confirmed line by line by an adversarial reviewer
e92c88aA moderation takedown could be undone by the person taken down
- What it was
- Taking down a bio link set a flag that nothing ever read. The page stayed inaccessible only until its owner touched it.
- What it could have done
- Content removed by moderation could be restored in one click by the account it was removed from, and deleting the record could also free the address for immediate re-registration.
- How it was fixed
- The flag is enforced on read, update and delete, and repeated inside the update and delete statements themselves so the database refuses the write rather than trusting a check made a moment earlier. Refusing to delete a taken-down record is what keeps its address reserved.
- What we know about real-world impact
- No takedown is known to have been reversed this way. Moderation volume on the product to date is very low.
Found by: Internal audit
e92c88aThe email sanitizer emitted tags it could not parse, verbatim
- What it was
- The sanitizer that cleans inbound HTML mail passed through any tag its pattern failed to match. A tag containing an odd number of quote characters did not match.
- What it could have done
- A sender could defeat the sanitizer by malforming a tag — which also fully bypassed tracking-pixel and tracker removal, so a message could still phone home when opened.
- How it was fixed
- Unmatched input is no longer passed through. Failing closed is the only correct default for a sanitizer: an element it cannot understand is exactly the element it must not emit.
- What we know about real-world impact
- No message is known to have used this. Inbound mail volume during the affected period was small.
Found by: Internal audit — an adversarial review pass over a batch of security fixes, which found defects in the fixes themselves
e92c88aSupport threads returned the whole user record of every participant
- What it was
- Loading a support ticket selected entire user rows for each message sender instead of the few fields it needed to display.
- What it could have done
- A reply from an administrator carried that administrator’s email address and stored recovery-key hash into the response for the ticket’s owner.
- How it was fixed
- An explicit projection of only the fields the view renders. Selecting whole rows and filtering in the UI is the shape of this bug: the data has already left the server by then.
- What we know about real-world impact
- The exposed fields belong to staff accounts replying on tickets. Recovery-key hashes are stored hashed, not in a usable form.
Found by: Internal audit
e92c88aResetting your password did not sign out existing sessions
- What it was
- The password reset changed the credential and left every already-established session valid.
- What it could have done
- Changing your password is the single standard response to believing someone else has access to your account — and it silently did not remove their access. Someone already signed in stayed signed in.
- How it was fixed
- A reset now revokes existing sessions. This is published even though nothing was “breached”, because a security control that quietly does nothing is worse than an absent one: the user believes they have acted.
- What we know about real-world impact
- No account is known to have needed this. It is not possible to tell retroactively.
Found by: Internal audit
e92c88aInbox settings accepted whichever fields the caller sent
- What it was
- The settings update spread the caller’s input straight into the database write, so fields that identify the row — including which account it belongs to — were writable.
- What it could have done
- A cross-tenant settings takeover: the update could be steered onto another account’s settings row. A negative auto-delete value was also accepted, which put the deletion cutoff in the future and would have swept mail that had not aged at all.
- How it was fixed
- An explicit list of the fields that may be written, with bounds on the numeric ones. The identifying fields are not among them.
- What we know about real-world impact
- No settings row is known to have been modified this way; the affected fields are not exposed in the interface.
Found by: Internal audit
e92c88aThe two most expensive public endpoints rate-limited on a forgeable value
- What it was
- Both AI chat routes bucketed their rate limit on a client-supplied address header, so the bucket reset on every request that changed it.
- What it could have done
- The rate limit on the two most expensive unauthenticated paths in the product could be bypassed at will, which is a cost and availability problem rather than a data one.
- How it was fixed
- Bucketed on the address the edge sets, which a client cannot forge. A caller-named host reaching an outbound network tool without being resolved and checked first was fixed in the same pass.
- What we know about real-world impact
- No abnormal spend was identified on the AI provider accounts for the period. That is a billing-level check rather than a request-level one, so it would show sustained abuse and could easily miss a small amount.
Found by: Internal audit
What we do not currently cover
These are not defects and not a to-do list. They are things the product deliberately does not do, which you may want to know before trusting it with something. Publishing them is what stops the list above reading like a finished job.
Anything we cannot read, we cannot protect you from
Zero-knowledge mail is sealed to your key before it is written down: mail you compose and internal messages are encrypted in your browser, and mail arriving from the internet — which reaches our server as plaintext, because that is what SMTP delivers — is encrypted to your public key on arrival, before storage. Either way the stored copy is unreadable to us, so it is excluded from server-side search and the AI tools cannot work on it; it is scored for phishing exactly once, on arrival, on the copy we hold in memory before sealing, and never again. This is the guarantee working, not a gap — but it is a real trade, and it is the right choice for your most sensitive mail rather than automatically for all of it.
Attachments are not virus-scanned
Inbound attachments are identified by their actual content rather than their claimed type, executables are refused, and anything we cannot confidently identify is refused. They are then stored and served as inert downloads that a browser will not run or render. None of that is the same as malware scanning, and we do not claim it is.
Attachment contents are not kept for zero-knowledge mailboxes
If your mail is set to zero-knowledge encryption, attachment contents are not stored at all — only a record that the attachment arrived, with its filename, type and size. We have no key that could seal them to yours on the way in, and storing them in the clear beneath a zero-knowledge badge would make the badge false. On the encrypted-at-rest tier attachments are stored, encrypted with the same server-held key as the body.
Our own domains publish DMARC but do not yet ask receivers to enforce it
Our mail domains publish SPF, DKIM and DMARC. The DMARC policy is currently set to monitor rather than to quarantine or reject, so a receiver is told what we expect but is not asked to act on it. Tightening it is a deliberate, staged change that has not been made yet.
Sender-authentication results depend on which mail path delivered the message
We only trust an authentication verdict stamped by a mail service we recognise; anything else is shown as unknown rather than as a pass. Treating an unrecognised header as trustworthy is how a forged one becomes a green tick, so we would rather show you less than show you something we cannot stand behind.
Acknowledgments
Nobody yet. Every entry above was found by our own auditing or by a dependency scanner; no report has reached us from outside. The first person who sends one and wants to be named will be named here, in the words they choose.
How to read this page
Every entry above is already fixed. We do not publish live issues while they are live — that is a disclosure timeline, not transparency.
Almost everything here was found by our own auditing rather than reported to us. That is not a boast; it is the reason the list is long. A product that is not being audited has a short security page.
Where we can tell whether a defect was actually exercised, the entry says so. Where we cannot — usually because we do not retain logs that would answer it — the entry says that instead, rather than implying an all-clear we did not verify. One entry records a bypass that was in use in production.
The commit reference on each entry is the change that closed it, in this product’s own history.