@tak-ps/node-safeurl 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.github/workflows/doc.yml +45 -0
  2. package/.github/workflows/release.yml +40 -0
  3. package/.github/workflows/test.yml +46 -0
  4. package/CHANGELOG.md +20 -0
  5. package/LICENSE +21 -0
  6. package/README.md +77 -0
  7. package/coverage/base.css +224 -0
  8. package/coverage/block-navigation.js +87 -0
  9. package/coverage/favicon.png +0 -0
  10. package/coverage/index.html +116 -0
  11. package/coverage/lcov-report/base.css +224 -0
  12. package/coverage/lcov-report/block-navigation.js +87 -0
  13. package/coverage/lcov-report/favicon.png +0 -0
  14. package/coverage/lcov-report/index.html +116 -0
  15. package/coverage/lcov-report/prettify.css +1 -0
  16. package/coverage/lcov-report/prettify.js +2 -0
  17. package/coverage/lcov-report/safeurl.ts.html +430 -0
  18. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  19. package/coverage/lcov-report/sorter.js +210 -0
  20. package/coverage/lcov.info +178 -0
  21. package/coverage/prettify.css +1 -0
  22. package/coverage/prettify.js +2 -0
  23. package/coverage/safeurl.ts.html +430 -0
  24. package/coverage/sort-arrow-sprite.png +0 -0
  25. package/coverage/sorter.js +210 -0
  26. package/coverage/tmp/coverage-3190593-1780593894816-0.json +1 -0
  27. package/coverage/tmp/coverage-3190594-1780593894481-0.json +1 -0
  28. package/coverage/tmp/coverage-3190618-1780593894774-1.json +1 -0
  29. package/coverage/tmp/coverage-3190618-1780593894787-0.json +1 -0
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +2 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/lib/safeurl.d.ts +15 -0
  34. package/dist/lib/safeurl.js +106 -0
  35. package/dist/lib/safeurl.js.map +1 -0
  36. package/dist/package.json +54 -0
  37. package/dist/tsconfig.tsbuildinfo +1 -0
  38. package/eslint.config.js +15 -0
  39. package/index.ts +1 -0
  40. package/lib/safeurl.ts +118 -0
  41. package/package.json +54 -0
  42. package/tsconfig.json +24 -0
@@ -0,0 +1,45 @@
1
+ name: Documentation
2
+
3
+ on:
4
+ push:
5
+ branches: ["main"]
6
+ workflow_dispatch:
7
+
8
+ # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
9
+ permissions:
10
+ contents: read
11
+ pages: write
12
+ id-token: write
13
+
14
+ # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
15
+ # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
16
+ concurrency:
17
+ group: "pages"
18
+ cancel-in-progress: false
19
+
20
+ jobs:
21
+ deploy:
22
+ environment:
23
+ name: github-pages
24
+ url: ${{ steps.deployment.outputs.page_url }}
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - name: Checkout
28
+ uses: actions/checkout@v6
29
+ - name: Setup Pages
30
+ uses: actions/configure-pages@v5
31
+ - uses: actions/setup-node@v6
32
+ with:
33
+ node-version: 24
34
+ registry-url: https://registry.npmjs.org/
35
+ - name: npm install
36
+ run: npm install
37
+ - name: npm run doc
38
+ run: npm run doc
39
+ - name: Upload artifact
40
+ uses: actions/upload-pages-artifact@v3
41
+ with:
42
+ path: './docs/'
43
+ - name: Deploy to GitHub Pages
44
+ id: deployment
45
+ uses: actions/deploy-pages@v4
@@ -0,0 +1,40 @@
1
+ name: NPM Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - '*'
7
+
8
+ permissions:
9
+ id-token: write # Required for OIDC
10
+ contents: write
11
+
12
+ jobs:
13
+ build:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v6
17
+
18
+ - uses: actions/setup-node@v6
19
+ with:
20
+ node-version: 24
21
+
22
+ - name: Update npm
23
+ run: npm install -g npm@latest
24
+
25
+ - name: npm install
26
+ run: npm install
27
+
28
+ - name: npm run build
29
+ run: npm run build
30
+
31
+ - name: npm publish
32
+ run: npm publish --provenance --access public
33
+
34
+ - name: Generate CHANGELOG
35
+ run: grep -Pzo "### ${{ github.ref_name }}(?s).*?(?=###)" CHANGELOG.md > RELEASE
36
+
37
+ - name: Github Release
38
+ uses: softprops/action-gh-release@v2
39
+ with:
40
+ body_path: RELEASE
@@ -0,0 +1,46 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+ types:
9
+ - opened
10
+ - synchronize
11
+ - reopened
12
+ - ready_for_review
13
+
14
+ jobs:
15
+ test:
16
+ runs-on: ubuntu-latest
17
+ if: github.event.pull_request.draft == false
18
+ steps:
19
+ - uses: actions/checkout@v6
20
+ with:
21
+ ref: ${{ github.event.pull_request.head.sha }}
22
+
23
+ - uses: actions/setup-node@v6
24
+ with:
25
+ node-version: 24
26
+ registry-url: https://registry.npmjs.org/
27
+
28
+ - name: Install
29
+ run: npm install
30
+
31
+ - name: TSX
32
+ run: npm install --global tsx
33
+
34
+ - name: Build
35
+ run: npm run build
36
+
37
+ - name: Lint
38
+ run: npm run lint
39
+
40
+ - name: Test
41
+ run: npm test
42
+
43
+ - name: Upload coverage reports to Codecov
44
+ uses: codecov/codecov-action@v5
45
+ with:
46
+ token: ${{ secrets.CODECOV_TOKEN }}
package/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # CHANGELOG
2
+
3
+ ## Emoji Cheatsheet
4
+ - :pencil2: doc updates
5
+ - :bug: when fixing a bug
6
+ - :rocket: when making general improvements
7
+ - :white_check_mark: when adding tests
8
+ - :arrow_up: when upgrading dependencies
9
+ - :tada: when adding new features
10
+
11
+ ## Version History
12
+
13
+ ### Pending Fixed
14
+
15
+ ### v1.0.0 - 2025-06-04
16
+
17
+ - :tada: Initial release — SSRF-safe URL validation library
18
+ - :tada: `isSafeUrl()` — validates URLs against SSRF attacks (private IPs, blocked protocols, DNS rebinding)
19
+ - :tada: `isPrivateIPv4()` / `isPrivateIPv6()` — check if an IP falls in private/special-purpose ranges
20
+ - :tada: Uses `@microsoft/antissrf` for maintained block lists
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 State of Colorado, Department of Public Safety
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ <h1 align=center>Node-SafeURL</h1>
2
+
3
+ <p align=center>SSRF-safe URL validation library for Node.js</p>
4
+
5
+ Lightweight TypeScript library for validating URLs to prevent Server-Side Request Forgery (SSRF) attacks. It blocks requests to private IP ranges, localhost, link-local addresses, and other special-purpose networks using Microsoft's maintained [`@microsoft/antissrf`](https://www.npmjs.com/package/@microsoft/antissrf) block lists.
6
+
7
+ ## About
8
+
9
+ `node-safeurl` provides three main exports:
10
+
11
+ - **`isSafeUrl(url)`** — Async function that validates a URL is safe to fetch. Checks protocol, hostname, IP literals, and performs DNS resolution to guard against DNS rebinding attacks.
12
+ - **`isPrivateIPv4(address)`** — Synchronous check for private/special-purpose IPv4 addresses.
13
+ - **`isPrivateIPv6(address)`** — Synchronous check for private/special-purpose IPv6 addresses.
14
+
15
+ ## Installation
16
+
17
+ ### NPM
18
+
19
+ ```bash
20
+ npm install @tak-ps/node-safeurl
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```js
26
+ import { isSafeUrl, isPrivateIPv4, isPrivateIPv6 } from '@tak-ps/node-safeurl';
27
+
28
+ // Validate a URL before fetching
29
+ const result = await isSafeUrl('https://example.com/api');
30
+ if (result.safe) {
31
+ // Safe to fetch
32
+ const response = await fetch(result.url);
33
+ } else {
34
+ console.error('Blocked:', result.reason);
35
+ }
36
+
37
+ // Check individual IPs
38
+ isPrivateIPv4('192.168.1.1'); // true
39
+ isPrivateIPv4('8.8.8.8'); // false
40
+ isPrivateIPv6('::1'); // true
41
+ isPrivateIPv6('2606:4700:4700::1111'); // false
42
+ ```
43
+
44
+ ## API
45
+
46
+ ### `isSafeUrl(href: string): Promise<{ safe: boolean; url?: URL; reason?: string }>`
47
+
48
+ Validates that a URL is safe to fetch from a server context. Returns an object with:
49
+ - `safe` — `true` if the URL is safe, `false` if it should be blocked
50
+ - `url` — The parsed `URL` object (when the URL could be parsed)
51
+ - `reason` — A human-readable string explaining why the URL was blocked
52
+
53
+ Checks performed:
54
+ 1. URL must be parseable
55
+ 2. Protocol must be `http:` or `https:`
56
+ 3. Hostname must not be `localhost` or `0.0.0.0`
57
+ 4. IP literal hostnames must not be in private/special-purpose ranges
58
+ 5. DNS resolution results must not map to private/special-purpose IPs
59
+
60
+ ### `isPrivateIPv4(hostname: string): boolean`
61
+
62
+ Returns `true` if the given string is a valid IPv4 address in a private or special-purpose range.
63
+
64
+ ### `isPrivateIPv6(address: string): boolean`
65
+
66
+ Returns `true` if the given string is a valid IPv6 address in a private or special-purpose range.
67
+
68
+ ## Blocked Ranges
69
+
70
+ The following ranges are blocked (via `@microsoft/antissrf`):
71
+
72
+ - **IPv4**: Loopback (127.0.0.0/8), RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), Link-local (169.254.0.0/16), CGNAT (100.64.0.0/10), and more
73
+ - **IPv6**: Loopback (::1/128), ULA (fc00::/7), Link-local (fe80::/10), IPv4-mapped (::ffff:0:0/96), and more
74
+
75
+ ## License
76
+
77
+ MIT
@@ -0,0 +1,224 @@
1
+ body, html {
2
+ margin:0; padding: 0;
3
+ height: 100%;
4
+ }
5
+ body {
6
+ font-family: Helvetica Neue, Helvetica, Arial;
7
+ font-size: 14px;
8
+ color:#333;
9
+ }
10
+ .small { font-size: 12px; }
11
+ *, *:after, *:before {
12
+ -webkit-box-sizing:border-box;
13
+ -moz-box-sizing:border-box;
14
+ box-sizing:border-box;
15
+ }
16
+ h1 { font-size: 20px; margin: 0;}
17
+ h2 { font-size: 14px; }
18
+ pre {
19
+ font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace;
20
+ margin: 0;
21
+ padding: 0;
22
+ -moz-tab-size: 2;
23
+ -o-tab-size: 2;
24
+ tab-size: 2;
25
+ }
26
+ a { color:#0074D9; text-decoration:none; }
27
+ a:hover { text-decoration:underline; }
28
+ .strong { font-weight: bold; }
29
+ .space-top1 { padding: 10px 0 0 0; }
30
+ .pad2y { padding: 20px 0; }
31
+ .pad1y { padding: 10px 0; }
32
+ .pad2x { padding: 0 20px; }
33
+ .pad2 { padding: 20px; }
34
+ .pad1 { padding: 10px; }
35
+ .space-left2 { padding-left:55px; }
36
+ .space-right2 { padding-right:20px; }
37
+ .center { text-align:center; }
38
+ .clearfix { display:block; }
39
+ .clearfix:after {
40
+ content:'';
41
+ display:block;
42
+ height:0;
43
+ clear:both;
44
+ visibility:hidden;
45
+ }
46
+ .fl { float: left; }
47
+ @media only screen and (max-width:640px) {
48
+ .col3 { width:100%; max-width:100%; }
49
+ .hide-mobile { display:none!important; }
50
+ }
51
+
52
+ .quiet {
53
+ color: #7f7f7f;
54
+ color: rgba(0,0,0,0.5);
55
+ }
56
+ .quiet a { opacity: 0.7; }
57
+
58
+ .fraction {
59
+ font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
60
+ font-size: 10px;
61
+ color: #555;
62
+ background: #E8E8E8;
63
+ padding: 4px 5px;
64
+ border-radius: 3px;
65
+ vertical-align: middle;
66
+ }
67
+
68
+ div.path a:link, div.path a:visited { color: #333; }
69
+ table.coverage {
70
+ border-collapse: collapse;
71
+ margin: 10px 0 0 0;
72
+ padding: 0;
73
+ }
74
+
75
+ table.coverage td {
76
+ margin: 0;
77
+ padding: 0;
78
+ vertical-align: top;
79
+ }
80
+ table.coverage td.line-count {
81
+ text-align: right;
82
+ padding: 0 5px 0 20px;
83
+ }
84
+ table.coverage td.line-coverage {
85
+ text-align: right;
86
+ padding-right: 10px;
87
+ min-width:20px;
88
+ }
89
+
90
+ table.coverage td span.cline-any {
91
+ display: inline-block;
92
+ padding: 0 5px;
93
+ width: 100%;
94
+ }
95
+ .missing-if-branch {
96
+ display: inline-block;
97
+ margin-right: 5px;
98
+ border-radius: 3px;
99
+ position: relative;
100
+ padding: 0 4px;
101
+ background: #333;
102
+ color: yellow;
103
+ }
104
+
105
+ .skip-if-branch {
106
+ display: none;
107
+ margin-right: 10px;
108
+ position: relative;
109
+ padding: 0 4px;
110
+ background: #ccc;
111
+ color: white;
112
+ }
113
+ .missing-if-branch .typ, .skip-if-branch .typ {
114
+ color: inherit !important;
115
+ }
116
+ .coverage-summary {
117
+ border-collapse: collapse;
118
+ width: 100%;
119
+ }
120
+ .coverage-summary tr { border-bottom: 1px solid #bbb; }
121
+ .keyline-all { border: 1px solid #ddd; }
122
+ .coverage-summary td, .coverage-summary th { padding: 10px; }
123
+ .coverage-summary tbody { border: 1px solid #bbb; }
124
+ .coverage-summary td { border-right: 1px solid #bbb; }
125
+ .coverage-summary td:last-child { border-right: none; }
126
+ .coverage-summary th {
127
+ text-align: left;
128
+ font-weight: normal;
129
+ white-space: nowrap;
130
+ }
131
+ .coverage-summary th.file { border-right: none !important; }
132
+ .coverage-summary th.pct { }
133
+ .coverage-summary th.pic,
134
+ .coverage-summary th.abs,
135
+ .coverage-summary td.pct,
136
+ .coverage-summary td.abs { text-align: right; }
137
+ .coverage-summary td.file { white-space: nowrap; }
138
+ .coverage-summary td.pic { min-width: 120px !important; }
139
+ .coverage-summary tfoot td { }
140
+
141
+ .coverage-summary .sorter {
142
+ height: 10px;
143
+ width: 7px;
144
+ display: inline-block;
145
+ margin-left: 0.5em;
146
+ background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
147
+ }
148
+ .coverage-summary .sorted .sorter {
149
+ background-position: 0 -20px;
150
+ }
151
+ .coverage-summary .sorted-desc .sorter {
152
+ background-position: 0 -10px;
153
+ }
154
+ .status-line { height: 10px; }
155
+ /* yellow */
156
+ .cbranch-no { background: yellow !important; color: #111; }
157
+ /* dark red */
158
+ .red.solid, .status-line.low, .low .cover-fill { background:#C21F39 }
159
+ .low .chart { border:1px solid #C21F39 }
160
+ .highlighted,
161
+ .highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{
162
+ background: #C21F39 !important;
163
+ }
164
+ /* medium red */
165
+ .cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE }
166
+ /* light red */
167
+ .low, .cline-no { background:#FCE1E5 }
168
+ /* light green */
169
+ .high, .cline-yes { background:rgb(230,245,208) }
170
+ /* medium green */
171
+ .cstat-yes { background:rgb(161,215,106) }
172
+ /* dark green */
173
+ .status-line.high, .high .cover-fill { background:rgb(77,146,33) }
174
+ .high .chart { border:1px solid rgb(77,146,33) }
175
+ /* dark yellow (gold) */
176
+ .status-line.medium, .medium .cover-fill { background: #f9cd0b; }
177
+ .medium .chart { border:1px solid #f9cd0b; }
178
+ /* light yellow */
179
+ .medium { background: #fff4c2; }
180
+
181
+ .cstat-skip { background: #ddd; color: #111; }
182
+ .fstat-skip { background: #ddd; color: #111 !important; }
183
+ .cbranch-skip { background: #ddd !important; color: #111; }
184
+
185
+ span.cline-neutral { background: #eaeaea; }
186
+
187
+ .coverage-summary td.empty {
188
+ opacity: .5;
189
+ padding-top: 4px;
190
+ padding-bottom: 4px;
191
+ line-height: 1;
192
+ color: #888;
193
+ }
194
+
195
+ .cover-fill, .cover-empty {
196
+ display:inline-block;
197
+ height: 12px;
198
+ }
199
+ .chart {
200
+ line-height: 0;
201
+ }
202
+ .cover-empty {
203
+ background: white;
204
+ }
205
+ .cover-full {
206
+ border-right: none !important;
207
+ }
208
+ pre.prettyprint {
209
+ border: none !important;
210
+ padding: 0 !important;
211
+ margin: 0 !important;
212
+ }
213
+ .com { color: #999 !important; }
214
+ .ignore-none { color: #999; font-weight: normal; }
215
+
216
+ .wrapper {
217
+ min-height: 100%;
218
+ height: auto !important;
219
+ height: 100%;
220
+ margin: 0 auto -48px;
221
+ }
222
+ .footer, .push {
223
+ height: 48px;
224
+ }
@@ -0,0 +1,87 @@
1
+ /* eslint-disable */
2
+ var jumpToCode = (function init() {
3
+ // Classes of code we would like to highlight in the file view
4
+ var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no'];
5
+
6
+ // Elements to highlight in the file listing view
7
+ var fileListingElements = ['td.pct.low'];
8
+
9
+ // We don't want to select elements that are direct descendants of another match
10
+ var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > `
11
+
12
+ // Selector that finds elements on the page to which we can jump
13
+ var selector =
14
+ fileListingElements.join(', ') +
15
+ ', ' +
16
+ notSelector +
17
+ missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b`
18
+
19
+ // The NodeList of matching elements
20
+ var missingCoverageElements = document.querySelectorAll(selector);
21
+
22
+ var currentIndex;
23
+
24
+ function toggleClass(index) {
25
+ missingCoverageElements
26
+ .item(currentIndex)
27
+ .classList.remove('highlighted');
28
+ missingCoverageElements.item(index).classList.add('highlighted');
29
+ }
30
+
31
+ function makeCurrent(index) {
32
+ toggleClass(index);
33
+ currentIndex = index;
34
+ missingCoverageElements.item(index).scrollIntoView({
35
+ behavior: 'smooth',
36
+ block: 'center',
37
+ inline: 'center'
38
+ });
39
+ }
40
+
41
+ function goToPrevious() {
42
+ var nextIndex = 0;
43
+ if (typeof currentIndex !== 'number' || currentIndex === 0) {
44
+ nextIndex = missingCoverageElements.length - 1;
45
+ } else if (missingCoverageElements.length > 1) {
46
+ nextIndex = currentIndex - 1;
47
+ }
48
+
49
+ makeCurrent(nextIndex);
50
+ }
51
+
52
+ function goToNext() {
53
+ var nextIndex = 0;
54
+
55
+ if (
56
+ typeof currentIndex === 'number' &&
57
+ currentIndex < missingCoverageElements.length - 1
58
+ ) {
59
+ nextIndex = currentIndex + 1;
60
+ }
61
+
62
+ makeCurrent(nextIndex);
63
+ }
64
+
65
+ return function jump(event) {
66
+ if (
67
+ document.getElementById('fileSearch') === document.activeElement &&
68
+ document.activeElement != null
69
+ ) {
70
+ // if we're currently focused on the search input, we don't want to navigate
71
+ return;
72
+ }
73
+
74
+ switch (event.which) {
75
+ case 78: // n
76
+ case 74: // j
77
+ goToNext();
78
+ break;
79
+ case 66: // b
80
+ case 75: // k
81
+ case 80: // p
82
+ goToPrevious();
83
+ break;
84
+ }
85
+ };
86
+ })();
87
+ window.addEventListener('keydown', jumpToCode);
Binary file
@@ -0,0 +1,116 @@
1
+
2
+ <!doctype html>
3
+ <html lang="en">
4
+
5
+ <head>
6
+ <title>Code coverage report for All files</title>
7
+ <meta charset="utf-8" />
8
+ <link rel="stylesheet" href="prettify.css" />
9
+ <link rel="stylesheet" href="base.css" />
10
+ <link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
11
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
12
+ <style type='text/css'>
13
+ .coverage-summary .sorter {
14
+ background-image: url(sort-arrow-sprite.png);
15
+ }
16
+ </style>
17
+ </head>
18
+
19
+ <body>
20
+ <div class='wrapper'>
21
+ <div class='pad1'>
22
+ <h1>All files</h1>
23
+ <div class='clearfix'>
24
+
25
+ <div class='fl pad1y space-right2'>
26
+ <span class="strong">96.52% </span>
27
+ <span class="quiet">Statements</span>
28
+ <span class='fraction'>111/115</span>
29
+ </div>
30
+
31
+
32
+ <div class='fl pad1y space-right2'>
33
+ <span class="strong">93.18% </span>
34
+ <span class="quiet">Branches</span>
35
+ <span class='fraction'>41/44</span>
36
+ </div>
37
+
38
+
39
+ <div class='fl pad1y space-right2'>
40
+ <span class="strong">100% </span>
41
+ <span class="quiet">Functions</span>
42
+ <span class='fraction'>5/5</span>
43
+ </div>
44
+
45
+
46
+ <div class='fl pad1y space-right2'>
47
+ <span class="strong">96.52% </span>
48
+ <span class="quiet">Lines</span>
49
+ <span class='fraction'>111/115</span>
50
+ </div>
51
+
52
+
53
+ </div>
54
+ <p class="quiet">
55
+ Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
56
+ </p>
57
+ <template id="filterTemplate">
58
+ <div class="quiet">
59
+ Filter:
60
+ <input type="search" id="fileSearch">
61
+ </div>
62
+ </template>
63
+ </div>
64
+ <div class='status-line high'></div>
65
+ <div class="pad1">
66
+ <table class="coverage-summary">
67
+ <thead>
68
+ <tr>
69
+ <th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
70
+ <th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
71
+ <th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
72
+ <th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
73
+ <th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
74
+ <th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
75
+ <th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
76
+ <th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
77
+ <th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
78
+ <th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
79
+ </tr>
80
+ </thead>
81
+ <tbody><tr>
82
+ <td class="file high" data-value="safeurl.ts"><a href="safeurl.ts.html">safeurl.ts</a></td>
83
+ <td data-value="96.52" class="pic high">
84
+ <div class="chart"><div class="cover-fill" style="width: 96%"></div><div class="cover-empty" style="width: 4%"></div></div>
85
+ </td>
86
+ <td data-value="96.52" class="pct high">96.52%</td>
87
+ <td data-value="115" class="abs high">111/115</td>
88
+ <td data-value="93.18" class="pct high">93.18%</td>
89
+ <td data-value="44" class="abs high">41/44</td>
90
+ <td data-value="100" class="pct high">100%</td>
91
+ <td data-value="5" class="abs high">5/5</td>
92
+ <td data-value="96.52" class="pct high">96.52%</td>
93
+ <td data-value="115" class="abs high">111/115</td>
94
+ </tr>
95
+
96
+ </tbody>
97
+ </table>
98
+ </div>
99
+ <div class='push'></div><!-- for sticky footer -->
100
+ </div><!-- /wrapper -->
101
+ <div class='footer quiet pad2 space-top1 center small'>
102
+ Code coverage generated by
103
+ <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
104
+ at 2026-06-04T17:24:54.887Z
105
+ </div>
106
+ <script src="prettify.js"></script>
107
+ <script>
108
+ window.onload = function () {
109
+ prettyPrint();
110
+ };
111
+ </script>
112
+ <script src="sorter.js"></script>
113
+ <script src="block-navigation.js"></script>
114
+ </body>
115
+ </html>
116
+