@angular-helpers/security 21.2.0 → 21.3.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.
- package/README.es.md +195 -0
- package/README.md +197 -0
- package/fesm2022/angular-helpers-security-forms.mjs +110 -0
- package/fesm2022/angular-helpers-security-signal-forms.mjs +159 -0
- package/fesm2022/angular-helpers-security.mjs +920 -180
- package/package.json +15 -1
- package/types/angular-helpers-security-forms.d.ts +65 -0
- package/types/angular-helpers-security-signal-forms.d.ts +99 -0
- package/types/angular-helpers-security.d.ts +414 -10
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { inject, DestroyRef, Injectable, PLATFORM_ID, InjectionToken, makeEnvironmentProviders } from '@angular/core';
|
|
2
|
+
import { inject, DestroyRef, Injectable, PLATFORM_ID, InjectionToken, signal, computed, makeEnvironmentProviders } from '@angular/core';
|
|
3
3
|
import { isPlatformBrowser } from '@angular/common';
|
|
4
|
+
import { Observable } from 'rxjs';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Security service for regular expressions that prevents ReDoS
|
|
@@ -64,10 +65,10 @@ class RegexSecurityService {
|
|
|
64
65
|
message: 'Nested quantifiers (catastrophic backtracking)',
|
|
65
66
|
},
|
|
66
67
|
{ pattern: /\+\+/, risk: 'high', message: 'Nested plus quantifiers' },
|
|
67
|
-
{ pattern: /\(
|
|
68
|
-
{ pattern: /\(
|
|
69
|
-
{ pattern: /\(
|
|
70
|
-
{ pattern: /\(
|
|
68
|
+
{ pattern: /\(\?=/, risk: 'medium', message: 'Lookahead assertions' },
|
|
69
|
+
{ pattern: /\(\?!/, risk: 'medium', message: 'Negative lookahead' },
|
|
70
|
+
{ pattern: /\(\?:/, risk: 'low', message: 'Non-capturing groups' },
|
|
71
|
+
{ pattern: /\(\?</, risk: 'high', message: 'Lookbehind assertions' },
|
|
71
72
|
{ pattern: /\(\?\(\?\)/, risk: 'critical', message: 'Recursive patterns' },
|
|
72
73
|
{ pattern: /(\{(\d+,)?\d+\})/, risk: 'medium', message: 'Quantified repetition' },
|
|
73
74
|
{
|
|
@@ -247,9 +248,9 @@ class RegexSecurityService {
|
|
|
247
248
|
complexity += (pattern.match(/\+\+/g) || []).length * 5;
|
|
248
249
|
complexity += (pattern.match(/\?\?/g) || []).length * 3;
|
|
249
250
|
// Lookaheads/lookbehinds
|
|
250
|
-
complexity += (pattern.match(/\(
|
|
251
|
-
complexity += (pattern.match(/\(
|
|
252
|
-
complexity += (pattern.match(/\(
|
|
251
|
+
complexity += (pattern.match(/\(\?=/g) || []).length * 2;
|
|
252
|
+
complexity += (pattern.match(/\(\?!/g) || []).length * 2;
|
|
253
|
+
complexity += (pattern.match(/\(\?</g) || []).length * 3;
|
|
253
254
|
// Nested groups
|
|
254
255
|
const openParens = (pattern.match(/\(/g) || []).length;
|
|
255
256
|
complexity += openParens * 0.5;
|
|
@@ -725,7 +726,185 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImpor
|
|
|
725
726
|
type: Injectable
|
|
726
727
|
}], ctorParameters: () => [] });
|
|
727
728
|
|
|
728
|
-
|
|
729
|
+
/**
|
|
730
|
+
* Internal validator helpers shared by both Reactive Forms validators
|
|
731
|
+
* (`SecurityValidators`) and Signal Forms validators (`@angular-helpers/security/signal-forms`).
|
|
732
|
+
*
|
|
733
|
+
* All helpers in this module are pure and side-effect free. Browser-only helpers
|
|
734
|
+
* (those using DOMParser) are clearly marked and throw when called outside a browser.
|
|
735
|
+
*
|
|
736
|
+
* This module is INTERNAL — not part of the public API surface.
|
|
737
|
+
*/
|
|
738
|
+
const COMMON_PASSWORDS = new Set([
|
|
739
|
+
'password',
|
|
740
|
+
'123456',
|
|
741
|
+
'qwerty',
|
|
742
|
+
'letmein',
|
|
743
|
+
'admin',
|
|
744
|
+
'welcome',
|
|
745
|
+
'111111',
|
|
746
|
+
'abc123',
|
|
747
|
+
'monkey',
|
|
748
|
+
'master',
|
|
749
|
+
'login',
|
|
750
|
+
'pass',
|
|
751
|
+
]);
|
|
752
|
+
const ALPHA_SEQUENCES = 'abcdefghijklmnopqrstuvwxyz';
|
|
753
|
+
const DIGIT_SEQUENCES = '0123456789';
|
|
754
|
+
const KEYBOARD_ROWS = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'];
|
|
755
|
+
const SCORE_LABELS = {
|
|
756
|
+
0: 'very-weak',
|
|
757
|
+
1: 'weak',
|
|
758
|
+
2: 'fair',
|
|
759
|
+
3: 'strong',
|
|
760
|
+
4: 'very-strong',
|
|
761
|
+
};
|
|
762
|
+
/**
|
|
763
|
+
* Entropy-based password assessment. Pure function — safe to call outside Angular context.
|
|
764
|
+
*
|
|
765
|
+
* Thresholds (bits of entropy):
|
|
766
|
+
* - 0 (very-weak): < 28
|
|
767
|
+
* - 1 (weak): 28–35
|
|
768
|
+
* - 2 (fair): 36–49
|
|
769
|
+
* - 3 (strong): 50–69
|
|
770
|
+
* - 4 (very-strong): ≥ 70
|
|
771
|
+
*/
|
|
772
|
+
function assessPasswordStrength(password) {
|
|
773
|
+
if (!password) {
|
|
774
|
+
return {
|
|
775
|
+
score: 0,
|
|
776
|
+
label: 'very-weak',
|
|
777
|
+
entropy: 0,
|
|
778
|
+
feedback: ['Password cannot be empty'],
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
const feedback = [];
|
|
782
|
+
const chars = [...password];
|
|
783
|
+
const length = chars.length;
|
|
784
|
+
const hasLower = /[a-z]/.test(password);
|
|
785
|
+
const hasUpper = /[A-Z]/.test(password);
|
|
786
|
+
const hasDigit = /[0-9]/.test(password);
|
|
787
|
+
const hasSymbol = /[!@#$%^&*()\-_=+[\]{}|;:'",.<>/?\\`~]/.test(password);
|
|
788
|
+
const hasExtended = chars.some((c) => c.codePointAt(0) > 127);
|
|
789
|
+
let poolSize = 0;
|
|
790
|
+
if (hasLower)
|
|
791
|
+
poolSize += 26;
|
|
792
|
+
if (hasUpper)
|
|
793
|
+
poolSize += 26;
|
|
794
|
+
if (hasDigit)
|
|
795
|
+
poolSize += 10;
|
|
796
|
+
if (hasSymbol)
|
|
797
|
+
poolSize += 32;
|
|
798
|
+
if (hasExtended)
|
|
799
|
+
poolSize += 64;
|
|
800
|
+
if (poolSize === 0)
|
|
801
|
+
poolSize = 26;
|
|
802
|
+
let entropy = length * Math.log2(poolSize);
|
|
803
|
+
const hasAlphaSeq = containsSequence(password.toLowerCase(), ALPHA_SEQUENCES, 3);
|
|
804
|
+
const hasDigitSeq = containsSequence(password, DIGIT_SEQUENCES, 3);
|
|
805
|
+
const hasRepeat = /(.)\1{2,}/.test(password);
|
|
806
|
+
const hasKeyboard = KEYBOARD_ROWS.some((row) => containsSequence(password.toLowerCase(), row, 4));
|
|
807
|
+
if (hasAlphaSeq || hasDigitSeq) {
|
|
808
|
+
entropy *= 0.8;
|
|
809
|
+
feedback.push('Avoid predictable sequences (abc, 123)');
|
|
810
|
+
}
|
|
811
|
+
if (hasRepeat) {
|
|
812
|
+
entropy *= 0.9;
|
|
813
|
+
feedback.push('Avoid repeated characters');
|
|
814
|
+
}
|
|
815
|
+
if (hasKeyboard) {
|
|
816
|
+
entropy *= 0.85;
|
|
817
|
+
feedback.push('Avoid keyboard patterns (qwerty, asdf)');
|
|
818
|
+
}
|
|
819
|
+
if (length < 8) {
|
|
820
|
+
feedback.push('Use at least 8 characters');
|
|
821
|
+
}
|
|
822
|
+
if (!hasUpper)
|
|
823
|
+
feedback.push('Add uppercase letters');
|
|
824
|
+
if (!hasDigit)
|
|
825
|
+
feedback.push('Add numbers');
|
|
826
|
+
if (!hasSymbol)
|
|
827
|
+
feedback.push('Add special characters');
|
|
828
|
+
const isCommon = COMMON_PASSWORDS.has(password.toLowerCase());
|
|
829
|
+
let score = entropyToScore(entropy);
|
|
830
|
+
if (isCommon) {
|
|
831
|
+
score = Math.min(score, 1);
|
|
832
|
+
if (!feedback.includes('Use at least 8 characters')) {
|
|
833
|
+
feedback.push('This is a commonly used password');
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return {
|
|
837
|
+
score,
|
|
838
|
+
label: SCORE_LABELS[score],
|
|
839
|
+
entropy: Math.round(entropy * 100) / 100,
|
|
840
|
+
feedback,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
function entropyToScore(entropy) {
|
|
844
|
+
if (entropy < 28)
|
|
845
|
+
return 0;
|
|
846
|
+
if (entropy < 36)
|
|
847
|
+
return 1;
|
|
848
|
+
if (entropy < 50)
|
|
849
|
+
return 2;
|
|
850
|
+
if (entropy < 70)
|
|
851
|
+
return 3;
|
|
852
|
+
return 4;
|
|
853
|
+
}
|
|
854
|
+
function containsSequence(input, sequence, minLength) {
|
|
855
|
+
for (let i = 0; i <= sequence.length - minLength; i++) {
|
|
856
|
+
if (input.includes(sequence.substring(i, i + minLength)))
|
|
857
|
+
return true;
|
|
858
|
+
}
|
|
859
|
+
return false;
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* URL safety check. Returns the normalized URL when the scheme is allowed, `null` otherwise.
|
|
863
|
+
* Malformed URLs, relative URLs, and blocked schemes all return `null`.
|
|
864
|
+
*/
|
|
865
|
+
function sanitizeUrlString(input, allowedSchemes = ['http:', 'https:']) {
|
|
866
|
+
if (!input)
|
|
867
|
+
return null;
|
|
868
|
+
try {
|
|
869
|
+
const url = new URL(input);
|
|
870
|
+
return allowedSchemes.includes(url.protocol) ? url.toString() : null;
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Boolean helper around `sanitizeUrlString`. `true` when the URL is well-formed and allowed.
|
|
878
|
+
*/
|
|
879
|
+
function isUrlSafe(input, allowedSchemes = ['http:', 'https:']) {
|
|
880
|
+
return sanitizeUrlString(input, allowedSchemes) !== null;
|
|
881
|
+
}
|
|
882
|
+
const SCRIPT_INJECTION_PATTERN = /<\s*script\b|javascript:|on\w+\s*=/i;
|
|
883
|
+
/**
|
|
884
|
+
* Lightweight check for common script-injection sentinels. Complements (does NOT replace)
|
|
885
|
+
* a full HTML sanitizer or Content Security Policy.
|
|
886
|
+
*/
|
|
887
|
+
function containsScriptInjection(input) {
|
|
888
|
+
if (!input)
|
|
889
|
+
return false;
|
|
890
|
+
return SCRIPT_INJECTION_PATTERN.test(input);
|
|
891
|
+
}
|
|
892
|
+
const SQL_HINT_PATTERNS = [
|
|
893
|
+
/'\s*or\s+'?1'?\s*=\s*'?1/i,
|
|
894
|
+
/--\s*$/m,
|
|
895
|
+
/;\s*--/,
|
|
896
|
+
/\/\*/,
|
|
897
|
+
/\bunion\s+select\b/i,
|
|
898
|
+
];
|
|
899
|
+
/**
|
|
900
|
+
* Heuristic check for SQL-injection sentinel strings.
|
|
901
|
+
* Intended as defense-in-depth for client-side form inputs; never a substitute for parameterized queries.
|
|
902
|
+
*/
|
|
903
|
+
function containsSqlInjectionHints(input) {
|
|
904
|
+
if (!input)
|
|
905
|
+
return false;
|
|
906
|
+
return SQL_HINT_PATTERNS.some((pattern) => pattern.test(input));
|
|
907
|
+
}
|
|
729
908
|
const DEFAULT_ALLOWED_TAGS = [
|
|
730
909
|
'b',
|
|
731
910
|
'i',
|
|
@@ -742,13 +921,81 @@ const DEFAULT_ALLOWED_TAGS = [
|
|
|
742
921
|
const DEFAULT_ALLOWED_ATTRIBUTES = {
|
|
743
922
|
a: ['href'],
|
|
744
923
|
};
|
|
745
|
-
|
|
924
|
+
/**
|
|
925
|
+
* Sanitizes an HTML string by allowlist. Browser-only: requires DOMParser.
|
|
926
|
+
*
|
|
927
|
+
* @throws {Error} When called in a non-browser environment.
|
|
928
|
+
*/
|
|
929
|
+
function sanitizeHtmlString(input, options = {}) {
|
|
930
|
+
if (!input)
|
|
931
|
+
return '';
|
|
932
|
+
if (typeof DOMParser === 'undefined') {
|
|
933
|
+
throw new Error('sanitizeHtmlString requires a browser environment (DOMParser unavailable)');
|
|
934
|
+
}
|
|
935
|
+
const allowedTags = new Set(options.allowedTags ?? DEFAULT_ALLOWED_TAGS);
|
|
936
|
+
const allowedAttributes = options.allowedAttributes ?? DEFAULT_ALLOWED_ATTRIBUTES;
|
|
937
|
+
const parser = new DOMParser();
|
|
938
|
+
const doc = parser.parseFromString(input, 'text/html');
|
|
939
|
+
processHtmlNode(doc.body, allowedTags, allowedAttributes);
|
|
940
|
+
return doc.body.innerHTML;
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Returns `true` when sanitizing `input` produces the same string (i.e. nothing was stripped).
|
|
944
|
+
* Empty strings are considered safe.
|
|
945
|
+
*
|
|
946
|
+
* @throws {Error} When called in a non-browser environment.
|
|
947
|
+
*/
|
|
948
|
+
function isHtmlSafe(input, options = {}) {
|
|
949
|
+
if (!input)
|
|
950
|
+
return true;
|
|
951
|
+
return sanitizeHtmlString(input, options) === input;
|
|
952
|
+
}
|
|
953
|
+
function processHtmlNode(node, allowedTags, allowedAttributes) {
|
|
954
|
+
const children = Array.from(node.childNodes);
|
|
955
|
+
for (const child of children) {
|
|
956
|
+
if (child.nodeType !== Node.ELEMENT_NODE)
|
|
957
|
+
continue;
|
|
958
|
+
const element = child;
|
|
959
|
+
const tagName = element.tagName.toLowerCase();
|
|
960
|
+
if (!allowedTags.has(tagName)) {
|
|
961
|
+
const text = element.textContent ?? '';
|
|
962
|
+
node.replaceChild(document.createTextNode(text), element);
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
sanitizeElementAttributes(element, tagName, allowedAttributes);
|
|
966
|
+
processHtmlNode(element, allowedTags, allowedAttributes);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
function sanitizeElementAttributes(element, tagName, allowedAttributes) {
|
|
970
|
+
const attrsToRemove = [];
|
|
971
|
+
const allowed = allowedAttributes[tagName] ?? [];
|
|
972
|
+
for (let i = 0; i < element.attributes.length; i++) {
|
|
973
|
+
const attr = element.attributes[i];
|
|
974
|
+
if (attr.name.startsWith('on')) {
|
|
975
|
+
attrsToRemove.push(attr.name);
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
if (!allowed.includes(attr.name)) {
|
|
979
|
+
attrsToRemove.push(attr.name);
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
if (attr.name === 'href' && sanitizeUrlString(attr.value) === null) {
|
|
983
|
+
attrsToRemove.push(attr.name);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
attrsToRemove.forEach((name) => element.removeAttribute(name));
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const SANITIZER_CONFIG = new InjectionToken('SANITIZER_CONFIG');
|
|
746
990
|
/**
|
|
747
991
|
* Service for structured input sanitization to defend against XSS, URL injection, and unsafe HTML.
|
|
748
992
|
*
|
|
749
993
|
* This service is defense-in-depth and DOES NOT replace a Content Security Policy (CSP).
|
|
750
994
|
* Always configure a proper CSP alongside using this service.
|
|
751
995
|
*
|
|
996
|
+
* Delegates to shared pure helpers in `internal/validators-core` so Signal Forms and Reactive Forms
|
|
997
|
+
* validators share the exact same sanitization logic.
|
|
998
|
+
*
|
|
752
999
|
* @example
|
|
753
1000
|
* const clean = sanitizer.sanitizeHtml('<b>Hello</b><script>alert(1)</script>');
|
|
754
1001
|
* // → '<b>Hello</b>'
|
|
@@ -763,7 +1010,7 @@ class InputSanitizerService {
|
|
|
763
1010
|
allowedAttributes;
|
|
764
1011
|
constructor() {
|
|
765
1012
|
const config = inject(SANITIZER_CONFIG, { optional: true }) ?? {};
|
|
766
|
-
this.allowedTags =
|
|
1013
|
+
this.allowedTags = config.allowedTags ?? DEFAULT_ALLOWED_TAGS;
|
|
767
1014
|
this.allowedAttributes = config.allowedAttributes ?? DEFAULT_ALLOWED_ATTRIBUTES;
|
|
768
1015
|
}
|
|
769
1016
|
isSupported() {
|
|
@@ -779,12 +1026,10 @@ class InputSanitizerService {
|
|
|
779
1026
|
if (!this.isSupported()) {
|
|
780
1027
|
throw new Error('sanitizeHtml requires a browser environment (DOMParser unavailable)');
|
|
781
1028
|
}
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
this.processNode(doc.body);
|
|
787
|
-
return doc.body.innerHTML;
|
|
1029
|
+
return sanitizeHtmlString(input, {
|
|
1030
|
+
allowedTags: this.allowedTags,
|
|
1031
|
+
allowedAttributes: this.allowedAttributes,
|
|
1032
|
+
});
|
|
788
1033
|
}
|
|
789
1034
|
/**
|
|
790
1035
|
* Validates and normalizes a URL string.
|
|
@@ -792,15 +1037,7 @@ class InputSanitizerService {
|
|
|
792
1037
|
* Returns `null` for `javascript:`, `data:`, `vbscript:`, relative URLs, or malformed input.
|
|
793
1038
|
*/
|
|
794
1039
|
sanitizeUrl(input) {
|
|
795
|
-
|
|
796
|
-
return null;
|
|
797
|
-
try {
|
|
798
|
-
const url = new URL(input);
|
|
799
|
-
return SAFE_URL_SCHEMES.includes(url.protocol) ? url.toString() : null;
|
|
800
|
-
}
|
|
801
|
-
catch {
|
|
802
|
-
return null;
|
|
803
|
-
}
|
|
1040
|
+
return sanitizeUrlString(input);
|
|
804
1041
|
}
|
|
805
1042
|
/**
|
|
806
1043
|
* Escapes HTML special characters for safe text interpolation.
|
|
@@ -830,41 +1067,6 @@ class InputSanitizerService {
|
|
|
830
1067
|
return null;
|
|
831
1068
|
}
|
|
832
1069
|
}
|
|
833
|
-
processNode(node) {
|
|
834
|
-
const children = Array.from(node.childNodes);
|
|
835
|
-
for (const child of children) {
|
|
836
|
-
if (child.nodeType !== Node.ELEMENT_NODE)
|
|
837
|
-
continue;
|
|
838
|
-
const element = child;
|
|
839
|
-
const tagName = element.tagName.toLowerCase();
|
|
840
|
-
if (!this.allowedTags.has(tagName)) {
|
|
841
|
-
const text = element.textContent ?? '';
|
|
842
|
-
node.replaceChild(document.createTextNode(text), element);
|
|
843
|
-
continue;
|
|
844
|
-
}
|
|
845
|
-
this.sanitizeAttributes(element, tagName);
|
|
846
|
-
this.processNode(element);
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
sanitizeAttributes(element, tagName) {
|
|
850
|
-
const attrsToRemove = [];
|
|
851
|
-
const allowed = this.allowedAttributes[tagName] ?? [];
|
|
852
|
-
for (let i = 0; i < element.attributes.length; i++) {
|
|
853
|
-
const attr = element.attributes[i];
|
|
854
|
-
if (attr.name.startsWith('on')) {
|
|
855
|
-
attrsToRemove.push(attr.name);
|
|
856
|
-
continue;
|
|
857
|
-
}
|
|
858
|
-
if (!allowed.includes(attr.name)) {
|
|
859
|
-
attrsToRemove.push(attr.name);
|
|
860
|
-
continue;
|
|
861
|
-
}
|
|
862
|
-
if (attr.name === 'href' && this.sanitizeUrl(attr.value) === null) {
|
|
863
|
-
attrsToRemove.push(attr.name);
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
attrsToRemove.forEach((name) => element.removeAttribute(name));
|
|
867
|
-
}
|
|
868
1070
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: InputSanitizerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
869
1071
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: InputSanitizerService });
|
|
870
1072
|
}
|
|
@@ -872,34 +1074,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImpor
|
|
|
872
1074
|
type: Injectable
|
|
873
1075
|
}], ctorParameters: () => [] });
|
|
874
1076
|
|
|
875
|
-
const COMMON_PASSWORDS = new Set([
|
|
876
|
-
'password',
|
|
877
|
-
'123456',
|
|
878
|
-
'qwerty',
|
|
879
|
-
'letmein',
|
|
880
|
-
'admin',
|
|
881
|
-
'welcome',
|
|
882
|
-
'111111',
|
|
883
|
-
'abc123',
|
|
884
|
-
'monkey',
|
|
885
|
-
'master',
|
|
886
|
-
'login',
|
|
887
|
-
'pass',
|
|
888
|
-
]);
|
|
889
|
-
const ALPHA_SEQUENCES = 'abcdefghijklmnopqrstuvwxyz';
|
|
890
|
-
const DIGIT_SEQUENCES = '0123456789';
|
|
891
|
-
const KEYBOARD_ROWS = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'];
|
|
892
|
-
const SCORE_LABELS = {
|
|
893
|
-
0: 'very-weak',
|
|
894
|
-
1: 'weak',
|
|
895
|
-
2: 'fair',
|
|
896
|
-
3: 'strong',
|
|
897
|
-
4: 'very-strong',
|
|
898
|
-
};
|
|
899
1077
|
/**
|
|
900
1078
|
* Service for entropy-based password strength evaluation.
|
|
901
1079
|
* All methods are synchronous and side-effect free — safely wrappable in Angular `computed()`.
|
|
902
1080
|
*
|
|
1081
|
+
* Delegates to the shared {@link assessPasswordStrength} helper so Reactive Forms validators,
|
|
1082
|
+
* Signal Forms validators, and direct service consumers all share identical logic.
|
|
1083
|
+
*
|
|
903
1084
|
* Score thresholds (bits of entropy):
|
|
904
1085
|
* - 0 (very-weak): < 28 bits
|
|
905
1086
|
* - 1 (weak): 28–35 bits
|
|
@@ -919,100 +1100,621 @@ class PasswordStrengthService {
|
|
|
919
1100
|
* Never throws — returns score 0 for empty or null-like input.
|
|
920
1101
|
*/
|
|
921
1102
|
assess(password) {
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1103
|
+
return assessPasswordStrength(password);
|
|
1104
|
+
}
|
|
1105
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: PasswordStrengthService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1106
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: PasswordStrengthService });
|
|
1107
|
+
}
|
|
1108
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: PasswordStrengthService, decorators: [{
|
|
1109
|
+
type: Injectable
|
|
1110
|
+
}] });
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Thrown when a string is not a well-formed JWT (wrong number of segments,
|
|
1114
|
+
* malformed base64url payload, or non-JSON payload).
|
|
1115
|
+
*/
|
|
1116
|
+
class InvalidJwtError extends Error {
|
|
1117
|
+
constructor(message) {
|
|
1118
|
+
super(message);
|
|
1119
|
+
this.name = 'InvalidJwtError';
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Client-side JWT inspection utilities.
|
|
1124
|
+
*
|
|
1125
|
+
* **This service decodes JWT payloads for client-side inspection only. Signature verification
|
|
1126
|
+
* MUST happen server-side** — this service never validates signatures, issuer, audience,
|
|
1127
|
+
* or any trust-related property.
|
|
1128
|
+
*
|
|
1129
|
+
* Use cases:
|
|
1130
|
+
* - Reading the expiration to schedule refresh
|
|
1131
|
+
* - Extracting user-facing claims (e.g. `name`, `email`) for UX
|
|
1132
|
+
* - Detecting expired tokens to redirect to login
|
|
1133
|
+
*
|
|
1134
|
+
* @example
|
|
1135
|
+
* const token = localStorage.getItem('access_token');
|
|
1136
|
+
* if (!token || jwt.isExpired(token, 30)) {
|
|
1137
|
+
* router.navigate(['/login']);
|
|
1138
|
+
* }
|
|
1139
|
+
* const userId = jwt.claim<string>(token, 'sub');
|
|
1140
|
+
*/
|
|
1141
|
+
class JwtService {
|
|
1142
|
+
/**
|
|
1143
|
+
* Decodes the payload segment of a JWT and returns it typed.
|
|
1144
|
+
*
|
|
1145
|
+
* @throws {InvalidJwtError} When the token is not three dot-separated segments,
|
|
1146
|
+
* or when the payload cannot be base64url-decoded and parsed as JSON.
|
|
1147
|
+
*/
|
|
1148
|
+
decode(token) {
|
|
1149
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
1150
|
+
throw new InvalidJwtError('JWT is empty');
|
|
929
1151
|
}
|
|
930
|
-
const
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
if (hasExtended)
|
|
948
|
-
poolSize += 64;
|
|
949
|
-
if (poolSize === 0)
|
|
950
|
-
poolSize = 26;
|
|
951
|
-
let entropy = length * Math.log2(poolSize);
|
|
952
|
-
const hasAlphaSeq = this.containsSequence(password.toLowerCase(), ALPHA_SEQUENCES, 3);
|
|
953
|
-
const hasDigitSeq = this.containsSequence(password, DIGIT_SEQUENCES, 3);
|
|
954
|
-
const hasRepeat = /(.)\1{2,}/.test(password);
|
|
955
|
-
const hasKeyboard = KEYBOARD_ROWS.some((row) => this.containsSequence(password.toLowerCase(), row, 4));
|
|
956
|
-
if (hasAlphaSeq || hasDigitSeq) {
|
|
957
|
-
entropy *= 0.8;
|
|
958
|
-
feedback.push('Avoid predictable sequences (abc, 123)');
|
|
959
|
-
}
|
|
960
|
-
if (hasRepeat) {
|
|
961
|
-
entropy *= 0.9;
|
|
962
|
-
feedback.push('Avoid repeated characters');
|
|
963
|
-
}
|
|
964
|
-
if (hasKeyboard) {
|
|
965
|
-
entropy *= 0.85;
|
|
966
|
-
feedback.push('Avoid keyboard patterns (qwerty, asdf)');
|
|
967
|
-
}
|
|
968
|
-
if (length < 8) {
|
|
969
|
-
feedback.push('Use at least 8 characters');
|
|
970
|
-
}
|
|
971
|
-
if (!hasUpper)
|
|
972
|
-
feedback.push('Add uppercase letters');
|
|
973
|
-
if (!hasDigit)
|
|
974
|
-
feedback.push('Add numbers');
|
|
975
|
-
if (!hasSymbol)
|
|
976
|
-
feedback.push('Add special characters');
|
|
977
|
-
const isCommon = COMMON_PASSWORDS.has(password.toLowerCase());
|
|
978
|
-
let score = this.entropyToScore(entropy);
|
|
979
|
-
if (isCommon) {
|
|
980
|
-
score = Math.min(score, 1);
|
|
981
|
-
if (!feedback.includes('Use at least 8 characters')) {
|
|
982
|
-
feedback.push('This is a commonly used password');
|
|
983
|
-
}
|
|
1152
|
+
const segments = token.split('.');
|
|
1153
|
+
if (segments.length !== 3) {
|
|
1154
|
+
throw new InvalidJwtError(`JWT must have 3 segments, got ${segments.length}`);
|
|
1155
|
+
}
|
|
1156
|
+
const payloadSegment = segments[1];
|
|
1157
|
+
let json;
|
|
1158
|
+
try {
|
|
1159
|
+
json = base64UrlDecode(payloadSegment);
|
|
1160
|
+
}
|
|
1161
|
+
catch (cause) {
|
|
1162
|
+
throw new InvalidJwtError(`JWT payload is not valid base64url: ${cause instanceof Error ? cause.message : 'unknown'}`);
|
|
1163
|
+
}
|
|
1164
|
+
try {
|
|
1165
|
+
return JSON.parse(json);
|
|
1166
|
+
}
|
|
1167
|
+
catch (cause) {
|
|
1168
|
+
throw new InvalidJwtError(`JWT payload is not valid JSON: ${cause instanceof Error ? cause.message : 'unknown'}`);
|
|
984
1169
|
}
|
|
985
|
-
return {
|
|
986
|
-
score,
|
|
987
|
-
label: SCORE_LABELS[score],
|
|
988
|
-
entropy: Math.round(entropy * 100) / 100,
|
|
989
|
-
feedback,
|
|
990
|
-
};
|
|
991
1170
|
}
|
|
992
|
-
|
|
993
|
-
|
|
1171
|
+
/**
|
|
1172
|
+
* Returns `true` when the token is expired relative to `Date.now()`.
|
|
1173
|
+
* Missing or non-numeric `exp` counts as expired (fail-secure).
|
|
1174
|
+
*
|
|
1175
|
+
* @param leewaySeconds Optional clock-skew tolerance in seconds. Default: `0`.
|
|
1176
|
+
*/
|
|
1177
|
+
isExpired(token, leewaySeconds = 0) {
|
|
1178
|
+
let payload;
|
|
1179
|
+
try {
|
|
1180
|
+
payload = this.decode(token);
|
|
1181
|
+
}
|
|
1182
|
+
catch {
|
|
1183
|
+
return true;
|
|
1184
|
+
}
|
|
1185
|
+
if (typeof payload.exp !== 'number')
|
|
1186
|
+
return true;
|
|
1187
|
+
const expiresAtMs = payload.exp * 1000;
|
|
1188
|
+
return expiresAtMs <= Date.now() - leewaySeconds * 1000;
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* Returns milliseconds until the token expires.
|
|
1192
|
+
* Negative when already expired. `0` when `exp` is missing.
|
|
1193
|
+
*/
|
|
1194
|
+
expiresIn(token) {
|
|
1195
|
+
let payload;
|
|
1196
|
+
try {
|
|
1197
|
+
payload = this.decode(token);
|
|
1198
|
+
}
|
|
1199
|
+
catch {
|
|
1200
|
+
return -1;
|
|
1201
|
+
}
|
|
1202
|
+
if (typeof payload.exp !== 'number')
|
|
994
1203
|
return 0;
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
if (input.includes(sequence.substring(i, i + minLength)))
|
|
1006
|
-
return true;
|
|
1204
|
+
return payload.exp * 1000 - Date.now();
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Extracts a single claim from the payload. Returns `null` when the claim is absent
|
|
1208
|
+
* or the token is malformed.
|
|
1209
|
+
*/
|
|
1210
|
+
claim(token, name) {
|
|
1211
|
+
let payload;
|
|
1212
|
+
try {
|
|
1213
|
+
payload = this.decode(token);
|
|
1007
1214
|
}
|
|
1008
|
-
|
|
1215
|
+
catch {
|
|
1216
|
+
return null;
|
|
1217
|
+
}
|
|
1218
|
+
return payload[name] ?? null;
|
|
1009
1219
|
}
|
|
1010
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type:
|
|
1011
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type:
|
|
1220
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: JwtService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1221
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: JwtService });
|
|
1012
1222
|
}
|
|
1013
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type:
|
|
1223
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: JwtService, decorators: [{
|
|
1014
1224
|
type: Injectable
|
|
1015
1225
|
}] });
|
|
1226
|
+
function base64UrlDecode(input) {
|
|
1227
|
+
const padded = input.replace(/-/g, '+').replace(/_/g, '/');
|
|
1228
|
+
const padLength = (4 - (padded.length % 4)) % 4;
|
|
1229
|
+
const base64 = padded + '='.repeat(padLength);
|
|
1230
|
+
const binary = atob(base64);
|
|
1231
|
+
const bytes = new Uint8Array(binary.length);
|
|
1232
|
+
for (let i = 0; i < binary.length; i++) {
|
|
1233
|
+
bytes[i] = binary.charCodeAt(i);
|
|
1234
|
+
}
|
|
1235
|
+
return new TextDecoder().decode(bytes);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
class ClipboardUnsupportedError extends Error {
|
|
1239
|
+
constructor() {
|
|
1240
|
+
super('Clipboard API not available in this environment');
|
|
1241
|
+
this.name = 'ClipboardUnsupportedError';
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
const DEFAULT_CLEAR_MS = 15_000;
|
|
1245
|
+
/**
|
|
1246
|
+
* Copies sensitive strings to the clipboard with automatic, verified clearing.
|
|
1247
|
+
*
|
|
1248
|
+
* Mirrors the behaviour of password managers (1Password, Bitwarden): the clipboard is
|
|
1249
|
+
* cleared only when its current content still matches what we wrote, preventing clobbering
|
|
1250
|
+
* of unrelated user copies.
|
|
1251
|
+
*
|
|
1252
|
+
* Requires a secure context and `navigator.clipboard`. The auto-clear step additionally
|
|
1253
|
+
* requires `navigator.clipboard.readText()` permission; when denied, the clear is skipped
|
|
1254
|
+
* and the status emits `'read-denied'`.
|
|
1255
|
+
*
|
|
1256
|
+
* Any pending clear timer is cancelled automatically when the owning injector is destroyed.
|
|
1257
|
+
*
|
|
1258
|
+
* @example
|
|
1259
|
+
* await sensitiveClipboard.copy(password, { clearAfterMs: 15_000 });
|
|
1260
|
+
*/
|
|
1261
|
+
class SensitiveClipboardService {
|
|
1262
|
+
platformId = inject(PLATFORM_ID);
|
|
1263
|
+
destroyRef = inject(DestroyRef);
|
|
1264
|
+
pendingTimer = null;
|
|
1265
|
+
constructor() {
|
|
1266
|
+
this.destroyRef.onDestroy(() => this.cancelPendingClear());
|
|
1267
|
+
}
|
|
1268
|
+
isSupported() {
|
|
1269
|
+
return (isPlatformBrowser(this.platformId) &&
|
|
1270
|
+
typeof navigator !== 'undefined' &&
|
|
1271
|
+
typeof navigator.clipboard?.writeText === 'function');
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Writes `text` to the clipboard and schedules an auto-clear.
|
|
1275
|
+
*
|
|
1276
|
+
* @throws {ClipboardUnsupportedError} When the Clipboard API is unavailable.
|
|
1277
|
+
*/
|
|
1278
|
+
async copy(text, options = {}) {
|
|
1279
|
+
if (!this.isSupported()) {
|
|
1280
|
+
throw new ClipboardUnsupportedError();
|
|
1281
|
+
}
|
|
1282
|
+
const clearAfterMs = options.clearAfterMs ?? DEFAULT_CLEAR_MS;
|
|
1283
|
+
await navigator.clipboard.writeText(text);
|
|
1284
|
+
this.cancelPendingClear();
|
|
1285
|
+
if (clearAfterMs <= 0)
|
|
1286
|
+
return;
|
|
1287
|
+
this.pendingTimer = setTimeout(() => {
|
|
1288
|
+
void this.safeClear(text);
|
|
1289
|
+
}, clearAfterMs);
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* Reactive variant of {@link copy}. Emits `'copied'` immediately after writing, then
|
|
1293
|
+
* `'cleared'` | `'read-denied'` | `'error'` once the auto-clear completes (or is skipped).
|
|
1294
|
+
*/
|
|
1295
|
+
copy$(text, options = {}) {
|
|
1296
|
+
return new Observable((subscriber) => {
|
|
1297
|
+
if (!this.isSupported()) {
|
|
1298
|
+
subscriber.error(new ClipboardUnsupportedError());
|
|
1299
|
+
return () => {
|
|
1300
|
+
/* no teardown */
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
const clearAfterMs = options.clearAfterMs ?? DEFAULT_CLEAR_MS;
|
|
1304
|
+
let cleared = false;
|
|
1305
|
+
let timer = null;
|
|
1306
|
+
navigator.clipboard
|
|
1307
|
+
.writeText(text)
|
|
1308
|
+
.then(() => {
|
|
1309
|
+
subscriber.next('copied');
|
|
1310
|
+
if (clearAfterMs <= 0) {
|
|
1311
|
+
subscriber.complete();
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
timer = setTimeout(async () => {
|
|
1315
|
+
const status = await this.safeClear(text);
|
|
1316
|
+
cleared = true;
|
|
1317
|
+
subscriber.next(status);
|
|
1318
|
+
subscriber.complete();
|
|
1319
|
+
}, clearAfterMs);
|
|
1320
|
+
})
|
|
1321
|
+
.catch(() => {
|
|
1322
|
+
subscriber.next('error');
|
|
1323
|
+
subscriber.complete();
|
|
1324
|
+
});
|
|
1325
|
+
return () => {
|
|
1326
|
+
if (!cleared && timer !== null)
|
|
1327
|
+
clearTimeout(timer);
|
|
1328
|
+
};
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* Cancels any pending auto-clear timer. The clipboard content is not modified.
|
|
1333
|
+
*/
|
|
1334
|
+
cancelPendingClear() {
|
|
1335
|
+
if (this.pendingTimer !== null) {
|
|
1336
|
+
clearTimeout(this.pendingTimer);
|
|
1337
|
+
this.pendingTimer = null;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
async safeClear(expected) {
|
|
1341
|
+
try {
|
|
1342
|
+
const current = await navigator.clipboard.readText();
|
|
1343
|
+
if (current !== expected)
|
|
1344
|
+
return 'read-denied';
|
|
1345
|
+
await navigator.clipboard.writeText('');
|
|
1346
|
+
return 'cleared';
|
|
1347
|
+
}
|
|
1348
|
+
catch {
|
|
1349
|
+
return 'read-denied';
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SensitiveClipboardService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1353
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SensitiveClipboardService });
|
|
1354
|
+
}
|
|
1355
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SensitiveClipboardService, decorators: [{
|
|
1356
|
+
type: Injectable
|
|
1357
|
+
}], ctorParameters: () => [] });
|
|
1358
|
+
|
|
1359
|
+
const HIBP_CONFIG = new InjectionToken('HIBP_CONFIG');
|
|
1360
|
+
const DEFAULT_ENDPOINT = 'https://api.pwnedpasswords.com/range/';
|
|
1361
|
+
/**
|
|
1362
|
+
* Checks whether a password appears in the Have I Been Pwned breach corpus using the
|
|
1363
|
+
* k-anonymity API. Only the first 5 hex characters of the SHA-1 hash leave the browser;
|
|
1364
|
+
* the full password is never transmitted.
|
|
1365
|
+
*
|
|
1366
|
+
* The service is fail-open: network errors return `{ leaked: false, error: 'network' }`
|
|
1367
|
+
* to avoid blocking form submissions when HIBP is unreachable. Use this signal to
|
|
1368
|
+
* *complement* entropy-based checks, never as the sole gate.
|
|
1369
|
+
*
|
|
1370
|
+
* @example
|
|
1371
|
+
* const { leaked, count } = await hibp.isPasswordLeaked(password);
|
|
1372
|
+
* if (leaked) alert(`This password has appeared in ${count} breaches`);
|
|
1373
|
+
*/
|
|
1374
|
+
class HibpService {
|
|
1375
|
+
platformId = inject(PLATFORM_ID);
|
|
1376
|
+
webCrypto = inject(WebCryptoService);
|
|
1377
|
+
endpoint;
|
|
1378
|
+
constructor() {
|
|
1379
|
+
const config = inject(HIBP_CONFIG, { optional: true }) ?? {};
|
|
1380
|
+
this.endpoint = config.endpoint ?? DEFAULT_ENDPOINT;
|
|
1381
|
+
}
|
|
1382
|
+
isSupported() {
|
|
1383
|
+
return (isPlatformBrowser(this.platformId) &&
|
|
1384
|
+
typeof fetch === 'function' &&
|
|
1385
|
+
this.webCrypto.isSupported());
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Returns whether the given password is present in the HIBP breach corpus.
|
|
1389
|
+
* Never throws: network failures return `{ leaked: false, error: 'network' }`,
|
|
1390
|
+
* unsupported environments return `{ leaked: false, error: 'unsupported' }`.
|
|
1391
|
+
*/
|
|
1392
|
+
async isPasswordLeaked(password) {
|
|
1393
|
+
if (!this.isSupported()) {
|
|
1394
|
+
return { leaked: false, count: 0, error: 'unsupported' };
|
|
1395
|
+
}
|
|
1396
|
+
if (!password) {
|
|
1397
|
+
return { leaked: false, count: 0 };
|
|
1398
|
+
}
|
|
1399
|
+
let hashHex;
|
|
1400
|
+
try {
|
|
1401
|
+
hashHex = (await this.webCrypto.hash(password, 'SHA-1')).toUpperCase();
|
|
1402
|
+
}
|
|
1403
|
+
catch {
|
|
1404
|
+
return { leaked: false, count: 0, error: 'unsupported' };
|
|
1405
|
+
}
|
|
1406
|
+
const prefix = hashHex.slice(0, 5);
|
|
1407
|
+
const suffix = hashHex.slice(5);
|
|
1408
|
+
let body;
|
|
1409
|
+
try {
|
|
1410
|
+
const response = await fetch(`${this.endpoint}${prefix}`, {
|
|
1411
|
+
method: 'GET',
|
|
1412
|
+
headers: { 'Add-Padding': 'true' },
|
|
1413
|
+
});
|
|
1414
|
+
if (response.status === 404) {
|
|
1415
|
+
return { leaked: false, count: 0 };
|
|
1416
|
+
}
|
|
1417
|
+
if (!response.ok) {
|
|
1418
|
+
return { leaked: false, count: 0, error: 'network' };
|
|
1419
|
+
}
|
|
1420
|
+
body = await response.text();
|
|
1421
|
+
}
|
|
1422
|
+
catch {
|
|
1423
|
+
return { leaked: false, count: 0, error: 'network' };
|
|
1424
|
+
}
|
|
1425
|
+
const match = findSuffixMatch(body, suffix);
|
|
1426
|
+
return match > 0 ? { leaked: true, count: match } : { leaked: false, count: 0 };
|
|
1427
|
+
}
|
|
1428
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: HibpService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1429
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: HibpService });
|
|
1430
|
+
}
|
|
1431
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: HibpService, decorators: [{
|
|
1432
|
+
type: Injectable
|
|
1433
|
+
}], ctorParameters: () => [] });
|
|
1434
|
+
function findSuffixMatch(body, suffix) {
|
|
1435
|
+
const lines = body.split(/\r?\n/);
|
|
1436
|
+
for (const line of lines) {
|
|
1437
|
+
const separatorIndex = line.indexOf(':');
|
|
1438
|
+
if (separatorIndex === -1)
|
|
1439
|
+
continue;
|
|
1440
|
+
const lineSuffix = line.slice(0, separatorIndex).trim().toUpperCase();
|
|
1441
|
+
if (lineSuffix !== suffix)
|
|
1442
|
+
continue;
|
|
1443
|
+
const count = parseInt(line.slice(separatorIndex + 1).trim(), 10);
|
|
1444
|
+
// Padding rows (not in breach) have count 0; treat them as no match.
|
|
1445
|
+
return Number.isFinite(count) && count > 0 ? count : 0;
|
|
1446
|
+
}
|
|
1447
|
+
return 0;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
const RATE_LIMITER_CONFIG = new InjectionToken('RATE_LIMITER_CONFIG');
|
|
1451
|
+
/**
|
|
1452
|
+
* Thrown by {@link RateLimiterService.consume} when the configured capacity is exhausted.
|
|
1453
|
+
*/
|
|
1454
|
+
class RateLimitExceededError extends Error {
|
|
1455
|
+
key;
|
|
1456
|
+
retryAfterMs;
|
|
1457
|
+
constructor(key, retryAfterMs) {
|
|
1458
|
+
super(`Rate limit exceeded for "${key}". Retry after ${retryAfterMs}ms.`);
|
|
1459
|
+
this.key = key;
|
|
1460
|
+
this.retryAfterMs = retryAfterMs;
|
|
1461
|
+
this.name = 'RateLimitExceededError';
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Client-side rate limiter with per-key policies. Use to protect the user's own backend
|
|
1466
|
+
* from accidental bursts (search-as-you-type, button mashing, automated retries) or to
|
|
1467
|
+
* pace client-side operations.
|
|
1468
|
+
*
|
|
1469
|
+
* Two policies are supported:
|
|
1470
|
+
* - **Token bucket**: smooth rate limiting with burst capacity.
|
|
1471
|
+
* - **Sliding window**: strict max operations per time window.
|
|
1472
|
+
*
|
|
1473
|
+
* Unknown keys are fail-open: `canExecute` returns `true`, `consume` is a no-op. Register
|
|
1474
|
+
* policies explicitly with {@link configure} or via the `RATE_LIMITER_CONFIG` token.
|
|
1475
|
+
*
|
|
1476
|
+
* All state is kept in memory for the lifetime of the service instance — cross-tab
|
|
1477
|
+
* synchronization is out of scope for v1.
|
|
1478
|
+
*
|
|
1479
|
+
* @example
|
|
1480
|
+
* rateLimiter.configure('search', { type: 'token-bucket', capacity: 5, refillPerSecond: 1 });
|
|
1481
|
+
*
|
|
1482
|
+
* async search(query: string) {
|
|
1483
|
+
* await rateLimiter.consume('search'); // throws RateLimitExceededError when exhausted
|
|
1484
|
+
* return this.api.search(query);
|
|
1485
|
+
* }
|
|
1486
|
+
*/
|
|
1487
|
+
class RateLimiterService {
|
|
1488
|
+
destroyRef = inject(DestroyRef);
|
|
1489
|
+
buckets = new Map();
|
|
1490
|
+
constructor() {
|
|
1491
|
+
const config = inject(RATE_LIMITER_CONFIG, { optional: true });
|
|
1492
|
+
if (config?.defaults) {
|
|
1493
|
+
for (const [key, policy] of Object.entries(config.defaults)) {
|
|
1494
|
+
this.configure(key, policy);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
this.destroyRef.onDestroy(() => this.buckets.clear());
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Registers or updates the policy for `key`. Re-configuring an existing key resets its state.
|
|
1501
|
+
*/
|
|
1502
|
+
configure(key, policy) {
|
|
1503
|
+
validatePolicy(policy);
|
|
1504
|
+
const now = Date.now();
|
|
1505
|
+
const initialRemaining = policy.type === 'token-bucket' ? policy.capacity : policy.max;
|
|
1506
|
+
this.buckets.set(key, {
|
|
1507
|
+
policy,
|
|
1508
|
+
tokens: policy.type === 'token-bucket' ? policy.capacity : 0,
|
|
1509
|
+
lastRefillAt: now,
|
|
1510
|
+
timestamps: [],
|
|
1511
|
+
remaining: signal(initialRemaining),
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* Attempts to consume `tokens` units from the bucket. Resolves on success; rejects with
|
|
1516
|
+
* {@link RateLimitExceededError} when the bucket is exhausted.
|
|
1517
|
+
*
|
|
1518
|
+
* For undeclared keys, this method resolves immediately without consuming anything
|
|
1519
|
+
* (fail-open behaviour — intentional to avoid silent failures when a policy is missing).
|
|
1520
|
+
*/
|
|
1521
|
+
async consume(key, tokens = 1) {
|
|
1522
|
+
const bucket = this.buckets.get(key);
|
|
1523
|
+
if (!bucket)
|
|
1524
|
+
return;
|
|
1525
|
+
const now = Date.now();
|
|
1526
|
+
if (bucket.policy.type === 'token-bucket') {
|
|
1527
|
+
this.refillTokenBucket(bucket, now);
|
|
1528
|
+
if (tokens > bucket.policy.capacity) {
|
|
1529
|
+
throw new RateLimitExceededError(key, Infinity);
|
|
1530
|
+
}
|
|
1531
|
+
if (bucket.tokens < tokens) {
|
|
1532
|
+
const deficit = tokens - bucket.tokens;
|
|
1533
|
+
const retryAfterMs = Math.ceil((deficit / bucket.policy.refillPerSecond) * 1000);
|
|
1534
|
+
throw new RateLimitExceededError(key, retryAfterMs);
|
|
1535
|
+
}
|
|
1536
|
+
bucket.tokens -= tokens;
|
|
1537
|
+
bucket.remaining.set(Math.floor(bucket.tokens));
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
// sliding-window
|
|
1541
|
+
const windowStart = now - bucket.policy.windowMs;
|
|
1542
|
+
bucket.timestamps = bucket.timestamps.filter((t) => t > windowStart);
|
|
1543
|
+
if (bucket.timestamps.length + tokens > bucket.policy.max) {
|
|
1544
|
+
const oldest = bucket.timestamps[0] ?? now;
|
|
1545
|
+
const retryAfterMs = Math.max(0, oldest + bucket.policy.windowMs - now);
|
|
1546
|
+
throw new RateLimitExceededError(key, retryAfterMs);
|
|
1547
|
+
}
|
|
1548
|
+
for (let i = 0; i < tokens; i++)
|
|
1549
|
+
bucket.timestamps.push(now);
|
|
1550
|
+
bucket.remaining.set(bucket.policy.max - bucket.timestamps.length);
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Reactive signal indicating whether a single unit can be consumed from `key` right now.
|
|
1554
|
+
* For undeclared keys, returns `signal(true)`.
|
|
1555
|
+
*/
|
|
1556
|
+
canExecute(key) {
|
|
1557
|
+
const bucket = this.buckets.get(key);
|
|
1558
|
+
if (!bucket)
|
|
1559
|
+
return signal(true).asReadonly();
|
|
1560
|
+
return computed(() => bucket.remaining() >= 1);
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* Reactive signal holding the remaining capacity for `key`.
|
|
1564
|
+
* For undeclared keys, returns `signal(Infinity)`.
|
|
1565
|
+
*/
|
|
1566
|
+
remaining(key) {
|
|
1567
|
+
const bucket = this.buckets.get(key);
|
|
1568
|
+
if (!bucket)
|
|
1569
|
+
return signal(Number.POSITIVE_INFINITY).asReadonly();
|
|
1570
|
+
return bucket.remaining.asReadonly();
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Resets the counter for `key` to its maximum. No-op for undeclared keys.
|
|
1574
|
+
*/
|
|
1575
|
+
reset(key) {
|
|
1576
|
+
const bucket = this.buckets.get(key);
|
|
1577
|
+
if (!bucket)
|
|
1578
|
+
return;
|
|
1579
|
+
bucket.timestamps = [];
|
|
1580
|
+
if (bucket.policy.type === 'token-bucket') {
|
|
1581
|
+
bucket.tokens = bucket.policy.capacity;
|
|
1582
|
+
bucket.lastRefillAt = Date.now();
|
|
1583
|
+
bucket.remaining.set(bucket.policy.capacity);
|
|
1584
|
+
}
|
|
1585
|
+
else {
|
|
1586
|
+
bucket.remaining.set(bucket.policy.max);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
refillTokenBucket(bucket, now) {
|
|
1590
|
+
if (bucket.policy.type !== 'token-bucket')
|
|
1591
|
+
return;
|
|
1592
|
+
const elapsedSec = (now - bucket.lastRefillAt) / 1000;
|
|
1593
|
+
if (elapsedSec <= 0)
|
|
1594
|
+
return;
|
|
1595
|
+
const refilled = elapsedSec * bucket.policy.refillPerSecond;
|
|
1596
|
+
bucket.tokens = Math.min(bucket.policy.capacity, bucket.tokens + refilled);
|
|
1597
|
+
bucket.lastRefillAt = now;
|
|
1598
|
+
bucket.remaining.set(Math.floor(bucket.tokens));
|
|
1599
|
+
}
|
|
1600
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: RateLimiterService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1601
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: RateLimiterService });
|
|
1602
|
+
}
|
|
1603
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: RateLimiterService, decorators: [{
|
|
1604
|
+
type: Injectable
|
|
1605
|
+
}], ctorParameters: () => [] });
|
|
1606
|
+
function validatePolicy(policy) {
|
|
1607
|
+
if (policy.type === 'token-bucket') {
|
|
1608
|
+
if (policy.capacity <= 0 || policy.refillPerSecond <= 0) {
|
|
1609
|
+
throw new RangeError('Token-bucket policy requires capacity > 0 and refillPerSecond > 0');
|
|
1610
|
+
}
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
if (policy.max <= 0 || policy.windowMs <= 0) {
|
|
1614
|
+
throw new RangeError('Sliding-window policy requires max > 0 and windowMs > 0');
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
const CSRF_CONFIG = new InjectionToken('CSRF_CONFIG');
|
|
1619
|
+
const DEFAULT_STORAGE_KEY = '__csrf_token__';
|
|
1620
|
+
const DEFAULT_TOKEN_BYTES = 32;
|
|
1621
|
+
/**
|
|
1622
|
+
* CSRF token helper using the double-submit cookie / header pattern.
|
|
1623
|
+
*
|
|
1624
|
+
* The service stores a cryptographically secure token in the configured storage and exposes
|
|
1625
|
+
* it to HTTP interceptors ({@link withCsrfHeader}). Token lifecycle (creation, rotation,
|
|
1626
|
+
* clearing) is the application's responsibility — typically the token is issued by the
|
|
1627
|
+
* backend at login and stored via {@link storeToken}.
|
|
1628
|
+
*
|
|
1629
|
+
* Use a different header name than Angular's built-in XSRF ({@link `X-XSRF-TOKEN`}) to avoid
|
|
1630
|
+
* interaction with `HttpClientXsrfModule`. Default: {@link `X-CSRF-Token`}.
|
|
1631
|
+
*/
|
|
1632
|
+
class CsrfService {
|
|
1633
|
+
platformId = inject(PLATFORM_ID);
|
|
1634
|
+
webCrypto = inject(WebCryptoService);
|
|
1635
|
+
storageKey;
|
|
1636
|
+
storageTarget;
|
|
1637
|
+
constructor() {
|
|
1638
|
+
const config = inject(CSRF_CONFIG, { optional: true }) ?? {};
|
|
1639
|
+
this.storageKey = config.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
1640
|
+
this.storageTarget = config.storage ?? 'session';
|
|
1641
|
+
}
|
|
1642
|
+
isSupported() {
|
|
1643
|
+
return isPlatformBrowser(this.platformId) && typeof window !== 'undefined';
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Generates a new CSRF token as a 32-byte hex string. The token is NOT persisted
|
|
1647
|
+
* automatically — call {@link storeToken} to save it.
|
|
1648
|
+
*/
|
|
1649
|
+
generateToken() {
|
|
1650
|
+
const bytes = this.webCrypto.generateRandomBytes(DEFAULT_TOKEN_BYTES);
|
|
1651
|
+
return Array.from(bytes)
|
|
1652
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
1653
|
+
.join('');
|
|
1654
|
+
}
|
|
1655
|
+
/**
|
|
1656
|
+
* Persists the token to the configured storage.
|
|
1657
|
+
*/
|
|
1658
|
+
storeToken(token) {
|
|
1659
|
+
if (!this.isSupported())
|
|
1660
|
+
return;
|
|
1661
|
+
this.nativeStorage.setItem(this.storageKey, token);
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* Returns the stored token, or `null` when unset or outside a browser environment.
|
|
1665
|
+
*/
|
|
1666
|
+
getToken() {
|
|
1667
|
+
if (!this.isSupported())
|
|
1668
|
+
return null;
|
|
1669
|
+
return this.nativeStorage.getItem(this.storageKey);
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Removes the stored token.
|
|
1673
|
+
*/
|
|
1674
|
+
clearToken() {
|
|
1675
|
+
if (!this.isSupported())
|
|
1676
|
+
return;
|
|
1677
|
+
this.nativeStorage.removeItem(this.storageKey);
|
|
1678
|
+
}
|
|
1679
|
+
get nativeStorage() {
|
|
1680
|
+
return this.storageTarget === 'session' ? sessionStorage : localStorage;
|
|
1681
|
+
}
|
|
1682
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: CsrfService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1683
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: CsrfService });
|
|
1684
|
+
}
|
|
1685
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: CsrfService, decorators: [{
|
|
1686
|
+
type: Injectable
|
|
1687
|
+
}], ctorParameters: () => [] });
|
|
1688
|
+
const DEFAULT_HEADER_NAME = 'X-CSRF-Token';
|
|
1689
|
+
const DEFAULT_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
|
1690
|
+
/**
|
|
1691
|
+
* Functional HTTP interceptor that injects the current CSRF token as a request header on
|
|
1692
|
+
* state-changing methods. When the token is absent, the header is omitted silently.
|
|
1693
|
+
*
|
|
1694
|
+
* Register via `provideHttpClient(withInterceptors([withCsrfHeader()]))`.
|
|
1695
|
+
*
|
|
1696
|
+
* @example
|
|
1697
|
+
* bootstrapApplication(App, {
|
|
1698
|
+
* providers: [
|
|
1699
|
+
* provideSecurity({ enableCsrf: true }),
|
|
1700
|
+
* provideHttpClient(withInterceptors([withCsrfHeader()])),
|
|
1701
|
+
* ],
|
|
1702
|
+
* });
|
|
1703
|
+
*/
|
|
1704
|
+
function withCsrfHeader(options = {}) {
|
|
1705
|
+
const headerName = options.headerName ?? DEFAULT_HEADER_NAME;
|
|
1706
|
+
const methods = new Set(options.methods ?? DEFAULT_METHODS);
|
|
1707
|
+
return (request, next) => {
|
|
1708
|
+
if (!methods.has(request.method.toUpperCase())) {
|
|
1709
|
+
return next(request);
|
|
1710
|
+
}
|
|
1711
|
+
const csrf = inject(CsrfService);
|
|
1712
|
+
const token = csrf.getToken();
|
|
1713
|
+
if (!token)
|
|
1714
|
+
return next(request);
|
|
1715
|
+
return next(request.clone({ setHeaders: { [headerName]: token } }));
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1016
1718
|
|
|
1017
1719
|
const defaultSecurityConfig = {
|
|
1018
1720
|
enableRegexSecurity: true,
|
|
@@ -1020,26 +1722,38 @@ const defaultSecurityConfig = {
|
|
|
1020
1722
|
enableSecureStorage: false,
|
|
1021
1723
|
enableInputSanitizer: false,
|
|
1022
1724
|
enablePasswordStrength: false,
|
|
1725
|
+
enableJwt: false,
|
|
1726
|
+
enableSensitiveClipboard: false,
|
|
1727
|
+
enableHibp: false,
|
|
1728
|
+
enableRateLimiter: false,
|
|
1729
|
+
enableCsrf: false,
|
|
1023
1730
|
defaultTimeout: 5000,
|
|
1024
1731
|
safeMode: false,
|
|
1025
1732
|
};
|
|
1026
1733
|
function provideSecurity(config = {}) {
|
|
1027
1734
|
const mergedConfig = { ...defaultSecurityConfig, ...config };
|
|
1028
1735
|
const providers = [];
|
|
1029
|
-
if (mergedConfig.enableRegexSecurity)
|
|
1736
|
+
if (mergedConfig.enableRegexSecurity)
|
|
1030
1737
|
providers.push(RegexSecurityService);
|
|
1031
|
-
|
|
1032
|
-
if (mergedConfig.enableWebCrypto) {
|
|
1738
|
+
if (mergedConfig.enableWebCrypto)
|
|
1033
1739
|
providers.push(WebCryptoService);
|
|
1034
|
-
|
|
1035
|
-
if (mergedConfig.enableSecureStorage) {
|
|
1740
|
+
if (mergedConfig.enableSecureStorage)
|
|
1036
1741
|
providers.push(SecureStorageService);
|
|
1037
|
-
|
|
1038
|
-
if (mergedConfig.enableInputSanitizer) {
|
|
1742
|
+
if (mergedConfig.enableInputSanitizer)
|
|
1039
1743
|
providers.push(InputSanitizerService);
|
|
1040
|
-
|
|
1041
|
-
if (mergedConfig.enablePasswordStrength) {
|
|
1744
|
+
if (mergedConfig.enablePasswordStrength)
|
|
1042
1745
|
providers.push(PasswordStrengthService);
|
|
1746
|
+
if (mergedConfig.enableJwt)
|
|
1747
|
+
providers.push(JwtService);
|
|
1748
|
+
if (mergedConfig.enableSensitiveClipboard)
|
|
1749
|
+
providers.push(SensitiveClipboardService);
|
|
1750
|
+
if (mergedConfig.enableHibp) {
|
|
1751
|
+
providers.push(WebCryptoService, HibpService);
|
|
1752
|
+
}
|
|
1753
|
+
if (mergedConfig.enableRateLimiter)
|
|
1754
|
+
providers.push(RateLimiterService);
|
|
1755
|
+
if (mergedConfig.enableCsrf) {
|
|
1756
|
+
providers.push(WebCryptoService, CsrfService);
|
|
1043
1757
|
}
|
|
1044
1758
|
return makeEnvironmentProviders(providers);
|
|
1045
1759
|
}
|
|
@@ -1064,9 +1778,35 @@ function provideInputSanitizer(config) {
|
|
|
1064
1778
|
function providePasswordStrength() {
|
|
1065
1779
|
return makeEnvironmentProviders([PasswordStrengthService]);
|
|
1066
1780
|
}
|
|
1781
|
+
function provideJwt() {
|
|
1782
|
+
return makeEnvironmentProviders([JwtService]);
|
|
1783
|
+
}
|
|
1784
|
+
function provideSensitiveClipboard() {
|
|
1785
|
+
return makeEnvironmentProviders([SensitiveClipboardService]);
|
|
1786
|
+
}
|
|
1787
|
+
function provideHibp(config) {
|
|
1788
|
+
return makeEnvironmentProviders([
|
|
1789
|
+
WebCryptoService,
|
|
1790
|
+
HibpService,
|
|
1791
|
+
...(config ? [{ provide: HIBP_CONFIG, useValue: config }] : []),
|
|
1792
|
+
]);
|
|
1793
|
+
}
|
|
1794
|
+
function provideRateLimiter(config) {
|
|
1795
|
+
return makeEnvironmentProviders([
|
|
1796
|
+
RateLimiterService,
|
|
1797
|
+
...(config ? [{ provide: RATE_LIMITER_CONFIG, useValue: config }] : []),
|
|
1798
|
+
]);
|
|
1799
|
+
}
|
|
1800
|
+
function provideCsrf(config) {
|
|
1801
|
+
return makeEnvironmentProviders([
|
|
1802
|
+
WebCryptoService,
|
|
1803
|
+
CsrfService,
|
|
1804
|
+
...(config ? [{ provide: CSRF_CONFIG, useValue: config }] : []),
|
|
1805
|
+
]);
|
|
1806
|
+
}
|
|
1067
1807
|
|
|
1068
1808
|
/**
|
|
1069
1809
|
* Generated bundle index. Do not edit.
|
|
1070
1810
|
*/
|
|
1071
1811
|
|
|
1072
|
-
export { InputSanitizerService, PasswordStrengthService, RegexSecurityBuilder, RegexSecurityService, SANITIZER_CONFIG, SECURE_STORAGE_CONFIG, SecureStorageService, WebCryptoService, defaultSecurityConfig, provideInputSanitizer, providePasswordStrength, provideRegexSecurity, provideSecureStorage, provideSecurity, provideWebCrypto };
|
|
1812
|
+
export { CSRF_CONFIG, ClipboardUnsupportedError, CsrfService, DEFAULT_ALLOWED_ATTRIBUTES, DEFAULT_ALLOWED_TAGS, HIBP_CONFIG, HibpService, InputSanitizerService, InvalidJwtError, JwtService, PasswordStrengthService, RATE_LIMITER_CONFIG, RateLimitExceededError, RateLimiterService, RegexSecurityBuilder, RegexSecurityService, SANITIZER_CONFIG, SECURE_STORAGE_CONFIG, SecureStorageService, SensitiveClipboardService, WebCryptoService, assessPasswordStrength, containsScriptInjection, containsSqlInjectionHints, defaultSecurityConfig, isHtmlSafe, isUrlSafe, provideCsrf, provideHibp, provideInputSanitizer, provideJwt, providePasswordStrength, provideRateLimiter, provideRegexSecurity, provideSecureStorage, provideSecurity, provideSensitiveClipboard, provideWebCrypto, sanitizeHtmlString, sanitizeUrlString, withCsrfHeader };
|