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