Baseline security guidance for Universal Emoji Parser. The package emits HTML that consumers typically inject via innerHTML / v-html / dangerouslySetInnerHTML — making the output's correctness and the package's supply chain both directly relevant to consumer security.
- The HTML output is trusted by consumers. Do not let untrusted data leak into the output template
- No secrets in the repo. Everything that authenticates as the package (npm token, signing keys) lives in CI secrets
- Default deny on dependencies. Adding a runtime dependency ships it to every consumer's bundle and increases the supply-chain surface
- Pin versions. No
^/~ranges in CI behavior —package.jsonuses exact versions for both deps and devDeps
text = text.replace(regex, `<img class="emoji" alt="${entity.text}" src="${emojiUrl}"/>`)Two interpolated values:
entity.text— the unicode emoji literal returned by@twemoji/parser. Never user-controlled — it's whatever Twemoji decided was an emoji entityemojiUrl— either Twemoji's CDN URL or a string-replaced version using theemojiCDNoption. The CDN comes from the consuming app's config (or default), not from end-user input
Both are safe under normal use. The risks come from:
// ❌ DANGEROUS — never do this
const userCdn = req.query.cdn // user-controlled
uEmojiParser.parse(text, { emojiCDN: userCdn })If a consumer pipes user input into emojiCDN, an attacker could set:
emojiCDN = '"></script><script>alert(1)</script>'
…and the output would contain executable JavaScript. This is the consumer's bug, not the package's, but the doc here is to flag it.
Mitigation in this package: the package doesn't validate emojiCDN shape. We could add a check (e.g., must start with https?://, must end with /, no quotes), but that adds complexity for a misuse that's clearly the consumer's responsibility. Document it loudly here and in API Reference; don't add defensive validation that can be bypassed.
If a consumer wants to allow user-configurable CDNs, they should validate before passing:
function safeCdn(input: string): string | undefined {
if (!/^https:\/\/[\w.-]+(:\d+)?(\/[\w.-]*)*\/$/.test(input)) return undefined
return input
}
uEmojiParser.parse(text, { emojiCDN: safeCdn(req.query.cdn) })// ❌ DANGEROUS
const userMessage = req.body.message // "<script>alert(1)</script> :smile:"
const html = uEmojiParser.parseToHtml(userMessage)
res.send(html) // ships the script tagThe package does not escape the surrounding text. It's a transformer, not a sanitizer. Consumers who feed user input through it must escape first:
import escape from 'lodash.escape'
const safe = escape(req.body.message) // escapes <, >, &, "
const html = uEmojiParser.parseToHtml(safe) // emojis still resolve; HTML is escaped
res.send(html)The reason: HTML-escaping inside parseToHtml would corrupt content for consumers who already consider the input safe (e.g., trusted markdown rendered to HTML by another library, then emoji-replaced). Escaping is the consumer's choice and timing.
This package's responsibility is to never introduce unsafe HTML. The output template is safe under all inputs because:
entity.textis always a unicode emoji (Twemoji's regex doesn't match arbitrary strings)emojiUrlis either trusted (default CDN) or consumer-supplied (their responsibility)
If we ever add user-controlled HTML attributes (e.g., custom data-* attributes from a customDataAttributes option), we must:
- HTML-escape all values before inserting
- Whitelist attribute names against a regex
- Document the security implications
Currently no such feature exists — the output template is fixed.
textnon-string check — throwsError('The text parameter should be a string.'). There's a test for this; don't remove ittextempty string — returns empty string; no errorstextvery long — no length limit. Latency scales linearly with text length and number of emojis. Consumers who accept untrusted input should rate-limit themselves; we can't enforce it heretextwith malformed input — unmatched shortcodes (:not_real:), garbage Unicode, partial surrogate pairs — all pass through as text, no errors
src/lib/emoji-lib.json is the catalog. It's:
- Generated by
prepareEmojiLibJson.test.tsfromemojilibandunicode-emoji-json - Reviewed by humans (PR diff) before merging
- Loaded as a static JSON import — no
eval, no dynamic require, no remote fetch
A malicious entry in the catalog (e.g., a slug with HTML special characters) would only affect parseToShortcode output. Currently every slug is [a-z0-9_]+ so this isn't a real risk, but if you ever notice a non-safe character in slug, that's a bug in the regenerator's input — fix EMOJIS_SPECIAL_CASES to scrub it.
"dependencies": {}Zero runtime dependencies. @twemoji/parser is the only library the runtime needs, and the Vite library-mode build inlines it into dist/index.js at build time — so the published package declares no dependencies at all. This is the smallest possible supply-chain surface for consumers: installing the package pulls in nothing transitive.
Adding a real runtime dependency (one that is not inlined) is a major decision — it would ship to every consumer's install tree.
- Maintained by jdecked (former Twemoji maintainer) and the broader Twemoji community
- License: MIT
- No native dependencies (pure JS)
- Used by Twitter/X, Discord, and many other major products
- Pinned to an exact version (
17.0.1) as adevDependencyand inlined at build time;.ncurc.jsonrejects17.0.2because it regressed U+FE0F handling. Bumping is a deliberatechore: bump @twemoji/parserPR after verifying the regression is resolved
Everything is a devDependency — including the toolchain (biome, vite, vitest), the regeneration sources (emojilib, unicode-emoji-json), and @twemoji/parser (inlined at build). None of them ship to npm consumers as install-time dependencies.
If a build- or test-only tool (Biome, Vite, Vitest) becomes compromised, the impact is limited to the build/test pipeline — it cannot reach a consumer's install tree, because the published tarball carries only the bundled dist/.
If emojilib or unicode-emoji-json becomes unmaintained or compromised, the impact is limited to catalog regeneration — we'd switch to a fork or fall back to the previously committed catalog.
secrets.NPM_TOKEN— automation-scoped; can publish but not modify package metadatasecrets.AUTOMATION_GITHUB_TOKEN— fine-grained PAT or GitHub App token withcontents: write,pull-requests: write. Scoped to this repo onlysecrets.DAILYBOT_API_KEY— sends notifications; no repo access
CI workflows check out the repo with actions/checkout@v4 (pinned major version) and use actions/setup-node@v4, actions/cache@v4 (also pinned majors). Pinning to SHAs would be more rigorous; we accept the major-pin risk for now.
check_packages_versions.yml runs ncu -u weekly and opens an auto-PR. The PR goes through code_check.yml (lint + format + test) before auto-merging. This means a malicious new release of any devDep that breaks the build is caught — but a malicious release that passes all checks would auto-merge.
Mitigations:
.ncurc.jsonrejects bumps for@twemoji/parser(pinned to17.0.1—17.0.2regressed U+FE0F handling)- The auto-merge workflow can be disabled if a high-profile supply-chain incident hits
To harden further, consider:
- Adding an audit step to CI (
pnpm audit --audit-level high) - Pinning to exact SHAs for action versions (
actions/checkout@<sha>) - Using Socket.dev or Snyk PR checks
- Disabling auto-merge and reviewing every dep PR by hand
We don't currently do any of these; document the gap so a future security-focused contributor can add them.
The repo uses pnpm as its package manager and leans on three pnpm/Corepack features to reduce supply-chain risk during development and CI. Rationale and threat model: Supply-chain attacks in the AI era.
-
Version quarantine —
minimumReleaseAge: 10080(inpnpm-workspace.yaml). pnpm refuses to install any package version published less than 10080 minutes (7 days) ago. This blunts the most common npm supply-chain attack: a compromised maintainer account (or hijacked publish token) pushing a malicious patch release that automated dependency bots install within minutes. By the time a quarantined version is eligible to install, the community has usually flagged and yanked a malicious release. The weeklycheck_packages_versions.ymlupgrade bot therefore won't pull a brand-new release until the window passes. -
Package-manager pinning via Corepack —
"packageManager": "pnpm@11.1.2". The exact pnpm version is pinned inpackage.jsonand provisioned by Corepack (corepack enable). Every contributor and every CI job runs the same pnpm binary, so a developer can't accidentally (or maliciously) run an old/forked pnpm that ignores the lockfile or the hardening settings. The pinned version is itself a reviewable, lockfile-committed decision. -
Install-script allow-list —
allowBuilds: { esbuild: true }(inpnpm-workspace.yaml). By default pnpm v11 does not run lifecycle/install scripts (preinstall/install/postinstall) for dependencies — a major attack vector, since a malicious postinstall script runs with full developer/CI privileges at install time. Only packages on the explicit allow-list may run their build scripts; here that is justesbuild(Vite's bundler needs its platform-specific binary). Any new dependency that wants to run an install script must be deliberately added toallowBuilds, which is a security review checkpoint.
Together these mean: a freshly published, install-script-bearing malicious dependency can't silently execute on a developer's machine or in CI, won't be installed for a week regardless, and runs under a pnpm binary everyone agrees on.
When bumping pnpm itself, update both "packageManager" in package.json and any CI Corepack step in lockstep, and keep pnpm-workspace.yaml's minimumReleaseAge / allowBuilds settings intact across the bump.
The npm account that owns secrets.NPM_TOKEN should have 2FA enabled with auth-and-writes mode. This prevents stolen tokens from publishing — they'd also need a TOTP code.
If an automation token can't satisfy 2FA (typically the case), use an "automation" token specifically (npm generates these for CI) and scope it to this single package. A leaked package-scoped automation token can't be used to publish other packages.
The package is built by Vite (library mode, esbuild minify) from TypeScript source, with declarations emitted by tsc. Build is deterministic for a given:
- Node version (CI: Node 24, pinned to 24.16.0)
- pnpm lockfile state (
pnpm-lock.yamlis committed; CI installs withpnpm install --frozen-lockfilefor exact reproducibility) - Source tree
Running pnpm install && pnpm run build on different machines produces byte-identical dist/index.js... mostly. Vite's esbuild minification is deterministic, and @twemoji/parser is inlined from a pinned version; the JSON catalog is checked-in source.
If you ever need to verify a published version against source: pnpm pack locally, diff against the published tarball.
.npmignore and package.json files (we don't currently use files; .npmignore is the source of truth) restrict what ships:
Included:
dist/index.js
dist/index.d.ts
dist/lib/type.d.ts
dist/*.map
package.json
README.md
LICENSE
Excluded:
src/, test/, docker/, .github/, docs/, .agents/, .claude/, .vscode/, .devcontainer/
*.config.js, vite.config.ts, vitest.config.ts, biome.json, .editorconfig, tsconfig.json
pnpm-lock.yaml, pnpm-workspace.yaml, *.txt
Verify before publishing: pnpm pack --dry-run.
- This package: MIT (see LICENSE)
@twemoji/parser: MIT- Twemoji assets (the SVGs the CDN serves): CC-BY 4.0 — consumers using the default CDN should attribute Twemoji per the license
If a consumer rebrands or self-hosts assets, they must respect Twemoji's license. We document this in Emoji Providers → CDN selection.
If you find a security issue:
- Don't open a public GitHub issue. Email developers@dailybot.com with details, repro steps, and impact assessment
- Expected response: acknowledgement within 5 business days; coordinated disclosure timeline depends on severity
- CVE assignment: we'll request one if the issue qualifies
For non-critical issues (e.g., "this docs example shows an unsafe pattern"), a public issue or PR is fine.
For a typical consumer integration:
- User input is HTML-escaped before being passed to
parse/parseToHtml -
emojiCDNis hardcoded or validated against an allowlist; never user-controlled - The CDN serving Twemoji assets is HTTPS (default is —
cdn.jsdelivr.net) - If self-hosting Twemoji assets, the CDN sets correct Content-Type (
image/svg+xml) - If serving on a strict CSP, the CDN host is whitelisted in
img-src - If hosting on edge functions with strict bundle limits, lazy-load the package
- Consumer's npm-audit / Snyk / Dependabot picks up vulnerabilities in this package's dependencies
For maintainers of this package:
- No
console.log, noeval, noFunctionconstructor insrc/ - No
dangerously*patterns; output template is fixed -
package.jsondependenciesstays empty (zero runtime deps;@twemoji/parseris adevDependency, inlined at build); verify on every PR - CI's
secrets.NPM_TOKENis scoped to this package - npm 2FA is
auth-and-writes - Major Twemoji bumps go through manual review (don't auto-merge
@twemoji/parserupdates without reading release notes)
- Consumer-side XSS — if a consumer's templating system is misconfigured and renders the package's output unescaped while also rendering attacker-controlled text unescaped, that's the consumer's vulnerability. Document the safe pattern, don't try to make every misuse safe
- CDN compromise — if the Twemoji CDN itself is compromised, every consumer using the default CDN serves compromised SVGs. Mitigation: pin a Twemoji version (so a future compromise of
@latestdoesn't propagate), or self-host - DoS via huge inputs —
parse('a'.repeat(10_000_000))will be slow (linear in input length × catalog regex). Consumers facing DoS risk should bound input size before calling - Catastrophic regex — the
parseToShortcodealternation regex doesn't have catastrophic backtracking under standard inputs, but with a maliciously crafted input that's mostly partial-emoji-prefixes, performance could degrade. We don't currently fuzz-test for this