This is a small, dependency-free utility — but it accepts arbitrary strings and produces HTML, which makes it a downstream attack surface for every consumer. This guide is the threat model.
┌────────────────────┐ query, options ┌──────────────────────┐
│ Consumer code │ ─────────────────────────► │ highlight(...) │
│ (potentially has │ │ │
│ user input here) │ ◄───────────────────────── │ returns HTML string │
└────────────────────┘ wrapped HTML output └──────────────────────┘
Two boundaries you must respect when changing the implementation:
queryflows into aRegExpconstructor. Treat it as untrusted regex source — see Regex injection / ReDoShtmlTagandhlClassare interpolated raw into HTML. They're consumer-controlled today; if a consumer pipes user input into either, they get attribute injection. Document this prominently — don't silently trust the values
- Validate at the boundary.
Utils.validate.*is the single entry; internal helpers trust their typed inputs - Never auto-escape — the library's contract is "raw regex, raw HTML tag". Changing that breaks consumers. If you really need escaping, it goes behind an opt-in option
- Keep the runtime dependency surface at zero. Every transitive dep is a supply-chain risk
- Clear English error messages. No stack traces leaking internals, no values from the offending input
The current implementation does:
return text.replace(new RegExp(query, modifiers), (match) => /* wrap match */)Risk #1 — Pattern interpretation. A consumer passing user-typed search input directly to highlight(text, userQuery) exposes their app to regex injection: a query of '.*' matches everything; '(.+)+x' may run for minutes. Their users may be unable to render output, or in extreme cases may DoS the page.
Risk #2 — Catastrophic backtracking. Nested quantifiers ((a+)+, (a|a)+, (a|aa)+b) on adversarial inputs trigger exponential matching time in the V8 regex engine. The library doesn't construct these — but it doesn't reject them either.
- Empty
queryshort-circuits before constructing a regex - All argument shapes are validated; non-string
querythrows
- We do not escape regex metacharacters in
query— the existing tests rely on regex syntax (e.g., the emoji test passes'😎'which is matched literally only because emoji aren't regex metacharacters) - We do not set a regex execution time limit (V8 doesn't expose one for synchronous regex anyway)
Recommendations for consumers (document in API Reference)
// Escape regex metacharacters before passing user input
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
const safe = searchTextHL.highlight(text, escapeRegex(userInput))For untrusted inputs in environments where a long-running regex would be a problem (e.g., a server rendering many highlights), consumers should:
- Limit
querylength (e.g., reject queries over 256 characters) - Run the call in a worker / sandbox with a wall-clock timeout
- Pre-validate
queryagainst an allowlist of expected characters
- An opt-in
escapeQuery: booleanoption that wrapsqueryin a regex-escape pass whentrue. Default would have to remainfalseto preserve backwards compatibility — a major version bump if we ever flip the default - A documented "safe mode" recipe in API Reference that consumers can copy/paste
- Adversarial-input test cases in
test/main.test.tsthat bound the worst-case behavior
Both options are interpolated into the output without escaping:
;`<${options.htmlTag} class="${options.hlClass}">${match}</${options.htmlTag}>`A consumer passing untrusted input into htmlTag:
searchTextHL.highlight(text, query, { htmlTag: 'span class="x" onclick="alert(1)"' })
// → <span class="x" onclick="alert(1)" class="text-highlight">...…produces garbage HTML at best, exploitable markup at worst. Same applies to hlClass.
- The library does not sanitize. Sanitization is the consumer's job — they construct the option object
- Document the contract clearly: "Treat
htmlTagandhlClassas static configuration; never interpolate user input"
If we ever decide to sanitize, the only correct path is an allowlist:
htmlTagmatches^[a-zA-Z][a-zA-Z0-9-]*$hlClassmatches^[a-zA-Z_][a-zA-Z0-9_\- ]*$
Anything outside the allowlist throws. That's a behavior change — major version bump, with a migration note.
Utils.validate.highlight and Utils.validate.options (in src/lib/utils.ts) are the only place the library checks types. They:
- Accept the documented argument shapes
- Throw plain
Errorwith English messages on mismatch - Don't include the offending value in messages (don't leak PII)
When you add an option:
- Extend
Utils.validate.options - Use the same error message style:
'The <name> option should be a <type>.' - Add a test that confirms the throw
- The package has zero
dependencies. Adding one is a security event — it expands the supply-chain surface for every consumer - All
devDependenciesare pinned inpackage.json; the resolution is locked inpnpm-lock.yaml(committed) - pnpm is provided by Corepack and pinned via
package.json'spackageManagerfield — never install pnpm globally .ncurc.jsonrecords the upgrade policy — deliberate freezes carry documented reasons
pnpm-workspace.yaml configures two defenses against compromised packages:
| Guard | Value | Effect |
|---|---|---|
minimumReleaseAge |
10080 |
New installs/updates only resolve versions published ≥ 1 week ago — blocks freshly-pushed malware that gets yanked within days. The existing lockfile is still respected. |
allowBuilds |
{ esbuild: true } |
pnpm 11+ refuses to run a package's install/build scripts unless explicitly allow-listed. Only esbuild (pulled in by Vite) is permitted to run its postinstall. |
When a new dependency emits an [ERR_PNPM_IGNORED_BUILDS] warning during corepack pnpm install, audit the package first, then add it to allowBuilds only if it genuinely needs an install script. Background: https://xergioalex.com/blog/supply-chain-attacks-ai-era/.
Before adding any package:
- Read its source. Most npm packages are small enough to skim
- Check its publish history on
npmjs.com— recent maintainer churn is a red flag - Run
corepack pnpm view <package> repository.urland verify the GitHub repo matches - Check open CVEs at
https://www.npmjs.com/advisoriesorhttps://github.com/advisories - Pin to a specific version (no
^ranges in this repo'spackage.json) - Remember
minimumReleaseAgeblocks versions newer than a week — that's intentional, don't override it to rush an install - Update Technologies and AGENTS.md in the same PR
The repo uses an internal weekly automation (check_packages_versions.yml) instead of Dependabot. The flow is:
- Tuesday 15:00 UTC — workflow runs
ncu:upgrade, opens a PR - Tuesday 20:00 UTC — auto-merges if
Code Checkpasses - Otherwise the PR sits for human review
Don't relax .ncurc.json or the minimumReleaseAge guard without weighing the supply-chain and migration cost.
- Publishing requires
NPM_TOKEN(a secret in GitHub). Rotate it annually - The token has publish access only to
search-text-highlight. It should not be a global token corepack pnpm publish --no-git-checksruns from CI onmainonly. There's no manual publish in the developer workflow- Two-factor on the npm account is required for the maintainer account that owns the package
- Provenance (
pnpm publish --provenance) is not enabled today. Enabling it requires the workflow to run withid-token: write— an easy improvement worth filing as an issue
- Never commit
.env,.npmrc, or any file with credentials .envis in.gitignore(and.npmignore)- The Dockerfile pulls Claude / Codex / Cursor CLIs but stores their auth in named volumes (
claude_data,codex_data,cursor_data,gh_data) — those live in the Docker host, not the image or the repo
If you accidentally commit a secret:
- Rotate it immediately (npm token, GitHub token, etc.)
- Force-push only if the secret hasn't been mirrored anywhere — usually you can't and rotation is the only safe answer
- Audit GitHub Secret Scanning alerts
- Biome's
noConsole: errorblocksconsole.loginsrc/. The library does not log - Tests are allowed to print (
noConsoleis off fortest/**); that's fine — they don't ship - Don't
console.error(input)— even an error path could expose untrusted input
If you find a vulnerability, do not open a public issue. Email the maintainer or use GitHub Security Advisories for coordinated disclosure.
The maintainer will acknowledge within a week and aim to ship a fix in the next minor or patch release. Critical issues warrant a same-day patch.
- No new
dependencies(or one was added with documented reason) - No new place where consumer-provided strings reach the regex engine without an explicit consumer-side escape recommendation
- No new place where consumer-provided strings reach the HTML output without documentation
-
pnpm-lock.yamlupdated alongsidepackage.json - Any new package needing an install script is reviewed and added to
allowBuildsdeliberately - Validation tests still cover every option key
- No
console.*calls slipped in -
.envanddist/are not staged -
corepack pnpm pack --dry-runshows the expected files only