mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c0bc4345a | |||
| 5aa6d97deb | |||
| 73d8bbd27c | |||
| 992d627b69 | |||
| c19a36c2fb | |||
| dca0a77488 | |||
| caf616670f | |||
| fec5977df4 | |||
| e33d69a71c | |||
| 6afe6fa781 | |||
| 07cb840eac | |||
| 7b884f585a | |||
| 948a8d6831 | |||
| e2660c3dee | |||
| f34a93c946 | |||
| 9949dccce1 | |||
| 5d00aa2bd4 | |||
| e86c31bf26 | |||
| 1804f73bec | |||
| 91df739b80 | |||
| 8d22cc953b | |||
| 5a693bafcd | |||
| 6bcce117fb | |||
| 31c25e2e85 | |||
| 88832c84f8 | |||
| 13fb0afe93 | |||
| 86a56dc279 | |||
| bcb1b7ea25 | |||
| b9d29381bb | |||
| a810a319d1 | |||
| 35510746b7 | |||
| a5f82b8315 | |||
| bc55749698 | |||
| d846326b07 | |||
| e245d40c4d | |||
| dc9c99636d | |||
| 1523061137 | |||
| 03188ddc9f | |||
| 736c4ab7e6 | |||
| 3fd6897a67 | |||
| db157e4aff | |||
| 70af5e8abd | |||
| 66821c4ed5 | |||
| b74fb76d1a | |||
| 66d09558b6 | |||
| ae1c8a1f7a | |||
| 9f6fda6815 | |||
| 1e5d76bfd9 | |||
| 22dd53ca97 | |||
| 1cd4f54aef | |||
| 9d8ea2cc9a | |||
| 69c0d48a0d | |||
| 7e38336dc7 | |||
| 34c4e401ba | |||
| f6d8e642b0 | |||
| 8206972189 | |||
| d3170031b6 | |||
| 9b7d0e5034 | |||
| cea6588bb3 | |||
| 6b8202eab6 | |||
| eb1cbbf2eb | |||
| 8538bdce1c | |||
| 582a417bd2 | |||
| 5c6d6df34b | |||
| 29ceeffd40 | |||
| 1a84668f0c | |||
| 551a2a1f46 | |||
| 84c6579a4e | |||
| 2b17b2db88 | |||
| fd3573d99d | |||
| ef38852213 | |||
| 2232a62bb2 | |||
| ad5053b518 | |||
| ca32fc1000 | |||
| b8bd49341d | |||
| 251c8f2740 | |||
| dada86e43d | |||
| 782e01594e | |||
| ff12fe55ac | |||
| 0b07f47bd6 | |||
| cc1378d54b | |||
| daaf32937f | |||
| 37e8aadf87 | |||
| 72696e244e | |||
| 300f61feb3 | |||
| 7f4ac78994 | |||
| e6fe77cc97 | |||
| 525e1605a6 | |||
| 4a41831326 | |||
| 6d8b156b45 | |||
| 82cf38ad95 | |||
| 711f873e31 |
@@ -36,6 +36,18 @@ jobs:
|
||||
# that the action was triggered by a team member.
|
||||
ref: ${{steps.comment-branch.outputs.head_sha}}
|
||||
|
||||
# We cannot use `angular/dev-infra/github-actions/npm/checkout-and-setup-node` here
|
||||
# because it does not support checking out from a fork (as it lacks a `repository` input).
|
||||
# Thus, we checkout and setup Node/pnpm manually.
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- uses: angular/dev-infra/github-actions/bazel/configure-remote@ba726e7bca0b08b125ccc6f93c233749e1213c17
|
||||
|
||||
+176
@@ -1,3 +1,179 @@
|
||||
<a name="21.2.22"></a>
|
||||
# 21.2.22 (2026-08-26)
|
||||
### platform-server
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [5aa6d97deb](https://github.com/angular/angular/commit/5aa6d97deb9ef1de14e23748b7fa74f97d183132) | fix | avoid stripping unicode whitespace during url resolution |
|
||||
| [73d8bbd27c](https://github.com/angular/angular/commit/73d8bbd27cb46495426d4132975a1355b47ad915) | fix | update domino to latest version |
|
||||
|
||||
<!-- CHANGELOG SPLIT MARKER -->
|
||||
|
||||
<a name="21.2.21"></a>
|
||||
# 21.2.21 (2026-08-19)
|
||||
### platform-browser
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [c19a36c2fb](https://github.com/angular/angular/commit/c19a36c2fbc51f41ce974ea1fd87f9f56b3e2444) | fix | disallow event handler attributes in Meta |
|
||||
|
||||
<!-- CHANGELOG SPLIT MARKER -->
|
||||
|
||||
<a name="21.2.20"></a>
|
||||
# 21.2.20 (2026-08-12)
|
||||
### core
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [6afe6fa781](https://github.com/angular/angular/commit/6afe6fa781c2f0931f0aedd729b9884a8fe212ee) | fix | sanitize host bindings on concrete hosts |
|
||||
### http
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [fec5977df4](https://github.com/angular/angular/commit/fec5977df4dda3a10d5ce2923e3e06d86ba11ee7) | fix | match header values exactly when deleting |
|
||||
| [e33d69a71c](https://github.com/angular/angular/commit/e33d69a71c5beb8fe5785b53fd6b37658334e8e0) | fix | preserve immutability of materialized clones |
|
||||
| [caf616670f](https://github.com/angular/angular/commit/caf616670fd20d528aa69e0131cc17d60f0cc27d) | fix | run root interceptors in the terminal request chain |
|
||||
|
||||
<!-- CHANGELOG SPLIT MARKER -->
|
||||
|
||||
<a name="21.2.19"></a>
|
||||
# 21.2.19 (2026-07-29)
|
||||
### compiler
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [e2660c3dee](https://github.com/angular/angular/commit/e2660c3deeccb86471f9c11b7e65ebcc89d84e9e) | fix | disallow i18n event attributes |
|
||||
| [7b884f585a](https://github.com/angular/angular/commit/7b884f585abf50480c271d234c892c141eb61e2c) | fix | restrict possible event handler check to property names longer than 2 characters |
|
||||
### http
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [948a8d6831](https://github.com/angular/angular/commit/948a8d6831e8920b54663ec79421da95210e0e35) | fix | distinguish repeated transfer cache params |
|
||||
| [9949dccce1](https://github.com/angular/angular/commit/9949dccce164638597496a6b3881043117dfd1df) | fix | enable xsrf for root-provided HttpClient |
|
||||
### platform-server
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [f34a93c946](https://github.com/angular/angular/commit/f34a93c946c017eff526cfb0c8d17f51e97f01e4) | fix | update domino to latest version |
|
||||
|
||||
<!-- CHANGELOG SPLIT MARKER -->
|
||||
|
||||
<a name="21.2.18"></a>
|
||||
# 21.2.18 (2026-07-08)
|
||||
### compiler-cli
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [8d22cc953b](https://github.com/angular/angular/commit/8d22cc953bb9f970c6f86fb0f6e4d0665b874bfa) | fix | update babel dependencies to latest v7 |
|
||||
### core
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [6bcce117fb](https://github.com/angular/angular/commit/6bcce117fbc616e4e721f351e9e038202e916522) | fix | avoid caching missing locale data |
|
||||
| [5a693bafcd](https://github.com/angular/angular/commit/5a693bafcd49ef11ce687bd91a209e1a46652f42) | fix | reject dynamic script host elements |
|
||||
### http
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [91df739b80](https://github.com/angular/angular/commit/91df739b8022e2177bcc7214ed9a4e94098a4831) | fix | prevent caching of responses with Set-Cookie headers |
|
||||
### service-worker
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [1804f73bec](https://github.com/angular/angular/commit/1804f73becf425b5b1ccee9751d6e3fbd10e1f0b) | fix | preserve referrer in asset requests |
|
||||
| [e86c31bf26](https://github.com/angular/angular/commit/e86c31bf26359aacce814167506dbdae585a7eb8) | fix | preserve referrer policy in asset requests |
|
||||
|
||||
<!-- CHANGELOG SPLIT MARKER -->
|
||||
|
||||
<a name="21.2.17"></a>
|
||||
# 21.2.17 (2026-06-10)
|
||||
## Deprecations
|
||||
### platform-server
|
||||
- XHR support in `@angular/platform-server` is deprecated. Use standard `fetch` APIs instead.
|
||||
### common
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [86a56dc279](https://github.com/angular/angular/commit/86a56dc279e71159d09a073a3cb138f49131995b) | fix | Limits date format string length |
|
||||
| [d846326b07](https://github.com/angular/angular/commit/d846326b071e0a4ab090e068d934b182926c6b15) | fix | skip transfer cache for uncacheable HTTP traffic |
|
||||
| [bc55749698](https://github.com/angular/angular/commit/bc55749698ce3917160cd8e9f7108f3c5d1c0b32) | fix | use cryptographically secure SHA-256 for transfer cache key generation |
|
||||
### compiler
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [dc9c99636d](https://github.com/angular/angular/commit/dc9c99636d3471ed5a3c5cda54b95f604cd2b9a4) | fix | sanitize two-way properties |
|
||||
### core
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [1523061137](https://github.com/angular/angular/commit/152306113760e13196653699b42046d9f4129a37) | fix | harden TransferState restoration against DOM clobbering |
|
||||
| [88832c84f8](https://github.com/angular/angular/commit/88832c84f8a3cd88d80adcde539a6f91a1f30b74) | fix | validate lowercase SVG animation attribute names ([#69269](https://github.com/angular/angular/pull/69269)) |
|
||||
### http
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [bcb1b7ea25](https://github.com/angular/angular/commit/bcb1b7ea2575b140f7bf202ad4f779e402cd6094) | fix | preserve empty referrer option in HttpRequest |
|
||||
| [a810a319d1](https://github.com/angular/angular/commit/a810a319d10a8254307eb8f0598e7a888ce09ec0) | fix | Rejects non-HTTP(S) URLs in JSONP requests |
|
||||
| [e245d40c4d](https://github.com/angular/angular/commit/e245d40c4d05665ab4814c594b8e0849b6e88a2d) | fix | skip transfer cache for fetch credentialed requests |
|
||||
### platform-server
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [35510746b7](https://github.com/angular/angular/commit/35510746b7d6b5c3de41de04c0586fc286c9e748) | fix | harden platform location origin validation during SSR |
|
||||
| [13fb0afe93](https://github.com/angular/angular/commit/13fb0afe93b45e3c2383969f70d3ee1f0146ecfb) | refactor | deprecate ServerXhr ([#69255](https://github.com/angular/angular/pull/69255)) |
|
||||
### service-worker
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [b9d29381bb](https://github.com/angular/angular/commit/b9d29381bb4442164b19d9b7e0baa147a7b25629) | fix | Strips sensitive headers on cross-origin redirects |
|
||||
|
||||
<!-- CHANGELOG SPLIT MARKER -->
|
||||
|
||||
<a name="21.2.16"></a>
|
||||
# 21.2.16 (2026-06-03)
|
||||
### common
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [f6d8e642b0](https://github.com/angular/angular/commit/f6d8e642b0b215d2f9dbf1060abd24348c6cbf66) | fix | only strip a literal /index.html suffix from URLs |
|
||||
### compiler
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [ae1c8a1f7a](https://github.com/angular/angular/commit/ae1c8a1f7a7f1d4832da3b22e3763864fa5ff098) | fix | move projection attributes into constants |
|
||||
### core
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [3fd6897a67](https://github.com/angular/angular/commit/3fd6897a67fd6acdc01fcde0452a98c3e0f81e21) | fix | harden inherit definition feature against polluted prototypes |
|
||||
| [7e38336dc7](https://github.com/angular/angular/commit/7e38336dc73e14d98cc6465f54e1b7d6271facb2) | fix | use Object.create(null) for LOCALE_DATA as a hardening measure |
|
||||
### platform-server
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [66821c4ed5](https://github.com/angular/angular/commit/66821c4ed5f580912a1609fc1e06a86f8793c2cf) | fix | throw on suspicious URLs and restrict protocol-relative URLs |
|
||||
| [d3170031b6](https://github.com/angular/angular/commit/d3170031b6f35508f960cba18586843925bb61ec) | fix | update domino to latest version |
|
||||
|
||||
<!-- CHANGELOG SPLIT MARKER -->
|
||||
|
||||
<a name="21.2.15"></a>
|
||||
# 21.2.15 (2026-05-28)
|
||||
### common
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [7f4ac78994](https://github.com/angular/angular/commit/7f4ac78994bff1576ab33f3ce48f95c17f40b4d8) | fix | add upper bounds for digitsInfo |
|
||||
| [300f61feb3](https://github.com/angular/angular/commit/300f61feb3a534bfddf16fcbd240f97b32249699) | fix | sanitize placeholder |
|
||||
### compiler
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [0b07f47bd6](https://github.com/angular/angular/commit/0b07f47bd6598ae6bd5b75a375e2c817a3c0f243) | fix | normalize tag names with custom namespaces in DomElementSchemaRegistry ([#68925](https://github.com/angular/angular/pull/68925)) |
|
||||
| [eb1cbbf2eb](https://github.com/angular/angular/commit/eb1cbbf2eb5833219a367a61c04eb07aaa36cc29) | fix | prevent namespaced SVG <style> elements from being stripped |
|
||||
| [cc1378d54b](https://github.com/angular/angular/commit/cc1378d54bd93f3882d732261be8e66720eb71b2) | fix | sanitize dynamic href and xlink:href bindings on SVG a elements ([#68925](https://github.com/angular/angular/pull/68925)) |
|
||||
| [782e01594e](https://github.com/angular/angular/commit/782e01594e2ad9134c7385dcf3b518101b23ccab) | fix | strip namespaced SVG script elements during template compilation ([#68925](https://github.com/angular/angular/pull/68925)) |
|
||||
### core
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [ff12fe55ac](https://github.com/angular/angular/commit/ff12fe55ace5e861ba261afb4c0480ff3c40a192) | fix | normalize tag names in runtime i18n attribute security context lookup ([#68925](https://github.com/angular/angular/pull/68925)) |
|
||||
| [e6fe77cc97](https://github.com/angular/angular/commit/e6fe77cc97fd10351687416f938bf754aff4eb9f) | fix | sanitize meta selectors |
|
||||
| [daaf32937f](https://github.com/angular/angular/commit/daaf32937fd5c46e411b26f7c082613716fe9550) | fix | support prefix-insensitive DOM schema lookups and compile-time i18n attribute validation ([#68925](https://github.com/angular/angular/pull/68925)) |
|
||||
| [dada86e43d](https://github.com/angular/angular/commit/dada86e43d847204f714d1a933084617ab941c0a) | fix | synchronize core sanitization schema with compiler ([#68925](https://github.com/angular/angular/pull/68925)) |
|
||||
### http
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [582a417bd2](https://github.com/angular/angular/commit/582a417bd27fdaf989e5065dbcdf1ad752faf70c) | fix | exclude withCredentials requests from transfer cache |
|
||||
| [5c6d6df34b](https://github.com/angular/angular/commit/5c6d6df34bbeff3ce98f3b35875444f925cc8f51) | fix | skip TransferCache for cookie-bearing requests by default |
|
||||
### platform-server
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [37e8aadf87](https://github.com/angular/angular/commit/37e8aadf87b4facfcaf002a1557f8c393a362d97) | fix | prevent SSRF bypasses via backslash URLs in HttpClient |
|
||||
| [72696e244e](https://github.com/angular/angular/commit/72696e244ed7646cca9ab9afc7769a2163943bda) | fix | secure location and document initialization against SSRF and path hijack |
|
||||
### service-worker
|
||||
| Commit | Type | Description |
|
||||
| -- | -- | -- |
|
||||
| [b8bd49341d](https://github.com/angular/angular/commit/b8bd49341ddcee10d119a9d4aa8e5736e4e5da53) | fix | Preserves explicit 'credentials: omit' in asset requests |
|
||||
| [ca32fc1000](https://github.com/angular/angular/commit/ca32fc10001301e6174804f9abcfba62252334f4) | fix | Preserves HTTP cache mode in asset group requests |
|
||||
|
||||
<!-- CHANGELOG SPLIT MARKER -->
|
||||
|
||||
<a name="21.2.14"></a>
|
||||
# 21.2.14 (2026-05-20)
|
||||
### compiler
|
||||
|
||||
@@ -10,6 +10,8 @@ import {get} from 'node:https';
|
||||
import {posix} from 'node:path';
|
||||
|
||||
const GITHUB_API = 'https://api.github.com/repos/';
|
||||
const SHA_REGEX = /^[0-9a-f]{40}$/i;
|
||||
const BRANCH_REGEX = /^(?!.*\.\.)[a-zA-Z0-9/_.-]+$/;
|
||||
|
||||
export class GithubClient {
|
||||
#token;
|
||||
@@ -30,6 +32,12 @@ export class GithubClient {
|
||||
* @returns Promise<string[]>
|
||||
*/
|
||||
async getAffectedFiles(baseSha, headSha) {
|
||||
if (!SHA_REGEX.test(baseSha)) {
|
||||
throw new Error(`Invalid base SHA: ${baseSha}`);
|
||||
}
|
||||
if (!SHA_REGEX.test(headSha)) {
|
||||
throw new Error(`Invalid head SHA: ${headSha}`);
|
||||
}
|
||||
const {files} = JSON.parse(await this.#httpGet(`${this.#api}/compare/${baseSha}...${headSha}`));
|
||||
return files.map((f) => f.filename);
|
||||
}
|
||||
@@ -41,6 +49,9 @@ export class GithubClient {
|
||||
* @returns Promise<string>
|
||||
*/
|
||||
async getShaForBranch(branch) {
|
||||
if (!BRANCH_REGEX.test(branch)) {
|
||||
throw new Error(`Invalid branch name: ${branch}`);
|
||||
}
|
||||
const sha = await this.#httpGet(`${this.#api}/commits/${branch}`, {
|
||||
headers: {Accept: 'application/vnd.github.VERSION.sha'},
|
||||
});
|
||||
@@ -49,7 +60,7 @@ export class GithubClient {
|
||||
throw new Error(`Unable to extract the SHA for '${branch}'.`);
|
||||
}
|
||||
|
||||
return sha;
|
||||
return sha.trim();
|
||||
}
|
||||
|
||||
#httpGet(url, options = {}) {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
//tslint:disable:no-console
|
||||
import assert from 'node:assert';
|
||||
import {execSync} from 'node:child_process';
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import {existsSync, constants as fsConstants} from 'node:fs';
|
||||
import {
|
||||
copyFile,
|
||||
@@ -41,6 +41,16 @@ export async function updateAssets({repo, assetsPath, destPath}) {
|
||||
await readFile(buildInfoPath, 'utf-8'),
|
||||
);
|
||||
|
||||
const shaRegex = /^[0-9a-f]{40}$/i;
|
||||
const branchRegex = /^(?!.*\.\.)[a-zA-Z0-9/_.-]+$/;
|
||||
|
||||
if (!shaRegex.test(storedSha)) {
|
||||
throw new Error(`Invalid SHA in build info: ${storedSha}`);
|
||||
}
|
||||
if (!branchRegex.test(storedBranch)) {
|
||||
throw new Error(`Invalid branch name in build info: ${storedBranch}`);
|
||||
}
|
||||
|
||||
assert(process.env.ANGULAR_READONLY_GITHUB_TOKEN);
|
||||
const githubApi = new GithubClient(
|
||||
repo,
|
||||
@@ -63,6 +73,10 @@ export async function updateAssets({repo, assetsPath, destPath}) {
|
||||
downstreamBranch = storedBranch;
|
||||
}
|
||||
|
||||
if (!shaRegex.test(latestSha)) {
|
||||
throw new Error(`Invalid SHA resolved: ${latestSha}`);
|
||||
}
|
||||
|
||||
console.log(`Comparing ${storedSha}...${latestSha}.`);
|
||||
const affectedFiles = await githubApi.getAffectedFiles(storedSha, latestSha);
|
||||
const changedFiles = affectedFiles.filter((file) => file.startsWith(`${assetsPath}/`));
|
||||
@@ -78,14 +92,18 @@ export async function updateAssets({repo, assetsPath, destPath}) {
|
||||
|
||||
try {
|
||||
const execOptions = {cwd: temporaryDir, stdio: 'inherit'};
|
||||
execSync('git init', execOptions);
|
||||
execSync(`git remote add origin https://github.com/${repo}.git`, execOptions);
|
||||
execFileSync('git', ['init'], execOptions);
|
||||
execFileSync(
|
||||
'git',
|
||||
['remote', 'add', 'origin', `https://github.com/${repo}.git`],
|
||||
execOptions,
|
||||
);
|
||||
// fetch a commit
|
||||
execSync(`git fetch origin ${latestSha}`, execOptions);
|
||||
execFileSync('git', ['fetch', 'origin', latestSha], execOptions);
|
||||
// reset this repository's main branch to the commit of interest
|
||||
execSync('git reset --hard FETCH_HEAD', execOptions);
|
||||
execFileSync('git', ['reset', '--hard', 'FETCH_HEAD'], execOptions);
|
||||
// get sha when files where changed
|
||||
shaWhenFilesChanged = execSync(`git rev-list -1 ${latestSha} "${assetsPath}/"`, {
|
||||
shaWhenFilesChanged = execFileSync('git', ['rev-list', '-1', latestSha, `${assetsPath}/`], {
|
||||
encoding: 'utf8',
|
||||
cwd: temporaryDir,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
[href]="item.path"
|
||||
target="_blank"
|
||||
[matTooltip]="item.label"
|
||||
[matTooltipDisabled]="itemLabel.length < 27"
|
||||
[matTooltipDisabled]="itemLabel.length < labelTruncationThreshold"
|
||||
matTooltipPosition="after"
|
||||
[attr.aria-label]="item.label"
|
||||
[matTooltipClass]="'API-tooltip'"
|
||||
@@ -55,7 +55,7 @@
|
||||
}"
|
||||
(click)="emitClickOnLink(item)"
|
||||
[matTooltip]="item.label"
|
||||
[matTooltipDisabled]="itemLabel.length < 27"
|
||||
[matTooltipDisabled]="itemLabel.length < labelTruncationThreshold"
|
||||
matTooltipPosition="after"
|
||||
[attr.aria-label]="item.label"
|
||||
[matTooltipClass]="'API-tooltip'"
|
||||
@@ -76,7 +76,7 @@
|
||||
<div
|
||||
class="docs-secondary-nav-header"
|
||||
[matTooltip]="item.label"
|
||||
[matTooltipDisabled]="itemLabel.length < 27"
|
||||
[matTooltipDisabled]="itemLabel.length < labelTruncationThreshold"
|
||||
matTooltipPosition="after"
|
||||
[attr.aria-label]="item.label"
|
||||
[matTooltipClass]="'API-tooltip'"
|
||||
@@ -106,7 +106,7 @@
|
||||
item.children && item.level === expandableLevel() && !item.isExpanded
|
||||
"
|
||||
[matTooltip]="item.label"
|
||||
[matTooltipDisabled]="itemLabel.length < 27"
|
||||
[matTooltipDisabled]="itemLabel.length < labelTruncationThreshold"
|
||||
matTooltipPosition="after"
|
||||
[attr.aria-label]="item.label"
|
||||
[matTooltipClass]="'API-tooltip'"
|
||||
|
||||
@@ -39,6 +39,8 @@ export class NavigationList {
|
||||
|
||||
readonly linkClicked = output<void>();
|
||||
|
||||
protected readonly labelTruncationThreshold = 27;
|
||||
|
||||
private readonly navigationState = inject(NavigationState);
|
||||
private readonly crossCategoryOrigin = this.navigationState.crossCategoryOrigin;
|
||||
|
||||
|
||||
@@ -24,43 +24,43 @@ $screen-xxl: 1800px;
|
||||
}
|
||||
|
||||
@mixin for-tablet-portrait-up {
|
||||
@media (min-width: calc($screen-xs + 0.01px)) {
|
||||
@media (min-width: ($screen-xs + 1px)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin for-tablet {
|
||||
@media (min-width: calc($screen-xs + 0.01px)) and (max-width: $screen-md) {
|
||||
@media (min-width: ($screen-xs + 1px)) and (max-width: $screen-md) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin for-tablet-up {
|
||||
@media (min-width: calc($screen-sm + 0.01px)) {
|
||||
@media (min-width: ($screen-sm + 1px)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin for-tablet-landscape-up {
|
||||
@media (min-width: calc($screen-md + 0.01px)) {
|
||||
@media (min-width: ($screen-md + 1px)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin for-desktop-up {
|
||||
@media (min-width: calc($screen-lg + 0.01px)) {
|
||||
@media (min-width: ($screen-lg + 1px)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin for-large-desktop-up {
|
||||
@media (min-width: calc($screen-xl + 0.01px)) {
|
||||
@media (min-width: ($screen-xl + 1px)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin for-extra-large-desktop-up {
|
||||
@media (min-width: calc($screen-xxl + 0.01px)) {
|
||||
@media (min-width: ($screen-xxl + 1px)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ $screen-xxl: 1800px;
|
||||
}
|
||||
|
||||
@mixin for-large-desktop-down {
|
||||
@media (max-width: calc($screen-xl )) {
|
||||
@media (max-width: $screen-xl) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,26 +80,11 @@ $code-font-size: 0.875rem;
|
||||
&:not(pre *) {
|
||||
position: relative;
|
||||
padding: 0 0.3rem;
|
||||
// Fallback for older browsers
|
||||
background: #e62600;
|
||||
background: var(--red-to-orange-horizontal-gradient);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
background: var(--subtle-purple);
|
||||
color: var(--hot-pink);
|
||||
max-width: max-content;
|
||||
border-radius: 0.25rem;
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--subtle-purple);
|
||||
border-radius: 0.25rem;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
a:not(.docs-anchor) > & {
|
||||
position: relative;
|
||||
|
||||
@@ -99,6 +99,9 @@
|
||||
<li>
|
||||
<a href="https://angular.az/" title="Azərbaycanca">Azərbaycanca</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://docs.angular.lat/" title="Español">Español</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://angular-docs.tr/" title="Türkçe">Türkçe</a>
|
||||
</li>
|
||||
|
||||
@@ -79,12 +79,15 @@
|
||||
|
||||
:host {
|
||||
--item-attr-base-mix: 80%;
|
||||
--item-attr-text: var(--page-background);
|
||||
|
||||
.docs-light-mode & {
|
||||
--item-attr-base-mix: 20%;
|
||||
--item-attr-text: var(--primary-contrast);
|
||||
}
|
||||
@media screen and (prefers-color-scheme: light) {
|
||||
--item-attr-base-mix: 20%;
|
||||
--item-attr-text: var(--primary-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +113,7 @@
|
||||
var(--symbolic-yellow) var(--item-attr-base-mix),
|
||||
var(--octonary-contrast)
|
||||
);
|
||||
color: var(--page-background);
|
||||
color: var(--item-attr-text);
|
||||
}
|
||||
|
||||
&.adev-experimental {
|
||||
@@ -119,7 +122,7 @@
|
||||
var(--symbolic-green) var(--item-attr-base-mix),
|
||||
var(--octonary-contrast)
|
||||
);
|
||||
color: var(--page-background);
|
||||
color: var(--item-attr-text);
|
||||
}
|
||||
|
||||
&.adev-deprecated {
|
||||
|
||||
@@ -63,6 +63,9 @@ For example:
|
||||
|
||||
For full details of these and other tools, see the [Angular CDK accessibility overview](https://material.angular.dev/cdk/a11y/overview).
|
||||
|
||||
For custom-styled components that need reusable WAI-ARIA interaction patterns, [Angular Aria](guide/aria/overview) provides headless directives for patterns such as accordion, combobox, listbox, menu, tabs, and toolbar.
|
||||
These directives handle keyboard interaction, ARIA attributes, focus management, and screen reader support while letting you provide the HTML structure and styling for your application.
|
||||
|
||||
### Augmenting native elements
|
||||
|
||||
Native HTML elements capture several standard interaction patterns that are important to accessibility.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Field metadata is reactive data you can attach to an individual field. Angular's built-in constraint validators like `required()` and `min()` use this system internally. In other words, every time you call a validator, you're contributing to a metadata key for that particular field.
|
||||
|
||||
This guide covers the metadata system in depth: how reducers combine contributions from multiple schema rules, how to write custom reducers, how reading composes with `hasMetadata()`, and how managed metadata ties lifecycle-aware objects to individual fields.
|
||||
This guide covers the metadata system in depth: how reducers combine contributions from multiple schema rules, how to write custom reducers, how to read metadata values from fields, and how managed metadata ties lifecycle-aware objects to individual fields.
|
||||
|
||||
## You have already been using metadata
|
||||
|
||||
@@ -126,23 +126,15 @@ The logic function receives the field's context, which exposes `value` as a sign
|
||||
|
||||
## Reading metadata from a field
|
||||
|
||||
`hasMetadata(key)` returns `true` if any schema rule registered the key on this field. `state.metadata(key)` returns `undefined` when no rule has registered the key, and a signal of the current reduced value otherwise.
|
||||
`state.metadata(key)` returns `undefined` when no rule has registered the key, and a signal of the current reduced value otherwise.
|
||||
|
||||
```ts
|
||||
registrationForm.username().hasMetadata(USERNAME_HELP); // true if any metadata() rule registered this key
|
||||
const usernameHelp = registrationForm.username().metadata(USERNAME_HELP);
|
||||
```
|
||||
|
||||
The shape of that inner value (whether it can itself be `undefined`, what type it holds) depends on the key's reducer. Reducers are covered in the next section.
|
||||
|
||||
When the key may not be registered, gate the read with `hasMetadata()`:
|
||||
|
||||
```angular-html
|
||||
@if (registrationForm.username().hasMetadata(USERNAME_HELP)) {
|
||||
<p class="help">{{ registrationForm.username().metadata(USERNAME_HELP)!() }}</p>
|
||||
}
|
||||
```
|
||||
|
||||
When you know a rule always registers the key (because the schema in the same file does so), you can skip the `hasMetadata()` check and use optional chaining as a compact alternative:
|
||||
When the key may not be registered, use optional chaining:
|
||||
|
||||
```ts
|
||||
const message = registrationForm.username().metadata(USERNAME_HELP)?.();
|
||||
|
||||
@@ -478,7 +478,7 @@ import {form, FormField, required, min, max} from '@angular/forms/signals';
|
||||
imports: [FormField],
|
||||
template: `
|
||||
<label>
|
||||
Age (between {{ ageForm.age().min() }} and {{ ageForm.age().max() }})
|
||||
Age (between {{ ageForm.age().min?.() }} and {{ ageForm.age().max?.() }})
|
||||
<input type="number" [formField]="ageForm.age" />
|
||||
</label>
|
||||
|
||||
@@ -519,12 +519,8 @@ import {form, FormField, max} from '@angular/forms/signals';
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Quantity (max: {{ inventoryForm.quantity().max() }})
|
||||
<input
|
||||
type="number"
|
||||
[formField]="inventoryForm.quantity"
|
||||
[max]="inventoryForm.quantity().max()"
|
||||
/>
|
||||
Quantity (max: {{ inventoryForm.quantity().max?.() }})
|
||||
<input type="number" [formField]="inventoryForm.quantity" />
|
||||
</label>
|
||||
`,
|
||||
})
|
||||
|
||||
@@ -31,10 +31,12 @@ Use the `i18n` project option in the [`angular.json`][GuideWorkspaceConfig] work
|
||||
|
||||
The following sub-options identify the source language and tell the compiler where to find supported translations for the project.
|
||||
|
||||
| Suboption | Details |
|
||||
| :------------- | :--------------------------------------------------------------------------- |
|
||||
| `sourceLocale` | The locale you use within the application source code \(`en-US` by default\) |
|
||||
| `locales` | A map of locale identifiers to translation files |
|
||||
| Suboption | Details |
|
||||
| :------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `sourceLocale` | The locale you use within the application source code \(`en-US` by default\). Can also be an object with `code`, `baseHref`, and `subPath` properties. |
|
||||
| `locales` | A map of locale identifiers to translation files. Each entry can also be an object with `translation`, `baseHref`, and `subPath` properties. |
|
||||
|
||||
For the full list of `i18n` properties and their types, see [i18n options][GuideWorkspaceConfigI18n].
|
||||
|
||||
### `angular.json` for `en-US` and `fr` example
|
||||
|
||||
@@ -149,3 +151,4 @@ TLDR: Compile once, then translate for each locale.
|
||||
[GuideI18nCommonMergeGenerateApplicationVariantsForEachLocale]: guide/i18n/merge#generate-application-variants-for-each-locale 'Generate application variants for each locale - Merge translations into the application | Angular'
|
||||
[GuideI18nCommonTranslationFilesChangeTheSourceLanguageFileFormat]: guide/i18n/translation-files#change-the-source-language-file-format 'Change the source language file format - Work with translation files | Angular'
|
||||
[GuideWorkspaceConfig]: reference/configs/workspace-config 'Angular workspace configuration | Angular'
|
||||
[GuideWorkspaceConfigI18n]: reference/configs/workspace-config#i18n-options 'i18n options - Angular workspace configuration | Angular'
|
||||
|
||||
@@ -48,6 +48,26 @@ The following example shows the `<ng-container>` element transformed into a non-
|
||||
|
||||
<docs-code path="adev/src/content/examples/i18n/src/app/app.component.html" region="i18n-ng-container"/>
|
||||
|
||||
### Name the interpolation placeholder
|
||||
|
||||
By default, Angular generates a placeholder name for each interpolation in a translated message. To give it a meaningful name that helps translators understand the context, add an `//i18n(ph="name")` comment inside the interpolation.
|
||||
|
||||
```html
|
||||
<element i18n>{{ expression //i18n(ph="placeholder_name") }}</element>
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
```html
|
||||
<p i18n>Hello, {{ username //i18n(ph="name") }}!</p>
|
||||
```
|
||||
|
||||
This is the template equivalent of naming a placeholder in component code with [`$localize`][ApiLocalizeInitLocalize]:
|
||||
|
||||
```ts
|
||||
$localize`Hello, ${username}:name:!`;
|
||||
```
|
||||
|
||||
## Mark element attributes for translations
|
||||
|
||||
In a component template, the i18n metadata is the value of the `i18n-{attribute_name}` attribute.
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
Signals are useful because they notify interested consumers when they change. An **effect** is an operation that runs whenever one or more signal values change. You can create an effect with the `effect` function:
|
||||
|
||||
```ts
|
||||
import {effect} from '@angular/core';
|
||||
|
||||
effect(() => {
|
||||
console.log(`The current count is: ${count()}`);
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ const firstName = computed(() => {
|
||||
if (userResource.hasValue()) {
|
||||
// `hasValue` serves 2 purposes:
|
||||
// - It acts as type guard to strip `undefined` from the type
|
||||
// - If protects against reading a throwing `value` when the resource is in error state
|
||||
// - It protects against reading a throwing `value` when the resource is in error state
|
||||
return userResource.value().firstName;
|
||||
}
|
||||
|
||||
@@ -116,11 +116,11 @@ The `status` signal provides a specific `ResourceStatus` that describes the stat
|
||||
| `'idle'` | `undefined` | The resource has no valid request and the loader has not run. |
|
||||
| `'error'` | `undefined` | The loader has encountered an error. |
|
||||
| `'loading'` | `undefined` | The loader is running as a result of the `params` value changing. |
|
||||
| `'reloading'` | Previous value | The loader is running as a result calling of the resource's `reload` method. |
|
||||
| `'reloading'` | Previous value | The loader is running as a result of calling the resource's `reload` method. |
|
||||
| `'resolved'` | Resolved value | The loader has completed. |
|
||||
| `'local'` | Locally set value | The resource's value has been set locally via `.set()` or `.update()` |
|
||||
|
||||
You can use this status information to conditionally display user interface elements, such loading indicators and error messages.
|
||||
You can use this status information to conditionally display user interface elements, such as loading indicators and error messages.
|
||||
|
||||
## Reactive data fetching with `httpResource`
|
||||
|
||||
|
||||
@@ -432,7 +432,7 @@ To configure this, update your `angular.json` file as follows:
|
||||
You can customize how Angular caches HTTP responses during server‑side rendering (SSR) and reuses them during hydration by configuring `HttpTransferCacheOptions`.
|
||||
This configuration is provided globally using `withHttpTransferCacheOptions` inside `provideClientHydration()`.
|
||||
|
||||
By default, `HttpClient` caches all `HEAD` and `GET` requests which don't contain `Authorization` or `Proxy-Authorization` headers. You can override those settings by using `withHttpTransferCacheOptions` to the hydration configuration.
|
||||
By default, `HttpClient` caches all `HEAD` and `GET` requests which don't contain `Authorization`, `Proxy-Authorization`, or `Cookie` headers and are not sent with `withCredentials` or Fetch API `credentials` modes that can send credentials. Angular also skips transfer cache when a request or response includes `Cache-Control` directives that forbid caching (`no-store`, `no-cache`, or `private`), or when the Fetch API `cache` option is set to `no-store` or `no-cache`. Responses that carry a `Set-Cookie` header are also skipped. You can override the request filtering settings by using `withHttpTransferCacheOptions` in the hydration configuration.
|
||||
|
||||
```ts
|
||||
import {bootstrapApplication} from '@angular/platform-browser';
|
||||
@@ -467,6 +467,8 @@ withHttpTransferCacheOptions({
|
||||
|
||||
IMPORTANT: Avoid including sensitive headers like authentication tokens. These can leak user‑specific data between requests.
|
||||
|
||||
Including `Cache-Control` in `includeHeaders` only makes that header available on the hydrated response. Angular already evaluates `Cache-Control` headers automatically when deciding whether a request or response is eligible for transfer cache.
|
||||
|
||||
---
|
||||
|
||||
### `includePostRequests`
|
||||
@@ -486,8 +488,8 @@ Use this only when `POST` requests are **idempotent** and safe to reuse between
|
||||
|
||||
### `includeRequestsWithAuthHeaders`
|
||||
|
||||
Determines whether requests containing `Authorization` or `Proxy‑Authorization` headers are eligible for caching.
|
||||
By default, these are excluded to prevent caching user‑specific responses.
|
||||
Determines whether requests containing `Authorization`, `Proxy‑Authorization`, or `Cookie` headers are eligible for caching.
|
||||
By default, these are excluded to prevent caching user‑specific responses. Requests sent with `withCredentials` or Fetch API `credentials` set to `include` or `same-origin` are also excluded by default.
|
||||
|
||||
```ts
|
||||
withHttpTransferCacheOptions({
|
||||
@@ -558,6 +560,8 @@ To disable caching for an individual request, you can specify the [`transferCach
|
||||
httpClient.get('/api/sensitive-data', {transferCache: false});
|
||||
```
|
||||
|
||||
`HttpTransferCache` does not cache requests or responses that explicitly opt out of caching. Angular skips transfer cache entries when a request includes a `Cache-Control` header with `no-store`, `no-cache`, or `private`, or when the request uses the Fetch API `cache` option set to `no-store` or `no-cache`. Responses with `Cache-Control: no-store`, `Cache-Control: no-cache`, or `Cache-Control: private` are also not stored in the transfer cache. Responses that include a `Set-Cookie` header are likewise not stored, as they typically carry user-specific state.
|
||||
|
||||
NOTE: If your application uses different HTTP origins to make API calls on the server and on the client, the `HTTP_TRANSFER_CACHE_ORIGIN_MAP` token allows you to establish a mapping between those origins, so that `HttpTransferCache` feature can recognize those requests as the same ones and reuse the data cached on the server during hydration on the client.
|
||||
|
||||
## Configuring a server
|
||||
|
||||
@@ -76,8 +76,8 @@
|
||||
trusted type support, help protect your users from common vulnerabilities like
|
||||
cross-site scripting and cross-site request forgery.
|
||||
</docs-card>
|
||||
<docs-card title="Keep large teams productive with Vite and esbuild" href="tools/cli/build-system-migration" link="ESBuild and Vite" titleIconName="sensors">
|
||||
Angular CLI includes a fast, modern build pipeline using Vite and ESBuild. Developers report
|
||||
<docs-card title="Keep large teams productive with Vite and esbuild" href="tools/cli/build-system-migration" link="Vite and esbuild" titleIconName="sensors">
|
||||
Angular CLI includes a fast, modern build pipeline using Vite and esbuild. Developers report
|
||||
building projects with hundreds of thousands of lines of code in less than a minute.
|
||||
</docs-card>
|
||||
<docs-card title="Proven in some of Google's largest web apps" titleIconName="sensors">
|
||||
@@ -108,7 +108,7 @@
|
||||
<docs-card title="Partnering with other Google technologies" titleIconName="sensors">
|
||||
<p>Angular partners closely with other Google technologies and teams to improve the web.</p>
|
||||
<p>Our ongoing partnership with Chrome’s Aurora actively explores improvements to user experience across the web, developing built-in performance optimizations like <code>NgOptimizedImage</code> and improvements to Angular’s Core Web Vitals.</p>
|
||||
<p>We are also working with <a href="https://firebase.google.com/" target="_blank">Firebase</a>, <a href="https://www.tensorflow.org/" target="_blank">Tensorflow</a>, <a href="https://flutter.dev/" target="_blank">Flutter</a>, <a href="https://m3.material.io/" target="_blank">Material Design</a>, and <a href="https://cloud.google.com/" target="_blank">Google Cloud</a> to ensure we provide meaningful integrations across the developer workflow.</p>
|
||||
<p>We are also working with <a href="https://firebase.google.com/" target="_blank">Firebase</a>, <a href="https://www.tensorflow.org/" target="_blank">TensorFlow</a>, <a href="https://flutter.dev/" target="_blank">Flutter</a>, <a href="https://m3.material.io/" target="_blank">Material Design</a>, and <a href="https://cloud.google.com/" target="_blank">Google Cloud</a> to ensure we provide meaningful integrations across the developer workflow.</p>
|
||||
</docs-card>
|
||||
</docs-card-container>
|
||||
|
||||
|
||||
@@ -62,14 +62,46 @@ The following properties are a set of options that customize the Angular CLI.
|
||||
|
||||
The following top-level configuration properties are available for each project, under `projects['project-name']`.
|
||||
|
||||
| Property | Details | Value type | Default value |
|
||||
| :------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------- | :-------------- |
|
||||
| `root` | The root directory for this project's files, relative to the workspace directory. Empty for the initial application, which resides at the top level of the workspace. | `string` | None (required) |
|
||||
| `projectType` | One of "application" or "library" An application can run independently in a browser, while a library cannot. | `application` \| `library` | None (required) |
|
||||
| `sourceRoot` | The root directory for this project's source files. | `string` | `''` |
|
||||
| `prefix` | A string that Angular prepends to selectors when generating new components, directives, and pipes using `ng generate`. Can be customized to identify an application or feature area. | `string` | `'app'` |
|
||||
| `schematics` | A set of schematics that customize the `ng generate` sub-command option defaults for this project. See the [Generation schematics](#schematics) section. | See [schematics](#schematics) | `{}` |
|
||||
| `architect` | Configuration defaults for Architect builder targets for this project. | See [Configuring builder targets](#configuring-builder-targets) | `{}` |
|
||||
| Property | Details | Value type | Default value |
|
||||
| :------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------- | :-------------- |
|
||||
| `root` | The root directory for this project's files, relative to the workspace directory. Empty for the initial application, which resides at the top level of the workspace. | `string` | None (required) |
|
||||
| `projectType` | One of "application" or "library" An application can run independently in a browser, while a library cannot. | `application` \| `library` | None (required) |
|
||||
| `sourceRoot` | The root directory for this project's source files. | `string` | `''` |
|
||||
| `prefix` | A string that Angular prepends to selectors when generating new components, directives, and pipes using `ng generate`. Can be customized to identify an application or feature area. | `string` | `'app'` |
|
||||
| `i18n` | Internationalization options for the project. Defines the source locale and additional locales to build. See [Define locales in the build configuration](guide/i18n/merge#define-locales-in-the-build-configuration). | See [i18n options](#i18n-options) | `{}` |
|
||||
| `schematics` | A set of schematics that customize the `ng generate` sub-command option defaults for this project. See the [Generation schematics](#schematics) section. | See [schematics](#schematics) | `{}` |
|
||||
| `architect` | Configuration defaults for Architect builder targets for this project. | See [Configuring builder targets](#configuring-builder-targets) | `{}` |
|
||||
|
||||
## i18n options
|
||||
|
||||
Use the `i18n` project option to define the application's source locale and any additional locales to build.
|
||||
|
||||
| Property | Details | Value type | Default value |
|
||||
| :------------- | :----------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------ | :------------ |
|
||||
| `sourceLocale` | The locale used in the application source code. Can be a locale identifier string or a [configuration object](#sourcelocale-object). | `string` \| [sourceLocale object](#sourcelocale-object) | `"en-US"` |
|
||||
| `locales` | A map of locale identifiers to translation files or [locale configuration objects](#locale-object). | `object` | `{}` |
|
||||
|
||||
### `sourceLocale` object
|
||||
|
||||
Pass an object instead of a string to customize the output directory or base HREF for the source locale:
|
||||
|
||||
| Property | Details | Value type | Default value |
|
||||
| :--------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------- | :------------ |
|
||||
| `code` | The source locale identifier. | `string` | `"en-US"` |
|
||||
| `baseHref` | Overrides the HTML `<base href>` for this locale. The output directory name stays as the locale code. Cannot be used together with `subPath`. | `string` | Locale code |
|
||||
| `subPath` | Sets both the output directory name and the HTML `<base href>` for this locale. Cannot be used together with `baseHref`. | `string` | Locale code |
|
||||
|
||||
### Locale object
|
||||
|
||||
Each `locales` entry can be a path string, an array of paths, or an object:
|
||||
|
||||
| Property | Details | Value type | Default value |
|
||||
| :------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- | :------------ |
|
||||
| `translation` | Path or paths to the translation file(s) for this locale. | `string` \| `string[]` | |
|
||||
| `baseHref` | Overrides the HTML `<base href>` for this locale. The output directory name stays as the locale identifier. Cannot be used together with `subPath`. | `string` | Locale code |
|
||||
| `subPath` | Sets both the output directory name and the HTML `<base href>` for this locale. Cannot be used together with `baseHref`. | `string` | Locale code |
|
||||
|
||||
HELPFUL: Use `subPath` rather than `baseHref` when you also need to rename the output directory — for example, to output to `de-DE/` instead of `de/`.
|
||||
|
||||
## Schematics
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ We will work on finding a way to implement stricter type checking for reactive f
|
||||
<docs-card title="Improve integration of Angular DevTools with framework" link="Completed Q1 2022" href="tools/devtools">
|
||||
To improve the integration of Angular DevTools with the framework, we are working on moving the codebase to the angular/angular monorepository. This includes transitioning Angular DevTools to Bazel and integrating it into the existing processes and CI pipeline.
|
||||
</docs-card>
|
||||
<docs-card title="Launch advanced compiler diagnostics" link="Completed Q1 2022" href="reference/extended-diagnostics">
|
||||
<docs-card title="Launch advanced compiler diagnostics" link="Completed Q1 2022" href="extended-diagnostics">
|
||||
Extend the diagnostics of the Angular compiler outside type checking. Introduce other correctness and conformance checks to further guarantee correctness and best practices.
|
||||
</docs-card>
|
||||
<docs-card title="Update our e2e testing strategy" link="Completed Q3 2021" href="guide/testing">
|
||||
@@ -270,7 +270,7 @@ As part of the v11 release, we introduced an opt-in preview of webpack 5 in the
|
||||
<docs-card title="Faster apps by inlining critical styles in Universal apps" link="Completed Q1 2021" href="guide/ssr">
|
||||
Loading external stylesheets is a blocking operation, which means that the browser cannot start rendering your app until it loads all the referenced CSS. Having render-blocking resources in the header of a page can significantly impact its load performance, for example, its first contentful paint. To make apps faster, we have been collaborating with the Google Chrome team on inlining critical CSS and loading the rest of the styles asynchronously.
|
||||
</docs-card>
|
||||
<docs-card title="Improve debugging with better Angular error messages" link="Completed Q1 2021" href="reference/errors">
|
||||
<docs-card title="Improve debugging with better Angular error messages" link="Completed Q1 2021" href="errors">
|
||||
Error messages often bring limited actionable information to help developers resolve them. We have been working on making error messages more discoverable by adding associated codes, developing guides, and other materials to ensure a smoother debugging experience.
|
||||
</docs-card>
|
||||
<docs-card title="Improved developer onboarding with refreshed introductory documentation" link="Completed Q1 2021" href="tutorials">
|
||||
|
||||
@@ -23,7 +23,7 @@ You should be very careful when choosing the name of your library if you want to
|
||||
See [Publishing your library](tools/libraries/creating-libraries#publishing-your-library).
|
||||
|
||||
Avoid using a name that is prefixed with `ng-`, such as `ng-library`.
|
||||
The `ng-` prefix is a reserved keyword used from the Angular framework and its libraries.
|
||||
The `ng-` prefix is a reserved keyword used by the Angular framework and its libraries.
|
||||
The `ngx-` prefix is preferred as a convention used to denote that the library is suitable for use with Angular.
|
||||
It is also an excellent indication to consumers of the registry to differentiate between libraries of different JavaScript frameworks.
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ export const enum RuntimeErrorCode {
|
||||
// (undocumented)
|
||||
JSONP_HEADERS_NOT_SUPPORTED = 2812,
|
||||
// (undocumented)
|
||||
JSONP_UNSAFE_URL = 2826,
|
||||
// (undocumented)
|
||||
JSONP_WRONG_METHOD = 2810,
|
||||
// (undocumented)
|
||||
JSONP_WRONG_RESPONSE_TYPE = 2811,
|
||||
|
||||
@@ -1739,6 +1739,8 @@ export interface SchemaMetadata {
|
||||
|
||||
// @public
|
||||
export enum SecurityContext {
|
||||
// (undocumented)
|
||||
ATTRIBUTE_NO_BINDING = 6,
|
||||
// (undocumented)
|
||||
HTML = 1,
|
||||
// (undocumented)
|
||||
|
||||
@@ -13,6 +13,8 @@ export const enum RuntimeErrorCode {
|
||||
// (undocumented)
|
||||
HYDRATION_CONFLICTING_FEATURES = 5001,
|
||||
// (undocumented)
|
||||
INVALID_EVENT_ATTRIBUTE = 5203,
|
||||
// (undocumented)
|
||||
NO_PLUGIN_FOR_EVENT = 5101,
|
||||
// (undocumented)
|
||||
ROOT_NODE_NOT_FOUND = -5104,
|
||||
|
||||
@@ -61,6 +61,24 @@ export function renderModule<T>(moduleType: Type<T>, options: {
|
||||
allowedHosts?: Readonly<string>[];
|
||||
}): Promise<string>;
|
||||
|
||||
// @public
|
||||
export const enum RuntimeErrorCode {
|
||||
// (undocumented)
|
||||
DISABLED_DOM_EMULATION_IN_NON_BROWSER = 5704,
|
||||
// (undocumented)
|
||||
GET_COOKIE_NOT_IMPLEMENTED = 5700,
|
||||
// (undocumented)
|
||||
HOST_NOT_ALLOWED = 5706,
|
||||
// (undocumented)
|
||||
INVALID_URL = 5701,
|
||||
// (undocumented)
|
||||
PROTOCOL_RELATIVE_URL_NOT_ALLOWED = 5702,
|
||||
// (undocumented)
|
||||
SUSPICIOUS_URL_CHANGE_ORIGIN = 5703,
|
||||
// (undocumented)
|
||||
XHR_NOT_LOADED = 5705
|
||||
}
|
||||
|
||||
// @public
|
||||
export class ServerModule {
|
||||
// (undocumented)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"dist/main.js": 135813,
|
||||
"dist/main.js": 144843,
|
||||
"dist/polyfills.js": 35883
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"zone.js": "0.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/core": "7.29.7",
|
||||
"@rollup/plugin-babel": "^6.0.0",
|
||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||
"@types/jasmine": "^6.0.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"dist/browser/main-[hash].js": 227093,
|
||||
"dist/browser/polyfills-[hash].js": 34544,
|
||||
"dist/browser/main-[hash].js": 233303,
|
||||
"dist/browser/polyfills-[hash].js": 35677,
|
||||
"dist/browser/event-dispatch-contract.min.js": 476
|
||||
}
|
||||
|
||||
@@ -27,7 +27,9 @@ export class JsonpCmp {
|
||||
people: Person[] = [];
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
http.jsonp('./people.json', 'callback').subscribe((res: unknown) => {
|
||||
const peopleUrl = new URL('./people.json', window.location.href).toString();
|
||||
|
||||
http.jsonp(peopleUrl, 'callback').subscribe((res: unknown) => {
|
||||
this.people = res as Person[];
|
||||
});
|
||||
}
|
||||
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "angular-srcs",
|
||||
"version": "21.2.14",
|
||||
"version": "21.2.22",
|
||||
"private": true,
|
||||
"description": "Angular - a web framework for modern web apps",
|
||||
"homepage": "https://github.com/angular/angular",
|
||||
@@ -76,9 +76,9 @@
|
||||
"@angular/service-worker": "workspace:*",
|
||||
"@angular/ssr": "21.2.9",
|
||||
"@angular/upgrade": "workspace: *",
|
||||
"@babel/cli": "7.28.6",
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/generator": "7.29.1",
|
||||
"@babel/cli": "7.29.7",
|
||||
"@babel/core": "7.29.7",
|
||||
"@babel/generator": "7.29.7",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||
"@microsoft/api-extractor": "^7.24.2",
|
||||
"@rollup/plugin-babel": "^6.0.0",
|
||||
@@ -116,7 +116,7 @@
|
||||
"d3": "^7.0.0",
|
||||
"dagre-d3-es": "^7.0.14",
|
||||
"diff": "^8.0.0",
|
||||
"domino": "https://github.com/angular/domino.git#928dffb9d9431b2cd2a73d7b940d1575f221e072",
|
||||
"domino": "https://github.com/angular/domino.git#04f987dc08ff3736b427f50941adf1722458528f",
|
||||
"esbuild": "0.27.2",
|
||||
"esbuild-plugin-umd-wrapper": "^3.0.0",
|
||||
"http-server": "^14.0.0",
|
||||
@@ -163,7 +163,7 @@
|
||||
"@actions/github": "^9.0.0",
|
||||
"@angular/ng-dev": "https://github.com/angular/dev-infra-private-ng-dev-builds.git#4de8a14a1682d0f07e0b14a3b26498757c195904",
|
||||
"@babel/plugin-proposal-async-generator-functions": "7.20.7",
|
||||
"@babel/plugin-transform-async-generator-functions": "^7.27.1",
|
||||
"@babel/plugin-transform-async-generator-functions": "^7.29.7",
|
||||
"@bazel/bazelisk": "^1.7.5",
|
||||
"@bazel/buildifier": "^8.0.0",
|
||||
"@bazel/ibazel": "0.28.0",
|
||||
|
||||
@@ -100,11 +100,15 @@ export class HttpInterceptorHandler implements HttpHandler {
|
||||
|
||||
handle(initialRequest: HttpRequest<any>): Observable<HttpEvent<any>> {
|
||||
if (this.chain === null) {
|
||||
const parentHandler = this.injector.get(HttpHandler, null, {skipSelf: true});
|
||||
const isDelegating = parentHandler !== null && this.backend === parentHandler;
|
||||
const rootInterceptorFns = this.injector.get(
|
||||
HTTP_ROOT_INTERCEPTOR_FNS,
|
||||
[],
|
||||
isDelegating ? {self: true} : undefined,
|
||||
);
|
||||
const dedupedInterceptorFns = Array.from(
|
||||
new Set([
|
||||
...this.injector.get(HTTP_INTERCEPTOR_FNS),
|
||||
...this.injector.get(HTTP_ROOT_INTERCEPTOR_FNS, []),
|
||||
]),
|
||||
new Set([...this.injector.get(HTTP_INTERCEPTOR_FNS), ...rootInterceptorFns]),
|
||||
);
|
||||
|
||||
// Note: interceptors are wrapped right-to-left so that final execution order is
|
||||
|
||||
@@ -35,4 +35,6 @@ export const enum RuntimeErrorCode {
|
||||
REFERRER_NOT_SUPPORTED_WITH_XHR = 2821,
|
||||
INVALID_TIMEOUT_VALUE = 2822,
|
||||
REFERRER_POLICY_NOT_SUPPORTED_WITH_XHR = 2823,
|
||||
|
||||
JSONP_UNSAFE_URL = 2826,
|
||||
}
|
||||
|
||||
@@ -192,10 +192,10 @@ export class HttpHeaders {
|
||||
|
||||
private copyFrom(other: HttpHeaders) {
|
||||
other.init();
|
||||
Array.from(other.headers.keys()).forEach((key) => {
|
||||
this.headers.set(key, other.headers.get(key)!);
|
||||
for (const [key, values] of other.headers.entries()) {
|
||||
this.headers.set(key, values);
|
||||
this.normalizedNames.set(key, other.normalizedNames.get(key)!);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private clone(update: Update): HttpHeaders {
|
||||
@@ -218,21 +218,22 @@ export class HttpHeaders {
|
||||
return;
|
||||
}
|
||||
this.maybeSetNormalizedName(update.name, key);
|
||||
const base = (update.op === 'a' ? this.headers.get(key) : undefined) || [];
|
||||
const base = update.op === 'a' ? (this.headers.get(key) || []).slice() : [];
|
||||
base.push(...value);
|
||||
this.headers.set(key, base);
|
||||
break;
|
||||
case 'd':
|
||||
const toDelete = update.value as string | undefined;
|
||||
if (!toDelete) {
|
||||
const toDelete = update.value;
|
||||
if (toDelete === undefined) {
|
||||
this.headers.delete(key);
|
||||
this.normalizedNames.delete(key);
|
||||
} else {
|
||||
const valuesToDelete = Array.isArray(toDelete) ? toDelete : [toDelete];
|
||||
let existing = this.headers.get(key);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
existing = existing.filter((value) => toDelete.indexOf(value) === -1);
|
||||
existing = existing.filter((value) => valuesToDelete.indexOf(value) === -1);
|
||||
if (existing.length === 0) {
|
||||
this.headers.delete(key);
|
||||
this.normalizedNames.delete(key);
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {HttpHandler} from './backend';
|
||||
|
||||
import {HttpRequest} from './request';
|
||||
import {HttpEvent} from './response';
|
||||
import {xsrfInterceptorFn} from './xsrf';
|
||||
|
||||
/**
|
||||
* Intercepts and handles an `HttpRequest` or `HttpResponse`.
|
||||
@@ -200,7 +201,7 @@ export const HTTP_INTERCEPTORS = new InjectionToken<readonly HttpInterceptor[]>(
|
||||
*/
|
||||
export const HTTP_INTERCEPTOR_FNS = new InjectionToken<readonly HttpInterceptorFn[]>(
|
||||
typeof ngDevMode !== 'undefined' && ngDevMode ? 'HTTP_INTERCEPTOR_FNS' : '',
|
||||
{factory: () => []},
|
||||
{factory: () => [xsrfInterceptorFn]},
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -55,6 +55,10 @@ export const JSONP_ERR_WRONG_RESPONSE_TYPE = 'JSONP requests must use Json respo
|
||||
// headers set
|
||||
export const JSONP_ERR_HEADERS_NOT_SUPPORTED = 'JSONP requests do not support headers.';
|
||||
|
||||
// Error text given when a JSONP request URL is not absolute HTTP(S).
|
||||
export const JSONP_ERR_UNSAFE_URL =
|
||||
'JSONP requests only support absolute URLs with HTTP(S) protocols.';
|
||||
|
||||
/**
|
||||
* DI token/abstract type representing a map of JSONP callbacks.
|
||||
*
|
||||
@@ -139,6 +143,10 @@ export class JsonpClientBackend implements HttpBackend {
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.isAllowedJsonpUrl(req.urlWithParams)) {
|
||||
throw new RuntimeError(RuntimeErrorCode.JSONP_UNSAFE_URL, ngDevMode && JSONP_ERR_UNSAFE_URL);
|
||||
}
|
||||
|
||||
// Everything else happens inside the Observable boundary.
|
||||
return new Observable<HttpEvent<any>>((observer: Observer<HttpEvent<any>>) => {
|
||||
// The first step to make a request is to generate the callback name, and replace the
|
||||
@@ -282,6 +290,10 @@ export class JsonpClientBackend implements HttpBackend {
|
||||
|
||||
foreignDocument.adoptNode(script);
|
||||
}
|
||||
|
||||
private isAllowedJsonpUrl(url: string): boolean {
|
||||
return /^https?:\/\//i.test(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -324,18 +324,22 @@ export class HttpParams {
|
||||
}
|
||||
if (this.cloneFrom !== null) {
|
||||
this.cloneFrom.init();
|
||||
this.cloneFrom.keys().forEach((key) => this.map!.set(key, this.cloneFrom!.map!.get(key)!));
|
||||
|
||||
for (const [key, values] of this.cloneFrom.map!.entries()) {
|
||||
this.map!.set(key, values);
|
||||
}
|
||||
|
||||
this.updates!.forEach((update) => {
|
||||
switch (update.op) {
|
||||
case 'a':
|
||||
case 's':
|
||||
const base = (update.op === 'a' ? this.map!.get(update.param) : undefined) || [];
|
||||
const base = update.op === 'a' ? (this.map!.get(update.param) || []).slice() : [];
|
||||
base.push(valueToString(update.value!));
|
||||
this.map!.set(update.param, base);
|
||||
break;
|
||||
case 'd':
|
||||
if (update.value !== undefined) {
|
||||
let base = this.map!.get(update.param) || [];
|
||||
const base = (this.map!.get(update.param) || []).slice();
|
||||
const idx = base.indexOf(valueToString(update.value));
|
||||
if (idx !== -1) {
|
||||
base.splice(idx, 1);
|
||||
|
||||
@@ -101,9 +101,16 @@ export function provideHttpClient(
|
||||
featureKinds.has(HttpFeatureKind.CustomXsrfConfiguration)
|
||||
) {
|
||||
throw new Error(
|
||||
ngDevMode
|
||||
? `Configuration error: found both withXsrfConfiguration() and withNoXsrfProtection() in the same call to provideHttpClient(), which is a contradiction.`
|
||||
: '',
|
||||
`Configuration error: found both withXsrfConfiguration() and withNoXsrfProtection() in the same call to provideHttpClient(), which is a contradiction.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
featureKinds.has(HttpFeatureKind.RequestsMadeViaParent) &&
|
||||
featureKinds.has(HttpFeatureKind.Fetch)
|
||||
) {
|
||||
throw new Error(
|
||||
`Configuration error: withRequestsMadeViaParent() cannot be combined with withFetch() in the same call to provideHttpClient().`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -260,6 +267,8 @@ export function withJsonpSupport(): HttpFeature<HttpFeatureKind.JsonpSupport> {
|
||||
* "bubble up" until either reaching the root level or an `HttpClient` which was not configured with
|
||||
* this option.
|
||||
*
|
||||
* This feature cannot be combined with `withFetch` in the same `provideHttpClient()` call.
|
||||
*
|
||||
* @see [HTTP client setup](guide/http/setup#withrequestsmadeviaparent)
|
||||
* @see {@link provideHttpClient}
|
||||
* @publicApi 19.0
|
||||
|
||||
@@ -523,7 +523,7 @@ export class HttpRequest<T> {
|
||||
this.integrity = options.integrity;
|
||||
}
|
||||
|
||||
if (options.referrer) {
|
||||
if (options.referrer !== undefined) {
|
||||
this.referrer = options.referrer;
|
||||
}
|
||||
|
||||
@@ -734,7 +734,8 @@ export class HttpRequest<T> {
|
||||
const mode = update.mode || this.mode;
|
||||
const redirect = update.redirect || this.redirect;
|
||||
const credentials = update.credentials || this.credentials;
|
||||
const referrer = update.referrer || this.referrer;
|
||||
|
||||
const referrer = update.referrer ?? this.referrer;
|
||||
const integrity = update.integrity || this.integrity;
|
||||
const referrerPolicy = update.referrerPolicy || this.referrerPolicy;
|
||||
// Carefully handle the transferCache to differentiate between
|
||||
|
||||
@@ -40,8 +40,10 @@ import {HttpParams} from './params';
|
||||
* @param includePostRequests Enables caching for POST requests. By default, only GET and HEAD
|
||||
* requests are cached. This option can be enabled if POST requests are used to retrieve data
|
||||
* (for example using GraphQL).
|
||||
* @param includeRequestsWithAuthHeaders Enables caching of requests containing either `Authorization`
|
||||
* or `Proxy-Authorization` headers. By default, these requests are excluded from caching.
|
||||
* @param includeRequestsWithAuthHeaders Enables caching of requests containing `Authorization`,
|
||||
* `Proxy-Authorization`, or `Cookie` headers. By default, these requests are excluded from
|
||||
* caching. Requests sent using `withCredentials` or Fetch API `credentials` modes that can send
|
||||
* credentials are also excluded by default.
|
||||
*
|
||||
* @see [Configuring the caching options](guide/ssr#configuring-the-caching-options)
|
||||
*
|
||||
@@ -101,13 +103,13 @@ interface TransferHttpResponse {
|
||||
/** headers */
|
||||
[HEADERS]: Record<string, string[]>;
|
||||
/** status */
|
||||
[STATUS]?: number;
|
||||
[STATUS]: number;
|
||||
/** statusText */
|
||||
[STATUS_TEXT]?: string;
|
||||
[STATUS_TEXT]: string;
|
||||
/** url */
|
||||
[REQ_URL]?: string;
|
||||
[REQ_URL]: string;
|
||||
/** responseType */
|
||||
[RESPONSE_TYPE]?: HttpRequest<unknown>['responseType'];
|
||||
[RESPONSE_TYPE]: HttpRequest<unknown>['responseType'];
|
||||
}
|
||||
|
||||
interface CacheOptions extends HttpTransferCacheOptions {
|
||||
@@ -123,18 +125,24 @@ export const CACHE_OPTIONS = new InjectionToken<CacheOptions>(
|
||||
*/
|
||||
const ALLOWED_METHODS = ['GET', 'HEAD'];
|
||||
|
||||
function shouldCacheRequest(req: HttpRequest<unknown>, options: CacheOptions): boolean {
|
||||
function canUseOrCacheRequest(req: HttpRequest<unknown>, options: CacheOptions): boolean {
|
||||
const {isCacheActive, ...globalOptions} = options;
|
||||
const {transferCache: requestOptions, method: requestMethod} = req;
|
||||
|
||||
if (
|
||||
!isCacheActive ||
|
||||
requestOptions === false ||
|
||||
// Do not cache requests sent with credentials.
|
||||
hasOutgoingCredentials(req) ||
|
||||
// POST requests are allowed either globally or at request level
|
||||
(requestMethod === 'POST' && !globalOptions.includePostRequests && !requestOptions) ||
|
||||
(requestMethod !== 'POST' && !ALLOWED_METHODS.includes(requestMethod)) ||
|
||||
// Do not cache request that require authorization when includeRequestsWithAuthHeaders is falsey
|
||||
// Do not cache requests with authentication or cookie headers unless explicitly enabled.
|
||||
(!globalOptions.includeRequestsWithAuthHeaders && hasAuthHeaders(req)) ||
|
||||
// Do not cache requests that explicitly forbid caching via Cache-Control
|
||||
// or Fetch API cache mode.
|
||||
hasUncacheableCacheControl(req.headers) ||
|
||||
isNonCacheableRequest(req.cache) ||
|
||||
globalOptions.filter?.(req) === false
|
||||
) {
|
||||
return false;
|
||||
@@ -147,25 +155,30 @@ function getHeadersToInclude(
|
||||
options: CacheOptions,
|
||||
requestOptions: HttpTransferCacheOptions | boolean | undefined,
|
||||
): string[] | undefined {
|
||||
const {includeHeaders: globalHeaders} = options;
|
||||
let headersToInclude = globalHeaders;
|
||||
if (typeof requestOptions === 'object' && requestOptions.includeHeaders) {
|
||||
// Request-specific config takes precedence over the global config.
|
||||
headersToInclude = requestOptions.includeHeaders;
|
||||
}
|
||||
return headersToInclude;
|
||||
// Request-specific config takes precedence over the global config.
|
||||
return typeof requestOptions === 'object' && requestOptions.includeHeaders
|
||||
? requestOptions.includeHeaders
|
||||
: options.includeHeaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the cached response for a given request.
|
||||
* @param req The request to retrieve the cached response for.
|
||||
* @param options The caching options.
|
||||
* @param transferState The transfer state to retrieve the cached response from.
|
||||
* @param originMap The origin map to map the request URL to the origin. (Not needed when `storeKey` is provided).
|
||||
* @param storeKey The key to use to store the cached response in the transfer state. (If not provided, it will be computed from the request and originMap).
|
||||
* @param skipUseCacheChecks Whether to skip the use cache checks. (Only disable when the checks have been performed beforehand).
|
||||
*/
|
||||
export function retrieveStateFromCache(
|
||||
req: HttpRequest<unknown>,
|
||||
options: CacheOptions,
|
||||
transferState: TransferState,
|
||||
originMap: Record<string, string> | null,
|
||||
storeKey?: StateKey<TransferHttpResponse>,
|
||||
skipUseCacheChecks = false,
|
||||
): HttpResponse<unknown> | null {
|
||||
const {transferCache: requestOptions} = req;
|
||||
|
||||
// In the following situations we do not want to cache the request
|
||||
if (!shouldCacheRequest(req, options)) {
|
||||
if (!skipUseCacheChecks && !canUseOrCacheRequest(req, options)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -179,58 +192,61 @@ export function retrieveStateFromCache(
|
||||
);
|
||||
}
|
||||
|
||||
const requestUrl =
|
||||
typeof ngServerMode !== 'undefined' && ngServerMode && originMap
|
||||
? mapRequestOriginUrl(req.url, originMap)
|
||||
: req.url;
|
||||
if (!storeKey) {
|
||||
const requestUrl =
|
||||
typeof ngServerMode !== 'undefined' && ngServerMode && originMap
|
||||
? mapRequestOriginUrl(req.url, originMap)
|
||||
: req.url;
|
||||
|
||||
const storeKey = makeCacheKey(req, requestUrl);
|
||||
const response = transferState.get(storeKey, null);
|
||||
|
||||
const headersToInclude = getHeadersToInclude(options, requestOptions);
|
||||
|
||||
if (response) {
|
||||
const {
|
||||
[BODY]: undecodedBody,
|
||||
[RESPONSE_TYPE]: responseType,
|
||||
[HEADERS]: httpHeaders,
|
||||
[STATUS]: status,
|
||||
[STATUS_TEXT]: statusText,
|
||||
[REQ_URL]: url,
|
||||
} = response;
|
||||
// Request found in cache. Respond using it.
|
||||
let body: ArrayBuffer | Blob | string | undefined = undecodedBody;
|
||||
|
||||
switch (responseType) {
|
||||
case 'arraybuffer':
|
||||
body = fromBase64(undecodedBody);
|
||||
break;
|
||||
case 'blob':
|
||||
body = new Blob([fromBase64(undecodedBody)]);
|
||||
break;
|
||||
}
|
||||
|
||||
// We want to warn users accessing a header provided from the cache
|
||||
// That HttpTransferCache alters the headers
|
||||
// The warning will be logged a single time by HttpHeaders instance
|
||||
let headers = new HttpHeaders(httpHeaders);
|
||||
if (typeof ngDevMode === 'undefined' || ngDevMode) {
|
||||
// Append extra logic in dev mode to produce a warning when a header
|
||||
// that was not transferred to the client is accessed in the code via `get`
|
||||
// and `has` calls.
|
||||
headers = appendMissingHeadersDetection(req.url, headers, headersToInclude ?? []);
|
||||
}
|
||||
|
||||
return new HttpResponse({
|
||||
body,
|
||||
headers,
|
||||
status,
|
||||
statusText,
|
||||
url,
|
||||
});
|
||||
storeKey = makeCacheKey(req, requestUrl);
|
||||
}
|
||||
|
||||
return null;
|
||||
const response = transferState.get(storeKey, null);
|
||||
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
[BODY]: undecodedBody,
|
||||
[RESPONSE_TYPE]: responseType,
|
||||
[HEADERS]: httpHeaders,
|
||||
[STATUS]: status,
|
||||
[STATUS_TEXT]: statusText,
|
||||
[REQ_URL]: url,
|
||||
} = response;
|
||||
// Request found in cache. Respond using it.
|
||||
let body: ArrayBuffer | Blob | string | undefined = undecodedBody;
|
||||
|
||||
switch (responseType) {
|
||||
case 'arraybuffer':
|
||||
body = fromBase64(undecodedBody);
|
||||
break;
|
||||
case 'blob':
|
||||
body = new Blob([fromBase64(undecodedBody)]);
|
||||
break;
|
||||
}
|
||||
|
||||
// We want to warn users accessing a header provided from the cache
|
||||
// That HttpTransferCache alters the headers
|
||||
// The warning will be logged a single time by HttpHeaders instance
|
||||
let headers = new HttpHeaders(httpHeaders);
|
||||
if (typeof ngDevMode === 'undefined' || ngDevMode) {
|
||||
// Append extra logic in dev mode to produce a warning when a header
|
||||
// that was not transferred to the client is accessed in the code via `get`
|
||||
// and `has` calls.
|
||||
const {transferCache: requestOptions} = req;
|
||||
const headersToInclude = getHeadersToInclude(options, requestOptions);
|
||||
headers = appendMissingHeadersDetection(req.url, headers, headersToInclude ?? []);
|
||||
}
|
||||
|
||||
return new HttpResponse({
|
||||
body,
|
||||
headers,
|
||||
status,
|
||||
statusText,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
export function transferCacheInterceptorFn(
|
||||
@@ -238,47 +254,58 @@ export function transferCacheInterceptorFn(
|
||||
next: HttpHandlerFn,
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
const options = inject(CACHE_OPTIONS);
|
||||
const transferState = inject(TransferState);
|
||||
|
||||
const originMap = inject(HTTP_TRANSFER_CACHE_ORIGIN_MAP, {optional: true});
|
||||
|
||||
const cachedResponse = retrieveStateFromCache(req, options, transferState, originMap);
|
||||
if (cachedResponse) {
|
||||
return of(cachedResponse);
|
||||
if (!canUseOrCacheRequest(req, options)) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
const {transferCache: requestOptions} = req;
|
||||
const headersToInclude = getHeadersToInclude(options, requestOptions);
|
||||
|
||||
const transferState = inject(TransferState);
|
||||
const originMap = inject(HTTP_TRANSFER_CACHE_ORIGIN_MAP, {optional: true});
|
||||
const requestUrl =
|
||||
typeof ngServerMode !== 'undefined' && ngServerMode && originMap
|
||||
? mapRequestOriginUrl(req.url, originMap)
|
||||
: req.url;
|
||||
const storeKey = makeCacheKey(req, requestUrl);
|
||||
|
||||
// In the following situations we do not want to cache the request
|
||||
if (!shouldCacheRequest(req, options)) {
|
||||
return next(req);
|
||||
const cachedResponse = retrieveStateFromCache(
|
||||
req,
|
||||
options,
|
||||
transferState,
|
||||
/** originMap */ null,
|
||||
storeKey,
|
||||
/** skipUseCacheChecks */ true,
|
||||
);
|
||||
|
||||
if (cachedResponse) {
|
||||
return of(cachedResponse);
|
||||
}
|
||||
|
||||
const event$ = next(req);
|
||||
|
||||
if (typeof ngServerMode !== 'undefined' && ngServerMode) {
|
||||
// Request not found in cache. Make the request and cache it if on the server.
|
||||
return event$.pipe(
|
||||
tap((event: HttpEvent<unknown>) => {
|
||||
// Only cache successful HTTP responses.
|
||||
if (event instanceof HttpResponse) {
|
||||
const {headers, body, status, statusText} = event;
|
||||
|
||||
// Only cache successful HTTP responses that do not have Cache-Control
|
||||
// directives that forbid shared caching (no-store or private) and do not
|
||||
// carry a Set-Cookie header. A Set-Cookie header marks the response as
|
||||
// user-specific.
|
||||
if (hasUncacheableCacheControl(headers) || hasSetCookieHeader(headers)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {transferCache: requestOptions, responseType} = req;
|
||||
const headersToInclude = getHeadersToInclude(options, requestOptions);
|
||||
|
||||
transferState.set<TransferHttpResponse>(storeKey, {
|
||||
[BODY]:
|
||||
req.responseType === 'arraybuffer' || req.responseType === 'blob'
|
||||
? toBase64(event.body)
|
||||
: event.body,
|
||||
[HEADERS]: getFilteredHeaders(event.headers, headersToInclude),
|
||||
[STATUS]: event.status,
|
||||
[STATUS_TEXT]: event.statusText,
|
||||
responseType === 'arraybuffer' || responseType === 'blob' ? toBase64(body) : body,
|
||||
[HEADERS]: getFilteredHeaders(headers, headersToInclude),
|
||||
[STATUS]: status,
|
||||
[STATUS_TEXT]: statusText,
|
||||
[REQ_URL]: requestUrl,
|
||||
[RESPONSE_TYPE]: req.responseType,
|
||||
[RESPONSE_TYPE]: responseType,
|
||||
});
|
||||
}
|
||||
}),
|
||||
@@ -288,9 +315,43 @@ export function transferCacheInterceptorFn(
|
||||
return event$;
|
||||
}
|
||||
|
||||
/** @returns true when the requests contains autorization related headers. */
|
||||
/** @returns true when the request contains authentication or cookie headers. */
|
||||
function hasAuthHeaders(req: HttpRequest<unknown>): boolean {
|
||||
return req.headers.has('authorization') || req.headers.has('proxy-authorization');
|
||||
const headers = req.headers;
|
||||
|
||||
return (
|
||||
headers.has('authorization') || headers.has('proxy-authorization') || headers.has('cookie')
|
||||
);
|
||||
}
|
||||
|
||||
function hasOutgoingCredentials(req: HttpRequest<unknown>): boolean {
|
||||
const {withCredentials, credentials} = req;
|
||||
|
||||
return withCredentials || credentials === 'include' || credentials === 'same-origin';
|
||||
}
|
||||
|
||||
const UNCACHEABLE_CACHE_CONTROL_DIRECTIVES = new Set(['no-store', 'private', 'no-cache']);
|
||||
|
||||
function hasUncacheableCacheControl(headers: HttpHeaders): boolean {
|
||||
const cacheControl = headers.get('cache-control');
|
||||
|
||||
if (!cacheControl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return cacheControl.split(',').some((directive) => {
|
||||
const directiveName = directive.split('=', 1)[0].trim().toLowerCase();
|
||||
|
||||
return UNCACHEABLE_CACHE_CONTROL_DIRECTIVES.has(directiveName);
|
||||
});
|
||||
}
|
||||
|
||||
function hasSetCookieHeader(headers: HttpHeaders): boolean {
|
||||
return headers.has('set-cookie');
|
||||
}
|
||||
|
||||
function isNonCacheableRequest(cache: RequestCache): boolean {
|
||||
return cache === 'no-cache' || cache === 'no-store';
|
||||
}
|
||||
|
||||
function getFilteredHeaders(
|
||||
@@ -313,17 +374,17 @@ function getFilteredHeaders(
|
||||
}
|
||||
|
||||
function sortAndConcatParams(params: HttpParams | URLSearchParams): string {
|
||||
return [...params.keys()]
|
||||
.sort()
|
||||
.map((k) => `${k}=${params.getAll(k)}`)
|
||||
.join('&');
|
||||
const searchParams = new URLSearchParams(
|
||||
params instanceof URLSearchParams ? params : params.toString(),
|
||||
);
|
||||
searchParams.sort();
|
||||
return searchParams.toString();
|
||||
}
|
||||
|
||||
function makeCacheKey(
|
||||
request: HttpRequest<any>,
|
||||
mappedRequestUrl: string,
|
||||
): StateKey<TransferHttpResponse> {
|
||||
// make the params encoded same as a url so it's easy to identify
|
||||
const {params, method, responseType} = request;
|
||||
const encodedParams = sortAndConcatParams(params);
|
||||
|
||||
@@ -340,26 +401,6 @@ function makeCacheKey(
|
||||
return makeStateKey(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* A method that returns a hash representation of a string using a variant of DJB2 hash
|
||||
* algorithm.
|
||||
*
|
||||
* This is the same hashing logic that is used to generate component ids.
|
||||
*/
|
||||
function generateHash(value: string): string {
|
||||
let hash = 0;
|
||||
|
||||
for (const char of value) {
|
||||
hash = (Math.imul(31, hash) + char.charCodeAt(0)) << 0;
|
||||
}
|
||||
|
||||
// Force positive number hash.
|
||||
// 2147483647 = equivalent of Integer.MAX_VALUE.
|
||||
hash += 2147483647 + 1;
|
||||
|
||||
return hash.toString();
|
||||
}
|
||||
|
||||
function toBase64(buffer: unknown): string {
|
||||
//TODO: replace with when is Baseline widely available
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/toBase64
|
||||
@@ -497,3 +538,163 @@ function verifyMappedOrigin(url: string): void {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-256 Constants (first 32 bits of the fractional parts of the cube roots of the first 64 primes 2..311):
|
||||
*/
|
||||
const SHA256_ROUND_CONSTANTS = /* @__PURE__ */ new Uint32Array([
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
]);
|
||||
|
||||
let textEncoder: TextEncoder | undefined;
|
||||
|
||||
/**
|
||||
* Generates a SHA-256 hash representation of a string.
|
||||
*
|
||||
* Note: A custom synchronous SHA-256 implementation is used here because the Web Crypto API
|
||||
* (`crypto.subtle.digest`) is strictly asynchronous (Promise-based), whereas the transfer cache
|
||||
* state lookup and interceptor flow must operate synchronously due to the HttpResource API.
|
||||
*
|
||||
* The previous DJB2 hashing logic was vulnerable to pre-image and second-preimage attacks due to
|
||||
* its small 64-bit keyspace and mathematical simplicity. An attacker could craft colliding request
|
||||
* inputs to poison the cache, potentially causing a CDN or the application to serve the wrong
|
||||
* cached response to legitimate users. SHA-256 provides strong cryptographic collision resistance,
|
||||
* preventing cache key collision attacks.
|
||||
*/
|
||||
export function generateHash(value: string): string {
|
||||
textEncoder ??= new TextEncoder();
|
||||
const inputBytes = textEncoder.encode(value);
|
||||
|
||||
// Initial hash values (first 32 bits of the fractional parts of the square roots of the first 8 primes 2..19):
|
||||
let hashState0 = 0x6a09e667;
|
||||
let hashState1 = 0xbb67ae85;
|
||||
let hashState2 = 0x3c6ef372;
|
||||
let hashState3 = 0xa54ff53a;
|
||||
let hashState4 = 0x510e527f;
|
||||
let hashState5 = 0x9b05688c;
|
||||
let hashState6 = 0x1f83d9ab;
|
||||
let hashState7 = 0x5be0cd19;
|
||||
|
||||
// Pre-processing (Padding):
|
||||
const messageLengthInBits = inputBytes.length * 8;
|
||||
|
||||
// The total length of the padded message must be a multiple of 64 bytes (512 bits)
|
||||
const paddedLengthInBytes = (((inputBytes.length + 8) >> 6) + 1) << 6;
|
||||
const paddedBytes = new Uint8Array(paddedLengthInBytes);
|
||||
paddedBytes.set(inputBytes);
|
||||
paddedBytes[inputBytes.length] = 0x80; // Append a single '1' bit (0x80 byte)
|
||||
|
||||
const paddedBytesView = new DataView(paddedBytes.buffer);
|
||||
const lowBits = messageLengthInBits >>> 0;
|
||||
const highBits = (messageLengthInBits / 0x100000000) >>> 0;
|
||||
paddedBytesView.setUint32(paddedLengthInBytes - 8, highBits, false);
|
||||
paddedBytesView.setUint32(paddedLengthInBytes - 4, lowBits, false);
|
||||
|
||||
// Process the message in successive 64-byte chunks:
|
||||
const messageSchedule = new Uint32Array(64);
|
||||
for (let chunkOffset = 0; chunkOffset < paddedLengthInBytes; chunkOffset += 64) {
|
||||
// Initialize first 16 words of the message schedule:
|
||||
for (let i = 0; i < 16; i++) {
|
||||
messageSchedule[i] = paddedBytesView.getUint32(chunkOffset + i * 4, false);
|
||||
}
|
||||
|
||||
// Extend to 64 words:
|
||||
for (let i = 16; i < 64; i++) {
|
||||
const prevWord15 = messageSchedule[i - 15];
|
||||
const sigma0 =
|
||||
(((prevWord15 >>> 7) | (prevWord15 << 25)) ^
|
||||
((prevWord15 >>> 18) | (prevWord15 << 14)) ^
|
||||
(prevWord15 >>> 3)) >>>
|
||||
0;
|
||||
|
||||
const prevWord2 = messageSchedule[i - 2];
|
||||
const sigma1 =
|
||||
(((prevWord2 >>> 17) | (prevWord2 << 15)) ^
|
||||
((prevWord2 >>> 19) | (prevWord2 << 13)) ^
|
||||
(prevWord2 >>> 10)) >>>
|
||||
0;
|
||||
|
||||
messageSchedule[i] =
|
||||
(messageSchedule[i - 16] + sigma0 + messageSchedule[i - 7] + sigma1) >>> 0;
|
||||
}
|
||||
|
||||
// Initialize working variables to current hash values:
|
||||
let workingStateA = hashState0;
|
||||
let workingStateB = hashState1;
|
||||
let workingStateC = hashState2;
|
||||
let workingStateD = hashState3;
|
||||
let workingStateE = hashState4;
|
||||
let workingStateF = hashState5;
|
||||
let workingStateG = hashState6;
|
||||
let workingStateH = hashState7;
|
||||
|
||||
// Compression function main loop:
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const capitalSigma1 =
|
||||
(((workingStateE >>> 6) | (workingStateE << 26)) ^
|
||||
((workingStateE >>> 11) | (workingStateE << 21)) ^
|
||||
((workingStateE >>> 25) | (workingStateE << 7))) >>>
|
||||
0;
|
||||
const chFunction = ((workingStateE & workingStateF) ^ (~workingStateE & workingStateG)) >>> 0;
|
||||
const temp1 =
|
||||
(workingStateH +
|
||||
capitalSigma1 +
|
||||
chFunction +
|
||||
SHA256_ROUND_CONSTANTS[i] +
|
||||
messageSchedule[i]) >>>
|
||||
0;
|
||||
|
||||
const capitalSigma0 =
|
||||
(((workingStateA >>> 2) | (workingStateA << 30)) ^
|
||||
((workingStateA >>> 13) | (workingStateA << 19)) ^
|
||||
((workingStateA >>> 22) | (workingStateA << 10))) >>>
|
||||
0;
|
||||
const majFunction =
|
||||
((workingStateA & workingStateB) ^
|
||||
(workingStateA & workingStateC) ^
|
||||
(workingStateB & workingStateC)) >>>
|
||||
0;
|
||||
const temp2 = (capitalSigma0 + majFunction) >>> 0;
|
||||
|
||||
workingStateH = workingStateG;
|
||||
workingStateG = workingStateF;
|
||||
workingStateF = workingStateE;
|
||||
workingStateE = (workingStateD + temp1) >>> 0;
|
||||
workingStateD = workingStateC;
|
||||
workingStateC = workingStateB;
|
||||
workingStateB = workingStateA;
|
||||
workingStateA = (temp1 + temp2) >>> 0;
|
||||
}
|
||||
|
||||
// Update intermediate hash state:
|
||||
hashState0 = (hashState0 + workingStateA) >>> 0;
|
||||
hashState1 = (hashState1 + workingStateB) >>> 0;
|
||||
hashState2 = (hashState2 + workingStateC) >>> 0;
|
||||
hashState3 = (hashState3 + workingStateD) >>> 0;
|
||||
hashState4 = (hashState4 + workingStateE) >>> 0;
|
||||
hashState5 = (hashState5 + workingStateF) >>> 0;
|
||||
hashState6 = (hashState6 + workingStateG) >>> 0;
|
||||
hashState7 = (hashState7 + workingStateH) >>> 0;
|
||||
}
|
||||
|
||||
// Produce the final 64-character hexadecimal hash:
|
||||
return [
|
||||
hashState0,
|
||||
hashState1,
|
||||
hashState2,
|
||||
hashState3,
|
||||
hashState4,
|
||||
hashState5,
|
||||
hashState6,
|
||||
hashState7,
|
||||
]
|
||||
.map((x) => x.toString(16).padStart(8, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
} from '@angular/core';
|
||||
import {Observable} from 'rxjs';
|
||||
|
||||
import {HttpHandler} from './backend';
|
||||
import {HttpHandlerFn, HttpInterceptor} from './interceptor';
|
||||
import type {HttpHandler} from './backend';
|
||||
import type {HttpHandlerFn, HttpInterceptor} from './interceptor';
|
||||
import {HttpRequest} from './request';
|
||||
import {HttpEvent} from './response';
|
||||
|
||||
|
||||
@@ -154,6 +154,34 @@ describe('HttpHeaders', () => {
|
||||
const fourth = third.delete('FOO');
|
||||
expect(fourth.has('foo')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should delete only the exact matching string value', () => {
|
||||
const headers = new HttpHeaders({
|
||||
'X-Scopes': ['tenant:alpha', 'tenant:alpha:archive'],
|
||||
});
|
||||
|
||||
const updated = headers.delete('X-Scopes', 'tenant:alpha:archive');
|
||||
|
||||
expect(updated.getAll('X-Scopes')).toEqual(['tenant:alpha']);
|
||||
});
|
||||
|
||||
it('should delete only exact matching values from an array', () => {
|
||||
const headers = new HttpHeaders({
|
||||
'X-Scopes': ['tenant:alpha', 'tenant:alpha:archive'],
|
||||
});
|
||||
|
||||
const updated = headers.delete('X-Scopes', ['tenant:alpha:archive']);
|
||||
|
||||
expect(updated.getAll('X-Scopes')).toEqual(['tenant:alpha']);
|
||||
});
|
||||
|
||||
it('should treat an empty string as a value to delete', () => {
|
||||
const headers = new HttpHeaders({foo: ['', 'bar']});
|
||||
|
||||
const updated = headers.delete('foo', '');
|
||||
|
||||
expect(updated.getAll('foo')).toEqual(['bar']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('.append', () => {
|
||||
@@ -165,6 +193,15 @@ describe('HttpHeaders', () => {
|
||||
expect(third.getAll('foo')).toEqual(['bar', 'baz']);
|
||||
});
|
||||
|
||||
it('should not mutate a materialized source', () => {
|
||||
const source = new HttpHeaders().set('foo', 'bar');
|
||||
expect(source.getAll('foo')).toEqual(['bar']);
|
||||
|
||||
const clone = source.append('foo', 'baz');
|
||||
expect(clone.getAll('foo')).toEqual(['bar', 'baz']);
|
||||
expect(source.getAll('foo')).toEqual(['bar']);
|
||||
});
|
||||
|
||||
it('should preserve the case of the first call', () => {
|
||||
const headers = new HttpHeaders();
|
||||
const second = headers.append('FOO', 'bar');
|
||||
|
||||
@@ -12,6 +12,7 @@ import {HttpHeaders} from '../src/headers';
|
||||
import {
|
||||
JSONP_ERR_HEADERS_NOT_SUPPORTED,
|
||||
JSONP_ERR_NO_CALLBACK,
|
||||
JSONP_ERR_UNSAFE_URL,
|
||||
JSONP_ERR_WRONG_METHOD,
|
||||
JSONP_ERR_WRONG_RESPONSE_TYPE,
|
||||
JsonpCallbackContext,
|
||||
@@ -25,7 +26,7 @@ import {toArray} from 'rxjs/operators';
|
||||
import {MockDocument} from './jsonp_mock';
|
||||
|
||||
describe('JsonpClientBackend', () => {
|
||||
const SAMPLE_REQ = new HttpRequest<never>('JSONP', '/test');
|
||||
const SAMPLE_REQ = new HttpRequest<never>('JSONP', 'https://example.com/test');
|
||||
let home: any;
|
||||
let document: MockDocument;
|
||||
let backend: JsonpClientBackend;
|
||||
@@ -127,6 +128,44 @@ describe('JsonpClientBackend', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL protocols', () => {
|
||||
it('allows absolute HTTP(S) URLs', () => {
|
||||
const urls = [
|
||||
'http://example.com/test',
|
||||
'https://example.com/test',
|
||||
'HTTP://example.com/test',
|
||||
];
|
||||
|
||||
for (const url of urls) {
|
||||
const subscription = backend.handle(SAMPLE_REQ.clone<never>({url})).subscribe();
|
||||
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects URLs without absolute HTTP(S) protocols before creating a script element', () => {
|
||||
const urls = [
|
||||
'//example.com/test',
|
||||
'/test',
|
||||
'test',
|
||||
'data:text/javascript,alert(1)',
|
||||
'blob:https://example.com/jsonp',
|
||||
'javascript:alert(1)',
|
||||
'file:///tmp/jsonp.js',
|
||||
'filesystem:https://example.com/temporary/jsonp.js',
|
||||
'ftp://example.com/jsonp.js',
|
||||
'custom-scheme://example.com/jsonp.js',
|
||||
];
|
||||
|
||||
for (const url of urls) {
|
||||
expect(() => backend.handle(SAMPLE_REQ.clone<never>({url}))).toThrowError(
|
||||
`NG02826: ${JSONP_ERR_UNSAFE_URL}`,
|
||||
);
|
||||
expect(document.mock).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('throws an error', () => {
|
||||
it('when request method is not JSONP', () =>
|
||||
expect(() => backend.handle(SAMPLE_REQ.clone<never>({method: 'GET'}))).toThrowError(
|
||||
|
||||
@@ -72,6 +72,15 @@ describe('HttpUrlEncodedParams', () => {
|
||||
expect(mutated.toString()).toEqual('a=b&a=true');
|
||||
});
|
||||
|
||||
it('should not mutate a materialized source when appending', () => {
|
||||
const source = new HttpParams().append('a', 'b');
|
||||
expect(source.getAll('a')).toEqual(['b']);
|
||||
|
||||
const clone = source.append('a', 'c');
|
||||
expect(clone.getAll('a')).toEqual(['b', 'c']);
|
||||
expect(source.getAll('a')).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('should allow appending all string parameters', () => {
|
||||
const body = new HttpParams({fromString: 'a=a1&b=b1'});
|
||||
const mutated = body.appendAll({a: ['a2', 'a3'], b: 'b2'});
|
||||
@@ -138,6 +147,15 @@ describe('HttpUrlEncodedParams', () => {
|
||||
expect(mutated.getAll('a')).toEqual(['1', '3', '5']);
|
||||
});
|
||||
|
||||
it('should not mutate a materialized source when deleting a value', () => {
|
||||
const source = new HttpParams().append('a', '1').append('a', '2');
|
||||
expect(source.getAll('a')).toEqual(['1', '2']);
|
||||
|
||||
const clone = source.delete('a', '1');
|
||||
expect(clone.getAll('a')).toEqual(['2']);
|
||||
expect(source.getAll('a')).toEqual(['1', '2']);
|
||||
});
|
||||
|
||||
it('should allow deletion of one value of a number parameter', () => {
|
||||
const body = new HttpParams({fromString: 'a=0&a=1&a=2&a=3&a=4&a=5'});
|
||||
const mutated = body.delete('a', 0).delete('a', 4);
|
||||
|
||||
@@ -34,8 +34,9 @@ import {
|
||||
} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {EMPTY, Observable, from} from 'rxjs';
|
||||
import {tap} from 'rxjs/operators';
|
||||
|
||||
import {HttpInterceptorFn} from '../src/interceptor';
|
||||
import {HTTP_ROOT_INTERCEPTOR_FNS, HttpInterceptorFn} from '../src/interceptor';
|
||||
import {
|
||||
HttpFeature,
|
||||
HttpFeatureKind,
|
||||
@@ -264,6 +265,19 @@ describe('provideHttpClient', () => {
|
||||
});
|
||||
|
||||
describe('xsrf protection', () => {
|
||||
it('should enable xsrf protection for the root-provided HttpClient', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClientTesting(), {provide: PLATFORM_ID, useValue: 'test'}],
|
||||
});
|
||||
|
||||
setXsrfToken('abcdefg');
|
||||
|
||||
TestBed.inject(HttpClient).post('/test', '', {responseType: 'text'}).subscribe();
|
||||
const req = TestBed.inject(HttpTestingController).expectOne('/test');
|
||||
expect(req.request.headers.get('X-XSRF-TOKEN')).toEqual('abcdefg');
|
||||
req.flush('');
|
||||
});
|
||||
|
||||
it('should enable xsrf protection by default', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -314,7 +328,7 @@ describe('provideHttpClient', () => {
|
||||
|
||||
TestBed.inject(HttpClient).post('/test', '', {responseType: 'text'}).subscribe();
|
||||
const req = TestBed.inject(HttpTestingController).expectOne('/test');
|
||||
expect(req.request.headers.has('X-Custom-Xsrf-Header')).toBeFalse();
|
||||
expect(req.request.headers.has('X-XSRF-TOKEN')).toBeFalse();
|
||||
req.flush('');
|
||||
});
|
||||
|
||||
@@ -359,6 +373,12 @@ describe('provideHttpClient', () => {
|
||||
});
|
||||
|
||||
describe('withRequestsMadeViaParent()', () => {
|
||||
it('should error when combined with a backend override', () => {
|
||||
expect(() => provideHttpClient(withRequestsMadeViaParent(), withFetch())).toThrowError(
|
||||
'Configuration error: withRequestsMadeViaParent() cannot be combined with withFetch() in the same call to provideHttpClient().',
|
||||
);
|
||||
});
|
||||
|
||||
for (const backend of ['fetch', 'xhr']) {
|
||||
describe(`given '${backend}' backend`, () => {
|
||||
const commonHttpFeatures: HttpFeature<HttpFeatureKind>[] = [];
|
||||
@@ -436,18 +456,59 @@ describe('provideHttpClient', () => {
|
||||
req.flush('');
|
||||
});
|
||||
|
||||
it('should be able to connect to a legacy-provided HttpClient context', () => {
|
||||
it('should include root interceptors in independent child contexts', () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [provideLegacyInterceptor('parent')],
|
||||
providers: [
|
||||
provideHttpClient(...commonHttpFeatures),
|
||||
{
|
||||
provide: HTTP_ROOT_INTERCEPTOR_FNS,
|
||||
useValue: makeLiteralTagInterceptorFn('root'),
|
||||
multi: true,
|
||||
},
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
});
|
||||
|
||||
const child = createEnvironmentInjector(
|
||||
[
|
||||
provideHttpClient(withInterceptors([makeLiteralTagInterceptorFn('child')])),
|
||||
{
|
||||
provide: HttpBackend,
|
||||
useFactory: () => inject(HttpBackend, {skipSelf: true}),
|
||||
},
|
||||
],
|
||||
TestBed.inject(EnvironmentInjector),
|
||||
);
|
||||
|
||||
child.get(HttpClient).get('/test', {responseType: 'text'}).subscribe();
|
||||
const req = TestBed.inject(HttpTestingController).expectOne('/test');
|
||||
expect(req.request.headers.get('X-Tag')).toEqual('child,root');
|
||||
req.flush('');
|
||||
});
|
||||
|
||||
it('should run root interceptors once in the parent request and response chain', () => {
|
||||
const order: string[] = [];
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(
|
||||
...commonHttpFeatures,
|
||||
withInterceptors([makeOrderedTagInterceptorFn('parent', order)]),
|
||||
),
|
||||
{
|
||||
provide: HTTP_ROOT_INTERCEPTOR_FNS,
|
||||
useValue: makeOrderedTagInterceptorFn('root', order),
|
||||
multi: true,
|
||||
},
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
});
|
||||
|
||||
const child = createEnvironmentInjector(
|
||||
[
|
||||
provideHttpClient(
|
||||
...commonHttpFeatures,
|
||||
withRequestsMadeViaParent(),
|
||||
withInterceptors([makeLiteralTagInterceptorFn('child')]),
|
||||
withInterceptors([makeOrderedTagInterceptorFn('child', order)]),
|
||||
),
|
||||
],
|
||||
TestBed.inject(EnvironmentInjector),
|
||||
@@ -455,11 +516,112 @@ describe('provideHttpClient', () => {
|
||||
|
||||
child.get(HttpClient).get('/test', {responseType: 'text'}).subscribe();
|
||||
const req = TestBed.inject(HttpTestingController).expectOne('/test');
|
||||
expect(req.request.headers.get('X-Tag')).toEqual('child,parent');
|
||||
expect(req.request.headers.get('X-Tag')).toEqual('child,parent,root');
|
||||
expect(order).toEqual(['child:request', 'parent:request', 'root:request']);
|
||||
req.flush('');
|
||||
expect(order).toEqual([
|
||||
'child:request',
|
||||
'parent:request',
|
||||
'root:request',
|
||||
'root:response',
|
||||
'parent:response',
|
||||
'child:response',
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('should be able to connect to a legacy-provided HttpClient context', () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [provideLegacyInterceptor('parent')],
|
||||
});
|
||||
|
||||
const child = createEnvironmentInjector(
|
||||
[
|
||||
provideHttpClient(
|
||||
withRequestsMadeViaParent(),
|
||||
withInterceptors([makeLiteralTagInterceptorFn('child')]),
|
||||
),
|
||||
],
|
||||
TestBed.inject(EnvironmentInjector),
|
||||
);
|
||||
|
||||
child.get(HttpClient).get('/test', {responseType: 'text'}).subscribe();
|
||||
const req = TestBed.inject(HttpTestingController).expectOne('/test');
|
||||
expect(req.request.headers.get('X-Tag')).toEqual('child,parent');
|
||||
req.flush('');
|
||||
});
|
||||
|
||||
it('should inherit root interceptors in independent child contexts', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
{
|
||||
provide: HTTP_ROOT_INTERCEPTOR_FNS,
|
||||
useValue: makeLiteralTagInterceptorFn('root'),
|
||||
multi: true,
|
||||
},
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
});
|
||||
|
||||
const delegatingParent = createEnvironmentInjector(
|
||||
[provideHttpClient(withRequestsMadeViaParent())],
|
||||
TestBed.inject(EnvironmentInjector),
|
||||
);
|
||||
const independentChild = createEnvironmentInjector(
|
||||
[
|
||||
provideHttpClient(withInterceptors([makeLiteralTagInterceptorFn('child')])),
|
||||
{provide: HttpBackend, useValue: TestBed.inject(HttpBackend)},
|
||||
],
|
||||
delegatingParent,
|
||||
);
|
||||
|
||||
try {
|
||||
independentChild.get(HttpClient).get('/test', {responseType: 'text'}).subscribe();
|
||||
const req = TestBed.inject(HttpTestingController).expectOne('/test');
|
||||
expect(req.request.headers.get('X-Tag')).toEqual('child,root');
|
||||
req.flush('');
|
||||
} finally {
|
||||
independentChild.destroy();
|
||||
delegatingParent.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('should inherit root interceptors when a custom provider overrides delegation', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
{
|
||||
provide: HTTP_ROOT_INTERCEPTOR_FNS,
|
||||
useValue: makeLiteralTagInterceptorFn('root'),
|
||||
multi: true,
|
||||
},
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
});
|
||||
|
||||
const child = createEnvironmentInjector(
|
||||
[
|
||||
provideHttpClient(
|
||||
withRequestsMadeViaParent(),
|
||||
withInterceptors([makeLiteralTagInterceptorFn('child')]),
|
||||
),
|
||||
{provide: HttpBackend, useValue: TestBed.inject(HttpBackend)},
|
||||
],
|
||||
TestBed.inject(EnvironmentInjector),
|
||||
);
|
||||
|
||||
try {
|
||||
child.get(HttpClient).get('/test', {responseType: 'text'}).subscribe();
|
||||
const req = TestBed.inject(HttpTestingController).expectOne('/test');
|
||||
expect(req.request.headers.get('X-Tag')).toEqual('child,root');
|
||||
req.flush('');
|
||||
} finally {
|
||||
child.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('compatibility with Http NgModules', () => {
|
||||
@@ -641,6 +803,19 @@ function makeLiteralTagInterceptorFn(tag: string): HttpInterceptorFn {
|
||||
return (req, next) => next(addTagToRequest(req, tag));
|
||||
}
|
||||
|
||||
function makeOrderedTagInterceptorFn(tag: string, order: string[]): HttpInterceptorFn {
|
||||
return (req, next) => {
|
||||
order.push(`${tag}:request`);
|
||||
return next(addTagToRequest(req, tag)).pipe(
|
||||
tap((event) => {
|
||||
if (event instanceof HttpResponse) {
|
||||
order.push(`${tag}:response`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function makeTokenTagInterceptorFn(tag: InjectionToken<string>): HttpInterceptorFn {
|
||||
return (req, next) => next(addTagToRequest(req, inject(tag)));
|
||||
}
|
||||
|
||||
@@ -95,6 +95,10 @@ describe('HttpRequest', () => {
|
||||
const req = new HttpRequest('GET', '/test', {referrer: 'about:client'});
|
||||
expect(req.referrer).toBe('about:client');
|
||||
});
|
||||
it('should preserve an empty string referrer (used to suppress the Referer header)', () => {
|
||||
const req = new HttpRequest('GET', '/test', {referrer: ''});
|
||||
expect(req.referrer).toBe('');
|
||||
});
|
||||
it('should allow setting referrerPolicy option', () => {
|
||||
const req = new HttpRequest('GET', '/test', {referrerPolicy: 'no-referrer'});
|
||||
expect(req.referrerPolicy).toBe('no-referrer');
|
||||
@@ -175,6 +179,13 @@ describe('HttpRequest', () => {
|
||||
it('and updates the referrer', () => {
|
||||
expect(req.clone({referrer: 'https://example.com'}).referrer).toBe('https://example.com');
|
||||
});
|
||||
it('and preserves an existing empty string referrer when the update omits it', () => {
|
||||
const emptyReferrerReq = new HttpRequest('GET', '/test', {referrer: ''});
|
||||
expect(emptyReferrerReq.clone().referrer).toBe('');
|
||||
});
|
||||
it('and can update the referrer to an empty string', () => {
|
||||
expect(req.clone({referrer: ''}).referrer).toBe('');
|
||||
});
|
||||
it('and updates the integrity', () => {
|
||||
expect(req.clone({integrity: 'sha512-...'}).integrity).toBe('sha512-...');
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import {DOCUMENT} from '../../index';
|
||||
import {
|
||||
ApplicationRef,
|
||||
Component,
|
||||
createEnvironmentInjector,
|
||||
EnvironmentInjector,
|
||||
Injectable,
|
||||
PLATFORM_ID,
|
||||
TransferState,
|
||||
@@ -17,18 +19,30 @@ import {
|
||||
} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {useAutoTick, timeout, withBody} from '@angular/private/testing';
|
||||
import {BehaviorSubject} from 'rxjs';
|
||||
import {BehaviorSubject, Observable, of} from 'rxjs';
|
||||
|
||||
import {HttpClient, HttpResponse, provideHttpClient} from '../public_api';
|
||||
import {
|
||||
HttpClient,
|
||||
HttpHeaders,
|
||||
HttpParams,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
provideHttpClient,
|
||||
withInterceptors,
|
||||
withRequestsMadeViaParent,
|
||||
} from '../public_api';
|
||||
import {
|
||||
BODY,
|
||||
CACHE_OPTIONS,
|
||||
HEADERS,
|
||||
HTTP_TRANSFER_CACHE_ORIGIN_MAP,
|
||||
RESPONSE_TYPE,
|
||||
STATUS,
|
||||
STATUS_TEXT,
|
||||
REQ_URL,
|
||||
transferCacheInterceptorFn,
|
||||
withHttpTransferCache,
|
||||
generateHash,
|
||||
} from '../src/transfer_cache';
|
||||
import {HttpTestingController, provideHttpClientTesting} from '../testing';
|
||||
import {PLATFORM_BROWSER_ID, PLATFORM_SERVER_ID} from '../../src/platform_id';
|
||||
@@ -38,6 +52,11 @@ interface RequestParams {
|
||||
observe?: 'body' | 'response';
|
||||
transferCache?: {includeHeaders: string[]} | boolean;
|
||||
headers?: {[key: string]: string};
|
||||
/** Separate response headers for flush(); falls back to headers if not set */
|
||||
responseHeaders?: {[key: string]: string};
|
||||
withCredentials?: boolean;
|
||||
credentials?: RequestCredentials;
|
||||
cache?: RequestCache;
|
||||
body?: RequestBody;
|
||||
}
|
||||
|
||||
@@ -60,6 +79,344 @@ describe('TransferCache', () => {
|
||||
})
|
||||
class SomeComponent {}
|
||||
|
||||
describe('transferCacheInterceptorFn', () => {
|
||||
afterEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
function configureInterceptor(options: {includeRequestsWithAuthHeaders?: boolean} = {}): void {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TransferState,
|
||||
{
|
||||
provide: CACHE_OPTIONS,
|
||||
useValue: {
|
||||
isCacheActive: true,
|
||||
...options,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function runOnServer<T>(callback: () => T): T {
|
||||
const previousServerMode = globalThis['ngServerMode'];
|
||||
globalThis['ngServerMode'] = true;
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
globalThis['ngServerMode'] = previousServerMode;
|
||||
}
|
||||
}
|
||||
|
||||
function runInterceptor(
|
||||
req: HttpRequest<unknown>,
|
||||
next: (req: HttpRequest<unknown>) => Observable<HttpResponse<unknown>>,
|
||||
): HttpResponse<unknown> {
|
||||
let response!: HttpResponse<unknown>;
|
||||
TestBed.runInInjectionContext(() => {
|
||||
transferCacheInterceptorFn(req, next).subscribe((event) => {
|
||||
if (event instanceof HttpResponse) {
|
||||
response = event;
|
||||
}
|
||||
});
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
it('should not reuse cached responses for Cookie-bearing requests by default', () => {
|
||||
configureInterceptor();
|
||||
|
||||
const firstRequest = new HttpRequest('GET', '/test-cookie', null, {
|
||||
headers: new HttpHeaders({Cookie: 'session=user-a'}),
|
||||
});
|
||||
const secondRequest = new HttpRequest('GET', '/test-cookie', null, {
|
||||
headers: new HttpHeaders({Cookie: 'session=user-b'}),
|
||||
});
|
||||
|
||||
const firstNext = jasmine
|
||||
.createSpy('firstNext')
|
||||
.and.returnValue(of(new HttpResponse({body: 'user-a-secret'})));
|
||||
const secondNext = jasmine
|
||||
.createSpy('secondNext')
|
||||
.and.returnValue(of(new HttpResponse({body: 'user-b-secret'})));
|
||||
|
||||
runOnServer(() => {
|
||||
expect(runInterceptor(firstRequest, firstNext).body).toBe('user-a-secret');
|
||||
expect(runInterceptor(secondRequest, secondNext).body).toBe('user-b-secret');
|
||||
});
|
||||
|
||||
expect(firstNext).toHaveBeenCalledTimes(1);
|
||||
expect(secondNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should preserve opt-in caching for Cookie-bearing requests when 'includeRequestsWithAuthHeaders' is true", () => {
|
||||
configureInterceptor({includeRequestsWithAuthHeaders: true});
|
||||
|
||||
const request = new HttpRequest('GET', '/test-cookie', null, {
|
||||
headers: new HttpHeaders({Cookie: 'session=user-a'}),
|
||||
});
|
||||
|
||||
const firstNext = jasmine
|
||||
.createSpy('firstNext')
|
||||
.and.returnValue(of(new HttpResponse({body: 'user-a-secret'})));
|
||||
const secondNext = jasmine
|
||||
.createSpy('secondNext')
|
||||
.and.returnValue(of(new HttpResponse({body: 'network-should-not-run'})));
|
||||
|
||||
runOnServer(() => {
|
||||
expect(runInterceptor(request, firstNext).body).toBe('user-a-secret');
|
||||
expect(runInterceptor(request, secondNext).body).toBe('user-a-secret');
|
||||
});
|
||||
|
||||
expect(firstNext).toHaveBeenCalledTimes(1);
|
||||
expect(secondNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not cache responses with Cache-Control: no-store', () => {
|
||||
configureInterceptor();
|
||||
|
||||
const request = new HttpRequest('GET', '/test-no-store');
|
||||
|
||||
const firstNext = jasmine.createSpy('firstNext').and.returnValue(
|
||||
of(
|
||||
new HttpResponse({
|
||||
body: 'sensitive-data',
|
||||
headers: new HttpHeaders({'Cache-Control': 'no-store'}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const secondNext = jasmine
|
||||
.createSpy('secondNext')
|
||||
.and.returnValue(of(new HttpResponse({body: 'fresh-data'})));
|
||||
|
||||
runOnServer(() => {
|
||||
expect(runInterceptor(request, firstNext).body).toBe('sensitive-data');
|
||||
expect(runInterceptor(request, secondNext).body).toBe('fresh-data');
|
||||
});
|
||||
|
||||
expect(firstNext).toHaveBeenCalledTimes(1);
|
||||
expect(secondNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not cache responses with Cache-Control: private', () => {
|
||||
configureInterceptor();
|
||||
|
||||
const request = new HttpRequest('GET', '/test-private');
|
||||
|
||||
const firstNext = jasmine.createSpy('firstNext').and.returnValue(
|
||||
of(
|
||||
new HttpResponse({
|
||||
body: 'user-data',
|
||||
headers: new HttpHeaders({'Cache-Control': 'private'}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const secondNext = jasmine
|
||||
.createSpy('secondNext')
|
||||
.and.returnValue(of(new HttpResponse({body: 'public-data'})));
|
||||
|
||||
runOnServer(() => {
|
||||
expect(runInterceptor(request, firstNext).body).toBe('user-data');
|
||||
expect(runInterceptor(request, secondNext).body).toBe('public-data');
|
||||
});
|
||||
|
||||
expect(firstNext).toHaveBeenCalledTimes(1);
|
||||
expect(secondNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not cache responses with a Set-Cookie header', () => {
|
||||
configureInterceptor();
|
||||
|
||||
const request = new HttpRequest('GET', '/test-set-cookie');
|
||||
|
||||
const firstNext = jasmine.createSpy('firstNext').and.returnValue(
|
||||
of(
|
||||
new HttpResponse({
|
||||
body: 'user-a-session',
|
||||
headers: new HttpHeaders({'Set-Cookie': 'session=user-a; HttpOnly'}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const secondNext = jasmine
|
||||
.createSpy('secondNext')
|
||||
.and.returnValue(of(new HttpResponse({body: 'user-b-session'})));
|
||||
|
||||
runOnServer(() => {
|
||||
expect(runInterceptor(request, firstNext).body).toBe('user-a-session');
|
||||
expect(runInterceptor(request, secondNext).body).toBe('user-b-session');
|
||||
});
|
||||
|
||||
expect(firstNext).toHaveBeenCalledTimes(1);
|
||||
expect(secondNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not cache requests with Cache-Control: no-store', () => {
|
||||
configureInterceptor();
|
||||
|
||||
const request = new HttpRequest('GET', '/test-req-no-store', null, {
|
||||
headers: new HttpHeaders({'Cache-Control': 'no-store'}),
|
||||
});
|
||||
|
||||
const firstNext = jasmine
|
||||
.createSpy('firstNext')
|
||||
.and.returnValue(of(new HttpResponse({body: 'data'})));
|
||||
const secondNext = jasmine
|
||||
.createSpy('secondNext')
|
||||
.and.returnValue(of(new HttpResponse({body: 'fresh-data'})));
|
||||
|
||||
runOnServer(() => {
|
||||
expect(runInterceptor(request, firstNext).body).toBe('data');
|
||||
expect(runInterceptor(request, secondNext).body).toBe('fresh-data');
|
||||
});
|
||||
|
||||
expect(firstNext).toHaveBeenCalledTimes(1);
|
||||
expect(secondNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not cache requests with Cache-Control: no-cache', () => {
|
||||
configureInterceptor();
|
||||
|
||||
const request = new HttpRequest('GET', '/test-req-no-cache', null, {
|
||||
headers: new HttpHeaders({'Cache-Control': 'no-cache'}),
|
||||
});
|
||||
|
||||
const firstNext = jasmine
|
||||
.createSpy('firstNext')
|
||||
.and.returnValue(of(new HttpResponse({body: 'data'})));
|
||||
const secondNext = jasmine
|
||||
.createSpy('secondNext')
|
||||
.and.returnValue(of(new HttpResponse({body: 'fresh-data'})));
|
||||
|
||||
runOnServer(() => {
|
||||
expect(runInterceptor(request, firstNext).body).toBe('data');
|
||||
expect(runInterceptor(request, secondNext).body).toBe('fresh-data');
|
||||
});
|
||||
|
||||
expect(firstNext).toHaveBeenCalledTimes(1);
|
||||
expect(secondNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withRequestsMadeViaParent()', () => {
|
||||
let childInjector: EnvironmentInjector;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis['ngServerMode'] = true;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TransferState,
|
||||
withHttpTransferCache({
|
||||
filter: (request) => !request.headers.has('X-API-Key'),
|
||||
}),
|
||||
provideHttpClient(
|
||||
withInterceptors([
|
||||
(request, next) => {
|
||||
if (request.url === '/private') {
|
||||
request = request.clone({
|
||||
setHeaders: {Authorization: 'Bearer server-token'},
|
||||
});
|
||||
} else if (request.url === '/secret') {
|
||||
request = request.clone({setHeaders: {'X-API-Key': 'secret-a'}});
|
||||
} else if (request.url === '/public-alias') {
|
||||
request = request.clone({url: '/public'});
|
||||
}
|
||||
|
||||
return next(request);
|
||||
},
|
||||
]),
|
||||
),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
});
|
||||
|
||||
childInjector = createEnvironmentInjector(
|
||||
[
|
||||
provideHttpClient(
|
||||
withInterceptors([
|
||||
(request, next) =>
|
||||
next(request.clone({setHeaders: {'X-Trace-Id': 'feature-request'}})),
|
||||
]),
|
||||
withRequestsMadeViaParent(),
|
||||
),
|
||||
],
|
||||
TestBed.inject(EnvironmentInjector),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
childInjector.destroy();
|
||||
TestBed.inject(HttpTestingController).verify();
|
||||
globalThis['ngServerMode'] = undefined;
|
||||
});
|
||||
|
||||
it('should evaluate cache eligibility and keys after parent interceptors', () => {
|
||||
const httpClient = childInjector.get(HttpClient);
|
||||
const httpTestingController = TestBed.inject(HttpTestingController);
|
||||
const transferState = TestBed.inject(TransferState);
|
||||
|
||||
let privateResponse: unknown;
|
||||
httpClient.get('/private').subscribe((response) => (privateResponse = response));
|
||||
const privateRequest = httpTestingController.expectOne('/private');
|
||||
expect(privateRequest.request.headers.get('Authorization')).toBe('Bearer server-token');
|
||||
expect(privateRequest.request.headers.get('X-Trace-Id')).toBe('feature-request');
|
||||
privateRequest.flush({internalSecret: 'server-only'});
|
||||
|
||||
expect(privateResponse).toEqual({internalSecret: 'server-only'});
|
||||
expect(JSON.parse(transferState.toJson())).toEqual({});
|
||||
|
||||
let publicResponse: unknown;
|
||||
httpClient.get('/public-alias').subscribe((response) => (publicResponse = response));
|
||||
httpTestingController.expectOne('/public').flush({message: 'public'});
|
||||
|
||||
publicResponse = undefined;
|
||||
httpClient.get('/public-alias').subscribe((response) => (publicResponse = response));
|
||||
httpTestingController.expectNone('/public');
|
||||
expect(publicResponse).toEqual({message: 'public'});
|
||||
});
|
||||
|
||||
it('should evaluate cache filters after parent interceptors', () => {
|
||||
const httpClient = childInjector.get(HttpClient);
|
||||
const httpTestingController = TestBed.inject(HttpTestingController);
|
||||
const transferState = TestBed.inject(TransferState);
|
||||
|
||||
let response: unknown;
|
||||
httpClient.get('/secret').subscribe((value) => (response = value));
|
||||
const request = httpTestingController.expectOne('/secret');
|
||||
expect(request.request.headers.get('X-API-KEY')).toBe('secret-a');
|
||||
request.flush({internalSecret: 'secret-only'});
|
||||
|
||||
expect(response).toEqual({internalSecret: 'secret-only'});
|
||||
expect(JSON.parse(transferState.toJson())).toEqual({});
|
||||
});
|
||||
|
||||
it('should evaluate cache eligibility through multiple parent clients', () => {
|
||||
const grandchildInjector = createEnvironmentInjector(
|
||||
[provideHttpClient(withRequestsMadeViaParent())],
|
||||
childInjector,
|
||||
);
|
||||
|
||||
try {
|
||||
const httpClient = grandchildInjector.get(HttpClient);
|
||||
const httpTestingController = TestBed.inject(HttpTestingController);
|
||||
const transferState = TestBed.inject(TransferState);
|
||||
|
||||
let privateResponse: unknown;
|
||||
httpClient.get('/private').subscribe((response) => (privateResponse = response));
|
||||
const privateRequest = httpTestingController.expectOne('/private');
|
||||
expect(privateRequest.request.headers.get('Authorization')).toBe('Bearer server-token');
|
||||
privateRequest.flush({internalSecret: 'server-only'});
|
||||
|
||||
expect(privateResponse).toEqual({internalSecret: 'server-only'});
|
||||
expect(JSON.parse(transferState.toJson())).toEqual({});
|
||||
} finally {
|
||||
grandchildInjector.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('withHttpTransferCache', () => {
|
||||
let isStable: BehaviorSubject<boolean>;
|
||||
|
||||
@@ -78,7 +435,9 @@ describe('TransferCache', () => {
|
||||
TestBed.inject(HttpClient)
|
||||
.request(params?.method ?? 'GET', url, params)
|
||||
.subscribe((r) => (response = r));
|
||||
TestBed.inject(HttpTestingController).expectOne(url).flush(body, {headers: params?.headers});
|
||||
TestBed.inject(HttpTestingController)
|
||||
.expectOne(url)
|
||||
.flush(body, {headers: params?.responseHeaders ?? params?.headers});
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -182,7 +541,7 @@ describe('TransferCache', () => {
|
||||
|
||||
const transferState = TestBed.inject(TransferState);
|
||||
expect(JSON.parse(transferState.toJson()) as Record<string, unknown>).toEqual({
|
||||
'2400571479': {
|
||||
'2da5dfaf112523258ec9c26a0abe9a093b59ed7dbe5f43e4b5ee25a407ac9cf0': {
|
||||
[BODY]: 'foo',
|
||||
[HEADERS]: {},
|
||||
[STATUS]: 200,
|
||||
@@ -190,7 +549,7 @@ describe('TransferCache', () => {
|
||||
[REQ_URL]: '/test-1',
|
||||
[RESPONSE_TYPE]: 'json',
|
||||
},
|
||||
'2400572440': {
|
||||
'869485290d9385f3c0a9ba571918c335bbca9e03373bf8260d02f2b7dd335849': {
|
||||
[BODY]: 'buzz',
|
||||
[HEADERS]: {},
|
||||
[STATUS]: 200,
|
||||
@@ -228,6 +587,38 @@ describe('TransferCache', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should differentiate repeated parameters from scalar comma parameters', () => {
|
||||
let scalarResponse!: string;
|
||||
TestBed.inject(HttpClient)
|
||||
.get('/test-params', {params: new HttpParams().set('role', 'user,admin')})
|
||||
.subscribe((response) => (scalarResponse = response as string));
|
||||
TestBed.inject(HttpTestingController)
|
||||
.expectOne('/test-params?role=user,admin')
|
||||
.flush('scalar');
|
||||
|
||||
let repeatedResponse!: string;
|
||||
TestBed.inject(HttpClient)
|
||||
.get('/test-params', {
|
||||
params: new HttpParams().append('role', 'user').append('role', 'admin'),
|
||||
})
|
||||
.subscribe((response) => (repeatedResponse = response as string));
|
||||
TestBed.inject(HttpTestingController)
|
||||
.expectOne('/test-params?role=user&role=admin')
|
||||
.flush('repeated');
|
||||
|
||||
let repeatedCachedResponse!: string;
|
||||
TestBed.inject(HttpClient)
|
||||
.get('/test-params', {
|
||||
params: new HttpParams().append('role', 'user').append('role', 'admin'),
|
||||
})
|
||||
.subscribe((response) => (repeatedCachedResponse = response as string));
|
||||
TestBed.inject(HttpTestingController).expectNone('/test-params?role=user&role=admin');
|
||||
|
||||
expect(scalarResponse).toBe('scalar');
|
||||
expect(repeatedResponse).toBe('repeated');
|
||||
expect(repeatedCachedResponse).toBe('repeated');
|
||||
});
|
||||
|
||||
it('should skip cache when specified', () => {
|
||||
makeRequestAndExpectOne('/test-1?foo=1', 'foo', {transferCache: false});
|
||||
// The previous request wasn't cached so this one can't use the cache
|
||||
@@ -331,6 +722,164 @@ describe('TransferCache', () => {
|
||||
makeRequestAndExpectOne('/test-auth', 'foo');
|
||||
});
|
||||
|
||||
it('should not cache requests with credentials', async () => {
|
||||
makeRequestAndExpectOne('/test-auth', 'foo', {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
makeRequestAndExpectOne('/test-auth', 'foo', {
|
||||
withCredentials: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not cache requests with included credentials', async () => {
|
||||
makeRequestAndExpectOne('/test-auth', 'foo', {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
makeRequestAndExpectOne('/test-auth', 'foo', {
|
||||
credentials: 'include',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not cache requests with same-origin credentials', async () => {
|
||||
makeRequestAndExpectOne('/test-auth', 'foo', {
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
makeRequestAndExpectOne('/test-auth', 'foo', {
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
});
|
||||
|
||||
it('should cache requests with omitted credentials', async () => {
|
||||
makeRequestAndExpectOne('/test-auth', 'foo', {
|
||||
credentials: 'omit',
|
||||
});
|
||||
|
||||
makeRequestAndExpectNone('/test-auth', 'GET', {
|
||||
credentials: 'omit',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not cache responses with Cache-Control: no-store', () => {
|
||||
makeRequestAndExpectOne('/test-no-store', 'private-data', {
|
||||
responseHeaders: {'Cache-Control': 'no-store'},
|
||||
});
|
||||
|
||||
makeRequestAndExpectOne('/test-no-store', 'fresh-data');
|
||||
});
|
||||
|
||||
it('should not cache responses with Cache-Control: private', () => {
|
||||
makeRequestAndExpectOne('/test-private', 'user-data', {
|
||||
responseHeaders: {'Cache-Control': 'private'},
|
||||
});
|
||||
|
||||
makeRequestAndExpectOne('/test-private', 'fresh-data');
|
||||
});
|
||||
|
||||
it('should not cache responses with a Set-Cookie header', () => {
|
||||
makeRequestAndExpectOne('/test-set-cookie', 'user-a-session', {
|
||||
responseHeaders: {'Set-Cookie': 'session=user-a; HttpOnly'},
|
||||
});
|
||||
|
||||
makeRequestAndExpectOne('/test-set-cookie', 'user-b-session');
|
||||
});
|
||||
|
||||
it('should not cache responses with Cache-Control containing no-store among other directives', () => {
|
||||
makeRequestAndExpectOne('/test-multi', 'data', {
|
||||
responseHeaders: {'Cache-Control': 'max-age=0, no-store, must-revalidate'},
|
||||
});
|
||||
|
||||
makeRequestAndExpectOne('/test-multi', 'fresh-data');
|
||||
});
|
||||
|
||||
it('should not cache responses with Cache-Control containing private among other directives', () => {
|
||||
makeRequestAndExpectOne('/test-multi-private', 'data', {
|
||||
responseHeaders: {'Cache-Control': 'max-age=60, private'},
|
||||
});
|
||||
|
||||
makeRequestAndExpectOne('/test-multi-private', 'fresh-data');
|
||||
});
|
||||
|
||||
it('should cache responses with Cache-Control: public', () => {
|
||||
makeRequestAndExpectOne('/test-public', 'public-data', {
|
||||
responseHeaders: {'Cache-Control': 'public'},
|
||||
});
|
||||
|
||||
makeRequestAndExpectNone('/test-public');
|
||||
});
|
||||
|
||||
it('should cache responses with Cache-Control: max-age without no-store or private', () => {
|
||||
makeRequestAndExpectOne('/test-max-age', 'cacheable-data', {
|
||||
responseHeaders: {'Cache-Control': 'max-age=3600'},
|
||||
});
|
||||
|
||||
makeRequestAndExpectNone('/test-max-age');
|
||||
});
|
||||
|
||||
it('should cache responses without Cache-Control header', () => {
|
||||
makeRequestAndExpectOne('/test-no-cc', 'data');
|
||||
|
||||
makeRequestAndExpectNone('/test-no-cc');
|
||||
});
|
||||
|
||||
it('should not cache responses with Cache-Control: no-store (case-insensitive)', () => {
|
||||
makeRequestAndExpectOne('/test-case-resp', 'data', {
|
||||
responseHeaders: {'Cache-Control': 'No-Store'},
|
||||
});
|
||||
|
||||
makeRequestAndExpectOne('/test-case-resp', 'fresh-data');
|
||||
});
|
||||
|
||||
it('should not cache requests with Cache-Control: no-store', () => {
|
||||
makeRequestAndExpectOne('/test-req-no-store', 'data', {
|
||||
headers: {'Cache-Control': 'no-store'},
|
||||
});
|
||||
|
||||
makeRequestAndExpectOne('/test-req-no-store', 'fresh-data');
|
||||
});
|
||||
|
||||
it('should not cache requests with Cache-Control: no-cache', () => {
|
||||
makeRequestAndExpectOne('/test-req-no-cache', 'data', {
|
||||
headers: {'Cache-Control': 'no-cache'},
|
||||
});
|
||||
|
||||
makeRequestAndExpectOne('/test-req-no-cache', 'fresh-data');
|
||||
});
|
||||
|
||||
it('should not cache requests with Cache-Control containing no-store among other directives', () => {
|
||||
makeRequestAndExpectOne('/test-req-multi', 'data', {
|
||||
headers: {'Cache-Control': 'max-age=0, no-store'},
|
||||
});
|
||||
|
||||
makeRequestAndExpectOne('/test-req-multi', 'fresh-data');
|
||||
});
|
||||
|
||||
it('should cache requests with Cache-Control: max-age', () => {
|
||||
makeRequestAndExpectOne('/test-req-max-age', 'data', {
|
||||
headers: {'Cache-Control': 'max-age=3600'},
|
||||
});
|
||||
|
||||
makeRequestAndExpectNone('/test-req-max-age');
|
||||
});
|
||||
|
||||
it('should not cache requests with Fetch API cache mode: no-store', () => {
|
||||
makeRequestAndExpectOne('/test-fetch-no-store', 'data', {
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
makeRequestAndExpectOne('/test-fetch-no-store', 'fresh-data');
|
||||
});
|
||||
|
||||
it('should not cache requests with Fetch API cache mode: no-cache', () => {
|
||||
makeRequestAndExpectOne('/test-fetch-no-cache', 'data', {
|
||||
cache: 'no-cache',
|
||||
});
|
||||
|
||||
makeRequestAndExpectOne('/test-fetch-no-cache', 'fresh-data');
|
||||
});
|
||||
|
||||
it('should cache POST with the differing body in string form', () => {
|
||||
makeRequestAndExpectOne('/test-1', null, {method: 'POST', transferCache: true, body: 'foo'});
|
||||
makeRequestAndExpectNone('/test-1', 'POST', {transferCache: true, body: 'foo'});
|
||||
@@ -483,6 +1032,26 @@ describe('TransferCache', () => {
|
||||
makeRequestAndExpectNone('/test-auth');
|
||||
});
|
||||
|
||||
it(`should not cache requests with credentials when 'includeRequestsWithAuthHeaders' is 'true'`, async () => {
|
||||
makeRequestAndExpectOne('/test-auth', 'foo', {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
makeRequestAndExpectOne('/test-auth', 'foo', {
|
||||
withCredentials: true,
|
||||
});
|
||||
});
|
||||
|
||||
it(`should not cache requests with included credentials when 'includeRequestsWithAuthHeaders' is 'true'`, async () => {
|
||||
makeRequestAndExpectOne('/test-auth', 'foo', {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
makeRequestAndExpectOne('/test-auth', 'foo', {
|
||||
credentials: 'include',
|
||||
});
|
||||
});
|
||||
|
||||
it('should cache a POST request', () => {
|
||||
makeRequestAndExpectOne('/include?foo=1', 'post-body', {method: 'POST'});
|
||||
|
||||
@@ -692,4 +1261,34 @@ describe('TransferCache', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateHash', () => {
|
||||
async function computeNativeSha256(value: string): Promise<string> {
|
||||
const msgUint8 = new TextEncoder().encode(value);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
it('should generate standard SHA-256 hashes matching Web Crypto specs', async () => {
|
||||
const testCases = [
|
||||
'',
|
||||
'hello',
|
||||
'angular',
|
||||
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
|
||||
'Angular 🚀',
|
||||
'a'.repeat(55),
|
||||
'a'.repeat(56),
|
||||
'a'.repeat(63),
|
||||
'a'.repeat(64),
|
||||
'a'.repeat(65),
|
||||
'a'.repeat(1000),
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const expected = await computeNativeSha256(testCase);
|
||||
expect(generateHash(testCase)).withContext(`For: ${testCase}`).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -727,13 +727,15 @@ export class NgOptimizedImage implements OnInit, OnChanges {
|
||||
protected generatePlaceholder(placeholderInput: string | boolean): string | boolean | null {
|
||||
const {placeholderResolution} = this.config;
|
||||
if (placeholderInput === true) {
|
||||
return `url(${this.callImageLoader({
|
||||
src: this.ngSrc,
|
||||
width: placeholderResolution,
|
||||
isPlaceholder: true,
|
||||
})})`;
|
||||
return `url("${escapeCssUrl(
|
||||
this.callImageLoader({
|
||||
src: this.ngSrc,
|
||||
width: placeholderResolution,
|
||||
isPlaceholder: true,
|
||||
}),
|
||||
)}")`;
|
||||
} else if (typeof placeholderInput === 'string') {
|
||||
return `url(${placeholderInput})`;
|
||||
return `url("${escapeCssUrl(placeholderInput)}")`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1461,6 +1463,10 @@ function unwrapSafeUrl(value: string | SafeValue): string {
|
||||
return unwrapSafeValue(value);
|
||||
}
|
||||
|
||||
function escapeCssUrl(input: string): string {
|
||||
return input.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
// Transform function to handle inputs which may be booleans, strings, or string representations
|
||||
// of boolean values. Used for the placeholder attribute.
|
||||
export function booleanOrUrlAttribute(value: boolean | string): boolean | string {
|
||||
|
||||
@@ -36,6 +36,7 @@ export const ISO8601_DATE_REGEX =
|
||||
const NAMED_FORMATS: {[localeId: string]: {[format: string]: string}} = {};
|
||||
const DATE_FORMATS_SPLIT =
|
||||
/((?:[^BEGHLMOSWYZabcdhmswyz']+)|(?:'(?:[^']|'')*')|(?:G{1,5}|y{1,4}|Y{1,4}|M{1,5}|L{1,5}|w{1,2}|W{1}|d{1,2}|E{1,6}|c{1,6}|a{1,5}|b{1,5}|B{1,5}|h{1,2}|H{1,2}|m{1,2}|s{1,2}|S{1,3}|z{1,4}|Z{1,5}|O{1,4}))([\s\S]*)/;
|
||||
const MAX_DATE_FORMAT_LENGTH = 256;
|
||||
|
||||
const enum ZoneWidth {
|
||||
Short,
|
||||
@@ -89,6 +90,7 @@ export function formatDate(
|
||||
timezone?: string,
|
||||
): string {
|
||||
let date = toDate(value);
|
||||
assertValidDateFormatLength(format);
|
||||
const namedFormat = getNamedFormat(locale, format);
|
||||
format = namedFormat || format;
|
||||
|
||||
@@ -132,6 +134,16 @@ export function formatDate(
|
||||
return text;
|
||||
}
|
||||
|
||||
function assertValidDateFormatLength(format: string) {
|
||||
if (format.length > MAX_DATE_FORMAT_LENGTH) {
|
||||
throw new RuntimeError(
|
||||
RuntimeErrorCode.SUSPICIOUS_DATE_FORMAT,
|
||||
ngDevMode &&
|
||||
`Date format is too long. Exceeded maximum length of ${MAX_DATE_FORMAT_LENGTH} characters.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given date format is free from common mistakes. Throws an
|
||||
* error if one is found (except for the case of all "Y", in which case we just
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import {ɵRuntimeError as RuntimeError} from '@angular/core';
|
||||
|
||||
import {RuntimeErrorCode} from '../errors';
|
||||
import {
|
||||
getLocaleNumberFormat,
|
||||
getLocaleNumberSymbol,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
NumberFormatStyle,
|
||||
NumberSymbol,
|
||||
} from './locale_data_api';
|
||||
import {RuntimeErrorCode} from '../errors';
|
||||
|
||||
export const NUMBER_FORMAT_REGEXP = /^(\d+)?\.((\d+)(-(\d+))?)?$/;
|
||||
const MAX_DIGITS = 22;
|
||||
@@ -77,6 +77,20 @@ function formatNumberToLocaleString(
|
||||
} else if (minFractionPart != null && minFraction > maxFraction) {
|
||||
maxFraction = minFraction;
|
||||
}
|
||||
|
||||
// Prevent DoS via resource exhaustion by capping the maximum padding iterations
|
||||
const MAX_ALLOWED_DIGITS = 100;
|
||||
if (
|
||||
minInt > MAX_ALLOWED_DIGITS ||
|
||||
minFraction > MAX_ALLOWED_DIGITS ||
|
||||
maxFraction > MAX_ALLOWED_DIGITS
|
||||
) {
|
||||
throw new RuntimeError(
|
||||
RuntimeErrorCode.INVALID_DIGIT_INFO,
|
||||
ngDevMode &&
|
||||
`${digitsInfo} is not a valid digit info. Exceeded maximum limits of ${MAX_ALLOWED_DIGITS} digits.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
roundNumber(parsedNumber, minFraction, maxFraction);
|
||||
|
||||
@@ -319,7 +319,7 @@ function _stripBasePath(basePath: string, url: string): string {
|
||||
}
|
||||
|
||||
function _stripIndexHtml(url: string): string {
|
||||
return url.replace(/\/index.html$/, '');
|
||||
return url.replace(/\/index\.html$/, '');
|
||||
}
|
||||
|
||||
function _stripOrigin(baseHref: string): string {
|
||||
|
||||
@@ -1215,6 +1215,25 @@ describe('Image directive', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should escape loader output when placeholder is provided as a boolean (security regression)', () => {
|
||||
const maliciousLoader = (config: ImageLoaderConfig) => {
|
||||
if (config.isPlaceholder) {
|
||||
return 'https://mysite.com/img.png"); color: red;/*';
|
||||
}
|
||||
return `${IMG_BASE_URL}/${config.src}`;
|
||||
};
|
||||
setupTestingModule({imageLoader: maliciousLoader});
|
||||
const template = '<img ngSrc="path/img.png" width="400" height="300" placeholder="true" />';
|
||||
|
||||
const fixture = createTestComponent(template);
|
||||
fixture.detectChanges();
|
||||
|
||||
const imageDirective = fixture.debugElement.children[0].injector.get(NgOptimizedImage);
|
||||
const placeholderValue = (imageDirective as any).generatePlaceholder(true) as string;
|
||||
|
||||
expect(placeholderValue).toBe('url("https://mysite.com/img.png\\"); color: red;/*")');
|
||||
});
|
||||
|
||||
if (!isNode) {
|
||||
// DataURLs get stripped from background-image attribute in Node, but not browsers.
|
||||
it('should add a background-image tag when placeholder is provided as a data URL', () => {
|
||||
@@ -1264,6 +1283,22 @@ describe('Image directive', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should prevent CSS injection through placeholder URL values', () => {
|
||||
setupTestingModule({imageLoader});
|
||||
const maliciousPlaceholder = 'https://mysite.com/img.png"); color: red;';
|
||||
const template = `<img ngSrc="path/img.png" width="400" height="300" placeholder='${maliciousPlaceholder}' />`;
|
||||
|
||||
const fixture = createTestComponent(template);
|
||||
fixture.detectChanges();
|
||||
|
||||
const imageDirective = fixture.debugElement.children[0].injector.get(NgOptimizedImage);
|
||||
const placeholderValue = (imageDirective as any).generatePlaceholder(
|
||||
maliciousPlaceholder,
|
||||
) as string;
|
||||
|
||||
expect(placeholderValue).toBe('url("https://mysite.com/img.png\\"); color: red;")');
|
||||
});
|
||||
|
||||
it('should add a background-image tag when placeholder is provided without value', () => {
|
||||
setupTestingModule({imageLoader});
|
||||
const template = '<img ngSrc="path/img.png" width="400" height="300" placeholder />';
|
||||
|
||||
@@ -351,6 +351,16 @@ describe('Format date', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw if the date format exceeds 256 characters to prevent DoS', () => {
|
||||
const expectedError = /Exceeded maximum length of 256 characters/;
|
||||
expect(() => formatDate(date, 'y'.repeat(257), ɵDEFAULT_LOCALE_ID)).toThrowError(
|
||||
expectedError,
|
||||
);
|
||||
expect(() => formatDate(date, "'literal'".repeat(257), ɵDEFAULT_LOCALE_ID)).toThrowError(
|
||||
expectedError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should format invalid in IE ISO date', () =>
|
||||
expect(formatDate('2017-01-11T12:00:00.014-0500', defaultFormat, ɵDEFAULT_LOCALE_ID)).toEqual(
|
||||
'Jan 11, 2017',
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {ɵDEFAULT_LOCALE_ID, ɵregisterLocaleData, ɵunregisterLocaleData} from '@angular/core';
|
||||
import {formatCurrency, formatNumber, formatPercent} from '../../index';
|
||||
import localeAr from '../../locales/ar';
|
||||
import localeEn from '../../locales/en';
|
||||
import localeEsUS from '../../locales/es-US';
|
||||
import localeFr from '../../locales/fr';
|
||||
import {ɵDEFAULT_LOCALE_ID, ɵregisterLocaleData, ɵunregisterLocaleData} from '@angular/core';
|
||||
|
||||
describe('Format number', () => {
|
||||
beforeAll(() => {
|
||||
@@ -43,6 +43,18 @@ describe('Format number', () => {
|
||||
/is higher than the maximum/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if minInt, minFraction, or maxFraction exceeds 100 to prevent DoS', () => {
|
||||
const expectedError = /Exceeded maximum limits of 100 digits/;
|
||||
expect(() => formatNumber(1.1, ɵDEFAULT_LOCALE_ID, '101.4-5')).toThrowError(expectedError);
|
||||
expect(() => formatNumber(1.1, ɵDEFAULT_LOCALE_ID, '3.101-105')).toThrowError(
|
||||
expectedError,
|
||||
);
|
||||
expect(() => formatNumber(1.1, ɵDEFAULT_LOCALE_ID, '3.4-101')).toThrowError(expectedError);
|
||||
expect(() => formatNumber(1.1, ɵDEFAULT_LOCALE_ID, '1.2000000000-20000000')).toThrowError(
|
||||
expectedError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transform with custom locales', () => {
|
||||
|
||||
@@ -304,5 +304,19 @@ describe('Location Class', () => {
|
||||
expect(location.normalize(baseHref)).toBe('');
|
||||
expect(location.normalize(baseHref + path)).toBe(path);
|
||||
});
|
||||
|
||||
it('should only strip a literal /index.html suffix, not arbitrary characters', () => {
|
||||
const baseHref = '/en';
|
||||
|
||||
TestBed.configureTestingModule({providers: [{provide: APP_BASE_HREF, useValue: baseHref}]});
|
||||
|
||||
const location = TestBed.inject(Location);
|
||||
|
||||
// The `.` in the strip regex must be escaped; otherwise it would match any
|
||||
// character and turn e.g. `/foo/indexXhtml` into `/foo`.
|
||||
expect(location.normalize('/en/page/indexXhtml')).toBe('/page/indexXhtml');
|
||||
expect(location.normalize('/en/page/index_html')).toBe('/page/index_html');
|
||||
expect(location.normalize('/en/page/index.html')).toBe('/page');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,6 +77,12 @@ describe('DatePipe', () => {
|
||||
it('should give precedence to the passed in format', () =>
|
||||
expect(pipe.transform('2017-01-11T10:14:39+0000', 'shortDate')).toEqual('1/11/17'));
|
||||
|
||||
it('should reject date formats that are too long', () => {
|
||||
expect(() => pipe.transform(date, 'y'.repeat(257))).toThrowError(
|
||||
/InvalidPipeArgument: 'NG02300: Date format is too long/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use format provided in component as default format when no format is passed in', () => {
|
||||
@Component({
|
||||
selector: 'test-component',
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/core": "7.29.7",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"chokidar": "^5.0.0",
|
||||
|
||||
@@ -168,7 +168,7 @@ function extractParams(
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the the interface usually suffixed with "Decorator" that describes the decorator.
|
||||
* Find the interface usually suffixed with "Decorator" that describes the decorator.
|
||||
*/
|
||||
function getDecoratorInterface(declaration: ts.VariableDeclaration, typeChecker: ts.TypeChecker) {
|
||||
const name = declaration.name.getText();
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
const $_c0$ = [[["basic"]], "*", [["footer"]], [["structural"]]];
|
||||
const $_c1$ = ["basic", "*", "footer", "structural"];
|
||||
const $_c2$ = ["*ngIf", "hasStructural"];
|
||||
|
||||
function TestComponent_ProjectionFallback_0_Template(rf, ctx) {
|
||||
if (rf & 1) {
|
||||
@@ -46,7 +47,7 @@ function TestComponent_ng_content_6_ProjectionFallback_0_Template(rf, ctx) {
|
||||
|
||||
function TestComponent_ng_content_6_Template(rf, ctx) {
|
||||
if (rf & 1) {
|
||||
$r3$.ɵɵprojection(0, 3, ["*ngIf", "hasStructural"], TestComponent_ng_content_6_ProjectionFallback_0_Template, 2, 0);
|
||||
$r3$.ɵɵprojection(0, 3, $_c2$, TestComponent_ng_content_6_ProjectionFallback_0_Template, 2, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-8
@@ -1,16 +1,17 @@
|
||||
|
||||
const _c0 = [[["", "card-title", ""]], [["", "card-content", ""]]];
|
||||
const _c1 = ["[card-title]", "[card-content]"];
|
||||
const _c2 = ["*"];
|
||||
const $_c0$ = [[["", "card-title", ""]], [["", "card-content", ""]]];
|
||||
const $_c1$ = ["[card-title]", "[card-content]"];
|
||||
const $_c2$ = ["*"];
|
||||
const $_c3$ = ["ngProjectAs", "[card-content]", 5, ["", "card-content", ""]];
|
||||
// ...
|
||||
selectors: [["card"]],
|
||||
standalone: false,
|
||||
ngContentSelectors: _c1,
|
||||
ngContentSelectors: $_c1$,
|
||||
decls: 3,
|
||||
vars: 0,
|
||||
template: function Card_Template(rf, ctx) {
|
||||
if (rf & 1) {
|
||||
i0.ɵɵprojectionDef(_c0);
|
||||
i0.ɵɵprojectionDef($_c0$);
|
||||
i0.ɵɵprojection(0);
|
||||
i0.ɵɵtext(1, " --- ");
|
||||
i0.ɵɵprojection(2, 1);
|
||||
@@ -19,7 +20,7 @@ template: function Card_Template(rf, ctx) {
|
||||
// ...
|
||||
selectors: [["card-with-title"]],
|
||||
standalone: false,
|
||||
ngContentSelectors: _c2,
|
||||
ngContentSelectors: $_c2$,
|
||||
decls: 4,
|
||||
vars: 0,
|
||||
consts: [["ngProjectAs", "[card-title]", 5, ["", "card-title", ""]]],
|
||||
@@ -29,7 +30,7 @@ template: function CardWithTitle_Template(rf, ctx) {
|
||||
i0.ɵɵelementStart(0, "card")(1, "h1", 0);
|
||||
i0.ɵɵtext(2, "Title");
|
||||
i0.ɵɵelementEnd();
|
||||
i0.ɵɵprojection(3, 0, ["ngProjectAs", "[card-content]", 5, ["", "card-content", ""]]);
|
||||
i0.ɵɵprojection(3, 0, $_c3$);
|
||||
i0.ɵɵelementEnd();
|
||||
}
|
||||
}
|
||||
@@ -44,4 +45,4 @@ template: function App_Template(rf, ctx) {
|
||||
i0.ɵɵtext(1, "content");
|
||||
i0.ɵɵelementEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+57
@@ -936,6 +936,48 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
|
||||
},
|
||||
}]
|
||||
}] });
|
||||
export class HostBindingCustomSrcdocDir {
|
||||
evil = 'evil';
|
||||
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomSrcdocDir, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
||||
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: HostBindingCustomSrcdocDir, isStandalone: true, selector: "safe-srcdoc-carrier", host: { properties: { "attr.srcdoc": "evil" } }, ngImport: i0 });
|
||||
}
|
||||
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomSrcdocDir, decorators: [{
|
||||
type: Directive,
|
||||
args: [{
|
||||
selector: 'safe-srcdoc-carrier',
|
||||
host: {
|
||||
'[attr.srcdoc]': 'evil',
|
||||
},
|
||||
}]
|
||||
}] });
|
||||
export class HostBindingCustomSrcDir {
|
||||
evil = 'evil';
|
||||
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomSrcDir, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
||||
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: HostBindingCustomSrcDir, isStandalone: true, selector: "safe-src-carrier", host: { properties: { "attr.src": "evil" } }, ngImport: i0 });
|
||||
}
|
||||
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomSrcDir, decorators: [{
|
||||
type: Directive,
|
||||
args: [{
|
||||
selector: 'safe-src-carrier',
|
||||
host: {
|
||||
'[attr.src]': 'evil',
|
||||
},
|
||||
}]
|
||||
}] });
|
||||
export class HostBindingCustomDataDir {
|
||||
evil = 'evil';
|
||||
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomDataDir, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
||||
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: HostBindingCustomDataDir, isStandalone: true, selector: "safe-data-carrier", host: { properties: { "attr.data": "evil" } }, ngImport: i0 });
|
||||
}
|
||||
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomDataDir, decorators: [{
|
||||
type: Directive,
|
||||
args: [{
|
||||
selector: 'safe-data-carrier',
|
||||
host: {
|
||||
'[attr.data]': 'evil',
|
||||
},
|
||||
}]
|
||||
}] });
|
||||
|
||||
/****************************************************************************************************
|
||||
* PARTIAL FILE: sanitization.d.ts
|
||||
@@ -963,6 +1005,21 @@ export declare class HostBindingSvgAnimateDir {
|
||||
static ɵfac: i0.ɵɵFactoryDeclaration<HostBindingSvgAnimateDir, never>;
|
||||
static ɵdir: i0.ɵɵDirectiveDeclaration<HostBindingSvgAnimateDir, "animateMotion[hostBindingSvgAnimateDir]", never, {}, {}, never, never, true, never>;
|
||||
}
|
||||
export declare class HostBindingCustomSrcdocDir {
|
||||
evil: string;
|
||||
static ɵfac: i0.ɵɵFactoryDeclaration<HostBindingCustomSrcdocDir, never>;
|
||||
static ɵdir: i0.ɵɵDirectiveDeclaration<HostBindingCustomSrcdocDir, "safe-srcdoc-carrier", never, {}, {}, never, never, true, never>;
|
||||
}
|
||||
export declare class HostBindingCustomSrcDir {
|
||||
evil: string;
|
||||
static ɵfac: i0.ɵɵFactoryDeclaration<HostBindingCustomSrcDir, never>;
|
||||
static ɵdir: i0.ɵɵDirectiveDeclaration<HostBindingCustomSrcDir, "safe-src-carrier", never, {}, {}, never, never, true, never>;
|
||||
}
|
||||
export declare class HostBindingCustomDataDir {
|
||||
evil: string;
|
||||
static ɵfac: i0.ɵɵFactoryDeclaration<HostBindingCustomDataDir, never>;
|
||||
static ɵdir: i0.ɵɵDirectiveDeclaration<HostBindingCustomDataDir, "safe-data-carrier", never, {}, {}, never, never, true, never>;
|
||||
}
|
||||
|
||||
/****************************************************************************************************
|
||||
* PARTIAL FILE: security_sensitive_constant_attributes.js
|
||||
|
||||
+22
-4
@@ -1,21 +1,21 @@
|
||||
hostBindings: function HostBindingLinkDir_HostBindings(rf, ctx) {
|
||||
if (rf & 2) {
|
||||
$r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml)("href", ctx.evil, $r3$.ɵɵsanitizeUrl);
|
||||
$r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml)("href", ctx.evil, $r3$.ɵɵsanitizeUrlOrResourceUrl);
|
||||
$r3$.ɵɵattribute("style", ctx.evil, $r3$.ɵɵsanitizeStyle);
|
||||
}
|
||||
}
|
||||
…
|
||||
hostBindings: function HostBindingImageDir_HostBindings(rf, ctx) {
|
||||
if (rf & 2) {
|
||||
i0.ɵɵdomProperty("innerHTML", ctx.evil, i0.ɵɵsanitizeHtml)("src", ctx.nonEvil, i0.ɵɵsanitizeUrl);
|
||||
i0.ɵɵdomProperty("innerHTML", ctx.evil, i0.ɵɵsanitizeHtml)("src", ctx.nonEvil, i0.ɵɵsanitizeUrlOrResourceUrl);
|
||||
i0.ɵɵattribute("style", ctx.evil, i0.ɵɵsanitizeStyle);
|
||||
}
|
||||
}
|
||||
…
|
||||
hostBindings: function HostBindingIframeDir_HostBindings(rf, ctx) {
|
||||
if (rf & 2) {
|
||||
$r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml)("src", ctx.evil, i0.ɵɵsanitizeResourceUrl)("sandbox", ctx.evil, $r3$.ɵɵvalidateAttribute);
|
||||
$r3$.ɵɵattribute("style", ctx.evil, $r3$.ɵɵsanitizeStyle)("attributeName", ctx.nonEvil);
|
||||
$r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml)("src", ctx.evil, i0.ɵɵsanitizeUrlOrResourceUrl)("sandbox", ctx.evil, $r3$.ɵɵvalidateAttribute);
|
||||
$r3$.ɵɵattribute("style", ctx.evil, $r3$.ɵɵsanitizeStyle)("attributeName", ctx.nonEvil, i0.ɵɵvalidateAttribute);
|
||||
}
|
||||
}
|
||||
…
|
||||
@@ -24,3 +24,21 @@ hostBindings: function HostBindingSvgAnimateDir_HostBindings(rf, ctx) {
|
||||
i0.ɵɵattribute("attributeName", ctx.evil, i0.ɵɵvalidateAttribute);
|
||||
}
|
||||
}
|
||||
…
|
||||
hostBindings: function HostBindingCustomSrcdocDir_HostBindings(rf, ctx) {
|
||||
if (rf & 2) {
|
||||
i0.ɵɵattribute("srcdoc", ctx.evil, i0.ɵɵsanitizeHtml);
|
||||
}
|
||||
}
|
||||
…
|
||||
hostBindings: function HostBindingCustomSrcDir_HostBindings(rf, ctx) {
|
||||
if (rf & 2) {
|
||||
i0.ɵɵattribute("src", ctx.evil, i0.ɵɵsanitizeUrlOrResourceUrl);
|
||||
}
|
||||
}
|
||||
…
|
||||
hostBindings: function HostBindingCustomDataDir_HostBindings(rf, ctx) {
|
||||
if (rf & 2) {
|
||||
i0.ɵɵattribute("data", ctx.evil, i0.ɵɵsanitizeUrlOrResourceUrl);
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -49,3 +49,33 @@ export class HostBindingIframeDir {
|
||||
export class HostBindingSvgAnimateDir {
|
||||
evil = 'evil';
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: 'safe-srcdoc-carrier',
|
||||
host: {
|
||||
'[attr.srcdoc]': 'evil',
|
||||
},
|
||||
})
|
||||
export class HostBindingCustomSrcdocDir {
|
||||
evil = 'evil';
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: 'safe-src-carrier',
|
||||
host: {
|
||||
'[attr.src]': 'evil',
|
||||
},
|
||||
})
|
||||
export class HostBindingCustomSrcDir {
|
||||
evil = 'evil';
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: 'safe-data-carrier',
|
||||
host: {
|
||||
'[attr.data]': 'evil',
|
||||
},
|
||||
})
|
||||
export class HostBindingCustomDataDir {
|
||||
evil = 'evil';
|
||||
}
|
||||
|
||||
+18
@@ -828,6 +828,15 @@ export class MyComponent {
|
||||
<iframe [sandbox]="evil"></iframe>
|
||||
<a href="{{evil}}{{evil}}"></a>
|
||||
<div attr.style="{{evil}}{{evil}}"></div>
|
||||
<div [(innerHTML)]="evil"></div>
|
||||
<div bindon-innerHTML="evil"></div>
|
||||
<iframe [(srcdoc)]="evil"></iframe>
|
||||
<iframe bindon-srcdoc="evil"></iframe>
|
||||
<img [(src)]="evil" />
|
||||
<iframe [(src)]="evil"></iframe>
|
||||
<object [(data)]="evil"></object>
|
||||
<link [(href)]="evil" />
|
||||
<iframe [(sandbox)]="evil"></iframe>
|
||||
`, isInline: true });
|
||||
}
|
||||
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, decorators: [{
|
||||
@@ -842,6 +851,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
|
||||
<iframe [sandbox]="evil"></iframe>
|
||||
<a href="{{evil}}{{evil}}"></a>
|
||||
<div attr.style="{{evil}}{{evil}}"></div>
|
||||
<div [(innerHTML)]="evil"></div>
|
||||
<div bindon-innerHTML="evil"></div>
|
||||
<iframe [(srcdoc)]="evil"></iframe>
|
||||
<iframe bindon-srcdoc="evil"></iframe>
|
||||
<img [(src)]="evil" />
|
||||
<iframe [(src)]="evil"></iframe>
|
||||
<object [(data)]="evil"></object>
|
||||
<link [(href)]="evil" />
|
||||
<iframe [(sandbox)]="evil"></iframe>
|
||||
`
|
||||
}]
|
||||
}] });
|
||||
|
||||
+19
-3
@@ -1,7 +1,5 @@
|
||||
template: function MyComponent_Template(rf, ctx) {
|
||||
if (rf & 1) {
|
||||
$r3$.ɵɵdomElement(0, "div", 0)(1, "link", 1)(2, "div")(3, "img", 2)(4, "iframe", 3)(5, "a", 1)(6, "div");
|
||||
}
|
||||
…
|
||||
if (rf & 2) {
|
||||
$r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml);
|
||||
$r3$.ɵɵadvance();
|
||||
@@ -16,5 +14,23 @@ template: function MyComponent_Template(rf, ctx) {
|
||||
$r3$.ɵɵdomProperty("href", $r3$.ɵɵinterpolate2("", ctx.evil, "", ctx.evil), $r3$.ɵɵsanitizeUrl);
|
||||
$r3$.ɵɵadvance();
|
||||
$r3$.ɵɵattribute("style", $r3$.ɵɵinterpolate2("", ctx.evil, "", ctx.evil), $r3$.ɵɵsanitizeStyle);
|
||||
$r3$.ɵɵadvance();
|
||||
$r3$.ɵɵtwoWayProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml);
|
||||
$r3$.ɵɵadvance();
|
||||
$r3$.ɵɵtwoWayProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml);
|
||||
$r3$.ɵɵadvance();
|
||||
$r3$.ɵɵtwoWayProperty("srcdoc", ctx.evil, $r3$.ɵɵsanitizeHtml);
|
||||
$r3$.ɵɵadvance();
|
||||
$r3$.ɵɵtwoWayProperty("srcdoc", ctx.evil, $r3$.ɵɵsanitizeHtml);
|
||||
$r3$.ɵɵadvance();
|
||||
$r3$.ɵɵtwoWayProperty("src", ctx.evil, $r3$.ɵɵsanitizeUrl);
|
||||
$r3$.ɵɵadvance();
|
||||
$r3$.ɵɵtwoWayProperty("src", ctx.evil, $r3$.ɵɵsanitizeResourceUrl);
|
||||
$r3$.ɵɵadvance();
|
||||
$r3$.ɵɵtwoWayProperty("data", ctx.evil, $r3$.ɵɵsanitizeResourceUrl);
|
||||
$r3$.ɵɵadvance();
|
||||
$r3$.ɵɵtwoWayProperty("href", ctx.evil, $r3$.ɵɵsanitizeResourceUrl);
|
||||
$r3$.ɵɵadvance();
|
||||
$r3$.ɵɵtwoWayProperty("sandbox", ctx.evil, $r3$.ɵɵvalidateAttribute);
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -10,6 +10,15 @@ import {Component} from '@angular/core';
|
||||
<iframe [sandbox]="evil"></iframe>
|
||||
<a href="{{evil}}{{evil}}"></a>
|
||||
<div attr.style="{{evil}}{{evil}}"></div>
|
||||
<div [(innerHTML)]="evil"></div>
|
||||
<div bindon-innerHTML="evil"></div>
|
||||
<iframe [(srcdoc)]="evil"></iframe>
|
||||
<iframe bindon-srcdoc="evil"></iframe>
|
||||
<img [(src)]="evil" />
|
||||
<iframe [(src)]="evil"></iframe>
|
||||
<object [(data)]="evil"></object>
|
||||
<link [(href)]="evil" />
|
||||
<iframe [(sandbox)]="evil"></iframe>
|
||||
`
|
||||
})
|
||||
export class MyComponent {
|
||||
|
||||
+3
-1
@@ -1,3 +1,5 @@
|
||||
const $_c1$ = ["*ngIf", "flag"];
|
||||
|
||||
function MyComponent_img_2_Template(rf, ctx) {
|
||||
if (rf & 1) {
|
||||
i0.ɵɵi18nStart(0, 0, 1);
|
||||
@@ -34,7 +36,7 @@ function MyComponent_ng_container_5_Template(rf, ctx) {
|
||||
function MyComponent_ng_content_6_Template(rf, ctx) {
|
||||
if (rf & 1) {
|
||||
i0.ɵɵi18nStart(0, 0, 6);
|
||||
i0.ɵɵprojection(1, 0, ["*ngIf", "flag"]);
|
||||
i0.ɵɵprojection(1, 0, $_c1$);
|
||||
i0.ɵɵi18nEnd();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8569,7 +8569,7 @@ runInEachFileSystem((os: string) => {
|
||||
hostVars: 6,
|
||||
hostBindings: function UnsafeAttrsDirective_HostBindings(rf, ctx) {
|
||||
if (rf & 2) {
|
||||
i0.ɵɵattribute("href", ctx.attrHref, i0.ɵɵsanitizeUrlOrResourceUrl)("src", ctx.attrSrc, i0.ɵɵsanitizeUrlOrResourceUrl)("action", ctx.attrAction, i0.ɵɵsanitizeUrl)("profile", ctx.attrProfile)("innerHTML", ctx.attrInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.attrSafeTitle);
|
||||
i0.ɵɵattribute("href", ctx.attrHref, i0.ɵɵsanitizeUrlOrResourceUrl)("src", ctx.attrSrc, i0.ɵɵsanitizeUrlOrResourceUrl)("action", ctx.attrAction, i0.ɵɵsanitizeUrlOrResourceUrl)("profile", ctx.attrProfile)("innerHTML", ctx.attrInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.attrSafeTitle);
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -8615,14 +8615,14 @@ runInEachFileSystem((os: string) => {
|
||||
hostVars: 3,
|
||||
hostBindings: function UnsafePropsDirective_HostBindings(rf, ctx) {
|
||||
if (rf & 2) {
|
||||
i0.ɵɵdomProperty("href", ctx.propHref, i0.ɵɵsanitizeUrl)("innerHTML", ctx.propInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.propSafeTitle);
|
||||
i0.ɵɵdomProperty("href", ctx.propHref, i0.ɵɵsanitizeUrlOrResourceUrl)("innerHTML", ctx.propInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.propSafeTitle);
|
||||
}
|
||||
}
|
||||
`;
|
||||
expect(trim(jsContents)).toContain(trim(hostBindingsFn));
|
||||
});
|
||||
|
||||
it('should not generate sanitizers for URL properties in hostBindings fn in Component', () => {
|
||||
it('should generate concrete-host URL sanitizers in hostBindings fn in Component', () => {
|
||||
env.write(
|
||||
`test.ts`,
|
||||
`
|
||||
@@ -8655,8 +8655,40 @@ runInEachFileSystem((os: string) => {
|
||||
hostVars: 5,
|
||||
hostBindings: function FooCmp_HostBindings(rf, ctx) {
|
||||
if (rf & 2) {
|
||||
i0.ɵɵdomProperty("href", ctx.hrefProp, i0.ɵɵsanitizeUrl)("title", ctx.titleProp);
|
||||
i0.ɵɵattribute("src", ctx.srcAttr)("href", ctx.hrefAttr, i0.ɵɵsanitizeUrl)("title", ctx.titleAttr);
|
||||
i0.ɵɵdomProperty("href", ctx.hrefProp, i0.ɵɵsanitizeUrlOrResourceUrl)("title", ctx.titleProp);
|
||||
i0.ɵɵattribute("src", ctx.srcAttr, i0.ɵɵsanitizeUrlOrResourceUrl)("href", ctx.hrefAttr, i0.ɵɵsanitizeUrlOrResourceUrl)("title", ctx.titleAttr);
|
||||
}
|
||||
}
|
||||
`;
|
||||
expect(trim(jsContents)).toContain(trim(hostBindingsFn));
|
||||
});
|
||||
|
||||
it('should generate sanitizers for pure :not selector host bindings', () => {
|
||||
env.write(
|
||||
`test.ts`,
|
||||
`
|
||||
import {Component} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: ':not(iframe)',
|
||||
template: '',
|
||||
host: {
|
||||
'[attr.srcdoc]': 'srcdoc',
|
||||
}
|
||||
})
|
||||
class FooCmp {
|
||||
srcdoc: any;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
env.driveMain();
|
||||
const jsContents = env.getContents('test.js');
|
||||
const hostBindingsFn = `
|
||||
hostVars: 1,
|
||||
hostBindings: function FooCmp_HostBindings(rf, ctx) {
|
||||
if (rf & 2) {
|
||||
i0.ɵɵattribute("srcdoc", ctx.srcdoc, i0.ɵɵsanitizeHtml);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -32,7 +32,6 @@ import {publishFacade} from './jit_compiler_facade';
|
||||
import * as outputAst from './output/output_ast';
|
||||
import {global} from './util';
|
||||
|
||||
export {SECURITY_SCHEMA} from './schema/dom_security_schema';
|
||||
export {CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA, SchemaMetadata} from './core';
|
||||
export {core};
|
||||
|
||||
|
||||
@@ -79,16 +79,6 @@ export interface Type extends Function {
|
||||
}
|
||||
export const Type = Function;
|
||||
|
||||
export enum SecurityContext {
|
||||
NONE = 0,
|
||||
HTML = 1,
|
||||
STYLE = 2,
|
||||
SCRIPT = 3,
|
||||
URL = 4,
|
||||
RESOURCE_URL = 5,
|
||||
ATTRIBUTE_NO_BINDING = 6,
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection flags for DI.
|
||||
*/
|
||||
@@ -331,3 +321,5 @@ export const enum AttributeMarker {
|
||||
*/
|
||||
I18n = 6,
|
||||
}
|
||||
|
||||
export {SecurityContext} from './schema/dom_security_schema';
|
||||
|
||||
@@ -160,7 +160,7 @@ class _I18nVisitor implements html.Visitor {
|
||||
}
|
||||
|
||||
// Else returns a placeholder
|
||||
// ICU placeholders should not be replaced with their original content but with the their
|
||||
// ICU placeholders should not be replaced with their original content but with their
|
||||
// translations.
|
||||
// TODO(vicb): add a html.Node -> i18n.Message cache to avoid having to re-create the msg
|
||||
const phName = context.placeholderRegistry.getPlaceholderName('ICU', icu.sourceSpan.toString());
|
||||
|
||||
@@ -208,7 +208,7 @@ export class I18nMetaVisitor implements html.Visitor {
|
||||
isTrustedType = isTrustedTypesSink(node.name, name);
|
||||
}
|
||||
|
||||
if (isTrustedType) {
|
||||
if (isTrustedType || isPossibleEventHandler(name)) {
|
||||
this._reportError(
|
||||
attr,
|
||||
`Translating attribute '${name}' is disallowed for security reasons.`,
|
||||
@@ -350,3 +350,14 @@ export function i18nMetaToJSDoc(meta: I18nMeta): o.JSDocComment {
|
||||
}
|
||||
return o.jsDocComment(tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the propertyName is a potential event handler.
|
||||
* We consider a property to be a potential event handler if its name is longer than 2 characters and starts with 'on' (e.g. 'onclick', 'onload', etc.).
|
||||
* @param propertyName The name of the property to check.
|
||||
* @returns True if the property is a potential event handler, false otherwise.
|
||||
*/
|
||||
function isPossibleEventHandler(propertyName: string): boolean {
|
||||
const name = propertyName.toLowerCase();
|
||||
return name.length > 2 && name !== 'only' && name.startsWith('on');
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
*/
|
||||
|
||||
import {CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA, SchemaMetadata, SecurityContext} from '../core';
|
||||
import {isNgContainer, isNgContent} from '../ml_parser/tags';
|
||||
import {isNgContainer, isNgContent, splitNsName} from '../ml_parser/tags';
|
||||
import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../template/pipeline/src/namespaces';
|
||||
import {dashCaseToCamelCase} from '../util';
|
||||
import {SECURITY_SCHEMA} from './dom_security_schema';
|
||||
import {ElementSchemaRegistry} from './element_schema_registry';
|
||||
@@ -17,6 +18,13 @@ const NUMBER = 'number';
|
||||
const STRING = 'string';
|
||||
const OBJECT = 'object';
|
||||
|
||||
function normalizeTagName(tagName: string): string {
|
||||
const tagNameLower = tagName.toLowerCase();
|
||||
const [ns, name] = splitNsName(tagNameLower, false);
|
||||
|
||||
return ns === SVG_NAMESPACE || ns === MATH_ML_NAMESPACE ? `:${ns}:${name}` : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* This array represents the DOM schema. It encodes inheritance, properties, and events.
|
||||
*
|
||||
@@ -388,8 +396,9 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tagName.indexOf('-') > -1) {
|
||||
if (isNgContainer(tagName) || isNgContent(tagName)) {
|
||||
const normalizedTag = normalizeTagName(tagName);
|
||||
if (normalizedTag.includes('-')) {
|
||||
if (isNgContainer(normalizedTag) || isNgContent(normalizedTag)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -400,8 +409,7 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
const elementProperties =
|
||||
this._schema.get(tagName.toLowerCase()) || this._schema.get('unknown')!;
|
||||
const elementProperties = this._schema.get(normalizedTag) || this._schema.get('unknown')!;
|
||||
return elementProperties.has(propName);
|
||||
}
|
||||
|
||||
@@ -410,8 +418,9 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tagName.indexOf('-') > -1) {
|
||||
if (isNgContainer(tagName) || isNgContent(tagName)) {
|
||||
const normalizedTag = normalizeTagName(tagName);
|
||||
if (normalizedTag.includes('-')) {
|
||||
if (isNgContainer(normalizedTag) || isNgContent(normalizedTag)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -421,7 +430,7 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
return this._schema.has(tagName.toLowerCase());
|
||||
return this._schema.has(normalizedTag);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -444,14 +453,14 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry {
|
||||
propName = this.getMappedPropName(propName);
|
||||
}
|
||||
|
||||
// Make sure comparisons are case insensitive, so that case differences between attribute and
|
||||
// property names do not have a security impact.
|
||||
tagName = tagName.toLowerCase();
|
||||
const normalizedTag = normalizeTagName(tagName);
|
||||
propName = propName.toLowerCase();
|
||||
const [namespace] = splitNsName(normalizedTag, false);
|
||||
|
||||
const securitySchema = SECURITY_SCHEMA();
|
||||
const ctx =
|
||||
securitySchema[tagName + '|' + propName] ??
|
||||
securitySchema[normalizedTag + '|' + propName] ??
|
||||
(namespace ? securitySchema[`:${namespace}:*|${propName}`] : undefined) ??
|
||||
securitySchema['*|' + propName] ??
|
||||
SecurityContext.NONE;
|
||||
|
||||
@@ -495,14 +504,15 @@ export class DomElementSchemaRegistry extends ElementSchemaRegistry {
|
||||
}
|
||||
|
||||
allKnownAttributesOfElement(tagName: string): string[] {
|
||||
const elementProperties =
|
||||
this._schema.get(tagName.toLowerCase()) || this._schema.get('unknown')!;
|
||||
const normalizedTag = normalizeTagName(tagName);
|
||||
const elementProperties = this._schema.get(normalizedTag) || this._schema.get('unknown')!;
|
||||
// Convert properties to attributes.
|
||||
return Array.from(elementProperties.keys()).map((prop) => _PROP_TO_ATTR.get(prop) ?? prop);
|
||||
}
|
||||
|
||||
allKnownEventsOfElement(tagName: string): string[] {
|
||||
return Array.from(this._eventSchema.get(tagName.toLowerCase()) ?? []);
|
||||
const normalizedTag = normalizeTagName(tagName);
|
||||
return Array.from(this._eventSchema.get(normalizedTag) ?? []);
|
||||
}
|
||||
|
||||
override normalizeAnimationStyleProperty(propName: string): string {
|
||||
|
||||
@@ -6,7 +6,24 @@
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {SecurityContext} from '../core';
|
||||
/**
|
||||
* A SecurityContext marks a location that has dangerous security implications, e.g. a DOM property
|
||||
* like `innerHTML` that could cause Cross Site Scripting (XSS) security bugs when improperly
|
||||
* handled.
|
||||
*
|
||||
* See DomSanitizer for more details on security in Angular applications.
|
||||
*
|
||||
* @publicApi
|
||||
*/
|
||||
export enum SecurityContext {
|
||||
NONE = 0,
|
||||
HTML = 1,
|
||||
STYLE = 2,
|
||||
SCRIPT = 3,
|
||||
URL = 4,
|
||||
RESOURCE_URL = 5,
|
||||
ATTRIBUTE_NO_BINDING = 6,
|
||||
}
|
||||
|
||||
// =================================================================================================
|
||||
// =================================================================================================
|
||||
@@ -18,11 +35,17 @@ import {SecurityContext} from '../core';
|
||||
//
|
||||
// =================================================================================================
|
||||
|
||||
/** Map from tagName|propertyName to SecurityContext. Properties applying to all tags use '*'. */
|
||||
/**
|
||||
* Map from tagName|propertyName to SecurityContext. Properties applying to all tags use '*'.
|
||||
*/
|
||||
let _SECURITY_SCHEMA!: {[k: string]: SecurityContext};
|
||||
const SVG_NAMESPACE = 'svg';
|
||||
const MATH_ML_NAMESPACE = 'math';
|
||||
export const SVG_NAMESPACE = 'svg';
|
||||
export const MATH_ML_NAMESPACE = 'math';
|
||||
|
||||
/**
|
||||
* @remarks Keep is a copy of DOM Security Schema.
|
||||
* @see [SECURITY_SCHEMA](../../../compiler/src/schema/dom_security_schema.ts)
|
||||
*/
|
||||
export function SECURITY_SCHEMA(): {[k: string]: SecurityContext} {
|
||||
if (!_SECURITY_SCHEMA) {
|
||||
_SECURITY_SCHEMA = {};
|
||||
@@ -48,6 +71,7 @@ export function SECURITY_SCHEMA(): {[k: string]: SecurityContext} {
|
||||
registerContext(SecurityContext.URL, MATH_ML_NAMESPACE, [
|
||||
// MathML namespace
|
||||
// https://crsrc.org/c/third_party/blink/renderer/core/sanitizer/sanitizer.cc;l=753-768;drc=b3eb16372dcd3317d65e9e0265015e322494edcd;bpv=1;bpt=1
|
||||
['*', ['href', 'xlink:href']],
|
||||
['annotation', ['href', 'xlink:href']],
|
||||
['annotation-xml', ['href', 'xlink:href']],
|
||||
['maction', ['href', 'xlink:href']],
|
||||
@@ -92,6 +116,7 @@ export function SECURITY_SCHEMA(): {[k: string]: SecurityContext} {
|
||||
['object', ['codebase', 'data']],
|
||||
]);
|
||||
|
||||
registerContext(SecurityContext.URL, SVG_NAMESPACE, [['a', ['href', 'xlink:href']]]);
|
||||
// Keep this in sync with SECURITY_SENSITIVE_ELEMENTS in packages/core/src/sanitization/sanitization.ts
|
||||
// The `unknown` elements refer to cases when we need to validate the input/binding in a directive (host bindings)
|
||||
// and the directive can be applied to multiple different elements (with different tag names). In this case we generate
|
||||
@@ -136,8 +161,7 @@ function registerContext(
|
||||
specs: readonly [tagName: string, attributeNames: readonly string[]][],
|
||||
): void {
|
||||
for (const [element, attributeNames] of specs) {
|
||||
let tagName =
|
||||
namespace && element !== '*' && element !== 'unknown' ? `:${namespace}:${element}` : element;
|
||||
let tagName = namespace && element !== 'unknown' ? `:${namespace}:${element}` : element;
|
||||
tagName = tagName.toLowerCase();
|
||||
|
||||
for (const attr of attributeNames) {
|
||||
@@ -145,3 +169,28 @@ function registerContext(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function checkSecurityContext(
|
||||
tagName: string,
|
||||
propName: string,
|
||||
namespace?: string | null,
|
||||
): SecurityContext {
|
||||
const schema = SECURITY_SCHEMA();
|
||||
const normalizedTagName = tagName.toLowerCase();
|
||||
const normalizedPropName = propName.toLowerCase();
|
||||
const namespacedContext =
|
||||
namespace && normalizedTagName !== '*' && normalizedTagName !== 'unknown'
|
||||
? schema[`:${namespace}:${normalizedTagName}|${normalizedPropName}`]
|
||||
: undefined;
|
||||
const namespacedWildcardContext = namespace
|
||||
? schema[`:${namespace}:*|${normalizedPropName}`]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
namespacedContext ??
|
||||
namespacedWildcardContext ??
|
||||
schema[`${normalizedTagName}|${normalizedPropName}`] ??
|
||||
schema[`*|${normalizedPropName}`] ??
|
||||
SecurityContext.NONE
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1137,7 +1137,7 @@ export interface ProjectionOp extends Op<CreateOp>, ConsumesSlotOpTrait {
|
||||
|
||||
projectionSlotIndex: number;
|
||||
|
||||
attributes: null | o.LiteralArrayExpr;
|
||||
attributes: null | o.Expression;
|
||||
|
||||
localRefs: string[];
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ import {resolveDeferDepsFns} from './phases/resolve_defer_deps_fns';
|
||||
import {resolveDollarEvent} from './phases/resolve_dollar_event';
|
||||
import {resolveI18nElementPlaceholders} from './phases/resolve_i18n_element_placeholders';
|
||||
import {resolveI18nExpressionPlaceholders} from './phases/resolve_i18n_expression_placeholders';
|
||||
import {resolveI18nAttrSanitizers} from './phases/resolve_i18n_attr_sanitizers';
|
||||
import {resolveNames} from './phases/resolve_names';
|
||||
import {resolveSanitizers} from './phases/resolve_sanitizers';
|
||||
import {saveAndRestoreView} from './phases/save_restore_view';
|
||||
@@ -157,6 +158,7 @@ const phases: Phase[] = [
|
||||
{kind: Kind.Tmpl, fn: resolveI18nExpressionPlaceholders},
|
||||
{kind: Kind.Tmpl, fn: extractI18nMessages},
|
||||
{kind: Kind.Tmpl, fn: collectI18nConsts},
|
||||
{kind: Kind.Tmpl, fn: resolveI18nAttrSanitizers},
|
||||
{kind: Kind.Tmpl, fn: collectConstExpressions},
|
||||
{kind: Kind.Both, fn: collectElementConsts},
|
||||
{kind: Kind.Tmpl, fn: removeI18nContexts},
|
||||
|
||||
@@ -17,7 +17,7 @@ import * as t from '../../../render3/r3_ast';
|
||||
import {DeferBlockDepsEmitMode, R3ComponentDeferMetadata} from '../../../render3/view/api';
|
||||
import {icuFromI18nMessage} from '../../../render3/view/i18n/util';
|
||||
import {DomElementSchemaRegistry} from '../../../schema/dom_element_schema_registry';
|
||||
import {BindingParser} from '../../../template_parser/binding_parser';
|
||||
import {BindingParser, calcPossibleSecurityContexts} from '../../../template_parser/binding_parser';
|
||||
import * as ir from '../ir';
|
||||
|
||||
import {
|
||||
@@ -115,19 +115,21 @@ export function ingestHostBinding(
|
||||
if (property.isAnimation) {
|
||||
bindingKind = ir.BindingKind.Animation;
|
||||
}
|
||||
const securityContexts = bindingParser
|
||||
.calcPossibleSecurityContexts(
|
||||
input.componentSelector,
|
||||
property.name,
|
||||
bindingKind === ir.BindingKind.Attribute,
|
||||
)
|
||||
.filter((context) => context !== SecurityContext.NONE);
|
||||
const securityContexts = calcHostBindingSecurityContexts(
|
||||
bindingParser,
|
||||
input.componentSelector,
|
||||
property.name,
|
||||
bindingKind === ir.BindingKind.Attribute,
|
||||
);
|
||||
ingestDomProperty(job, property, bindingKind, securityContexts);
|
||||
}
|
||||
for (const [name, expr] of Object.entries(input.attributes) ?? []) {
|
||||
const securityContexts = bindingParser
|
||||
.calcPossibleSecurityContexts(input.componentSelector, name, true)
|
||||
.filter((context) => context !== SecurityContext.NONE);
|
||||
const securityContexts = calcHostBindingSecurityContexts(
|
||||
bindingParser,
|
||||
input.componentSelector,
|
||||
name,
|
||||
true,
|
||||
);
|
||||
ingestHostAttribute(job, name, expr, securityContexts);
|
||||
}
|
||||
for (const event of input.events ?? []) {
|
||||
@@ -136,6 +138,42 @@ export function ingestHostBinding(
|
||||
return job;
|
||||
}
|
||||
|
||||
function calcHostBindingSecurityContexts(
|
||||
bindingParser: BindingParser,
|
||||
selector: string,
|
||||
name: string,
|
||||
isAttribute: boolean,
|
||||
): SecurityContext[] {
|
||||
const declaringSelectorContexts = bindingParser.calcPossibleSecurityContexts(
|
||||
selector,
|
||||
name,
|
||||
isAttribute,
|
||||
);
|
||||
const concreteHostContexts = calcPossibleSecurityContexts(
|
||||
domSchema,
|
||||
null,
|
||||
domSchema.getMappedPropName(name),
|
||||
isAttribute,
|
||||
);
|
||||
const concreteHostNonNoneContexts = concreteHostContexts.filter(
|
||||
(context) => context !== SecurityContext.NONE,
|
||||
);
|
||||
const concreteHostNonNoneCount = concreteHostNonNoneContexts.length;
|
||||
const hasConcreteHostNoneContext = concreteHostNonNoneCount !== concreteHostContexts.length;
|
||||
|
||||
// Host bindings can run against a concrete host whose element name differs from the declaring
|
||||
// selector, including dynamic root components whose TNode name is `#host`.
|
||||
if (hasConcreteHostNoneContext && concreteHostNonNoneCount > 0) {
|
||||
return concreteHostContexts;
|
||||
}
|
||||
|
||||
if (concreteHostNonNoneContexts.some((context) => !declaringSelectorContexts.includes(context))) {
|
||||
return concreteHostContexts;
|
||||
}
|
||||
|
||||
return declaringSelectorContexts.filter((context) => context !== SecurityContext.NONE);
|
||||
}
|
||||
|
||||
// TODO: We should refactor the parser to use the same types and structures for host bindings as
|
||||
// with ordinary components. This would allow us to share a lot more ingestion code.
|
||||
export function ingestDomProperty(
|
||||
|
||||
@@ -393,7 +393,7 @@ export function projectionDef(def: o.Expression | null): ir.CreateOp {
|
||||
export function projection(
|
||||
slot: number,
|
||||
projectionSlotIndex: number,
|
||||
attributes: o.LiteralArrayExpr | null,
|
||||
attributes: o.Expression | null,
|
||||
fallbackFnName: string | null,
|
||||
fallbackDecls: number | null,
|
||||
fallbackVars: number | null,
|
||||
|
||||
@@ -45,7 +45,7 @@ export function collectElementConsts(job: CompilationJob): void {
|
||||
if (attributes !== undefined) {
|
||||
const attrArray = serializeAttributes(attributes);
|
||||
if (attrArray.entries.length > 0) {
|
||||
op.attributes = attrArray;
|
||||
op.attributes = job.pool.getConstLiteral(attrArray, true);
|
||||
}
|
||||
}
|
||||
} else if (ir.isElementOrContainerOp(op)) {
|
||||
@@ -186,10 +186,7 @@ class ElementAttributes {
|
||||
if (value === null) {
|
||||
throw Error('Attribute, i18n attribute, & style element attributes must have a value');
|
||||
}
|
||||
if (trustedValueFn !== null) {
|
||||
if (!ir.isStringLiteral(value)) {
|
||||
throw Error('AssertionError: extracted attribute value should be string literal');
|
||||
}
|
||||
if (trustedValueFn !== null && ir.isStringLiteral(value)) {
|
||||
array.push(
|
||||
o.taggedTemplate(
|
||||
trustedValueFn,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {SecurityContext} from '../../../../core';
|
||||
import * as o from '../../../../output/output_ast';
|
||||
import {Identifiers} from '../../../../render3/r3_identifiers';
|
||||
import * as ir from '../../ir';
|
||||
import {CompilationJob} from '../compilation';
|
||||
import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../namespaces';
|
||||
|
||||
/**
|
||||
* Wraps static i18n extracted attributes in their corresponding sanitizers/validators.
|
||||
*/
|
||||
export function resolveI18nAttrSanitizers(job: CompilationJob): void {
|
||||
const tagNamesByElement = new Map<ir.XrefId, string>();
|
||||
|
||||
for (const unit of job.units) {
|
||||
for (const op of unit.ops()) {
|
||||
if (op.kind === ir.OpKind.ElementStart || op.kind === ir.OpKind.Template) {
|
||||
let tag = op.tag ?? '';
|
||||
switch (op.namespace) {
|
||||
case ir.Namespace.SVG:
|
||||
tag = `:${SVG_NAMESPACE}:${tag}`;
|
||||
break;
|
||||
case ir.Namespace.Math:
|
||||
tag = `:${MATH_ML_NAMESPACE}:${tag}`;
|
||||
break;
|
||||
}
|
||||
|
||||
tagNamesByElement.set(op.xref, tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const unit of job.units) {
|
||||
for (const op of unit.create) {
|
||||
if (
|
||||
op.kind === ir.OpKind.ExtractedAttribute &&
|
||||
op.i18nContext !== null &&
|
||||
op.expression !== null
|
||||
) {
|
||||
const tagName = tagNamesByElement.get(op.target) ?? '';
|
||||
let expr = op.expression;
|
||||
switch (op.securityContext) {
|
||||
case SecurityContext.HTML:
|
||||
expr = o.importExpr(Identifiers.sanitizeHtml).callFn([expr]);
|
||||
break;
|
||||
case SecurityContext.STYLE:
|
||||
expr = o.importExpr(Identifiers.sanitizeStyle).callFn([expr]);
|
||||
break;
|
||||
case SecurityContext.SCRIPT:
|
||||
expr = o.importExpr(Identifiers.sanitizeScript).callFn([expr]);
|
||||
break;
|
||||
case SecurityContext.URL:
|
||||
expr = o.importExpr(Identifiers.sanitizeUrl).callFn([expr]);
|
||||
break;
|
||||
case SecurityContext.RESOURCE_URL:
|
||||
expr = o.importExpr(Identifiers.sanitizeResourceUrl).callFn([expr]);
|
||||
break;
|
||||
case SecurityContext.ATTRIBUTE_NO_BINDING:
|
||||
expr = o
|
||||
.importExpr(Identifiers.validateAttribute)
|
||||
.callFn([expr, o.literal(tagName), o.literal(op.name)]);
|
||||
break;
|
||||
}
|
||||
op.expression = expr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,17 +56,16 @@ export function resolveSanitizers(job: CompilationJob): void {
|
||||
case ir.OpKind.Property:
|
||||
case ir.OpKind.Attribute:
|
||||
case ir.OpKind.DomProperty:
|
||||
case ir.OpKind.TwoWayProperty:
|
||||
let sanitizerFn: o.ExternalReference | null = null;
|
||||
if (
|
||||
Array.isArray(op.securityContext) &&
|
||||
op.securityContext.length === 2 &&
|
||||
op.securityContext.includes(SecurityContext.URL) &&
|
||||
op.securityContext.includes(SecurityContext.RESOURCE_URL)
|
||||
hasCompositeUrlSecurityContext(op.securityContext)
|
||||
) {
|
||||
// When the host element isn't known, some URL attributes (such as "src" and "href") may
|
||||
// be part of multiple different security contexts. In this case we use special
|
||||
// sanitization function and select the actual sanitizer at runtime based on a tag name
|
||||
// that is provided while invoking sanitization function.
|
||||
// When the host element isn't known, attributes such as `href`, `src`, `data`,
|
||||
// `action`, and `codebase` may be part of multiple security contexts. In this case we
|
||||
// use a special sanitization function and select the actual behavior at runtime based
|
||||
// on the concrete host element.
|
||||
sanitizerFn = Identifiers.sanitizeUrlOrResourceUrl;
|
||||
} else {
|
||||
sanitizerFn = sanitizerFns.get(getOnlySecurityContext(op.securityContext)) ?? null;
|
||||
@@ -80,21 +79,56 @@ export function resolveSanitizers(job: CompilationJob): void {
|
||||
}
|
||||
}
|
||||
|
||||
function hasCompositeUrlSecurityContext(securityContext: SecurityContext[]): boolean {
|
||||
let hasUrlContext = false;
|
||||
let hasResourceUrlContext = false;
|
||||
let hasNoneContext = false;
|
||||
|
||||
for (const context of securityContext) {
|
||||
switch (context) {
|
||||
case SecurityContext.URL:
|
||||
hasUrlContext = true;
|
||||
break;
|
||||
case SecurityContext.RESOURCE_URL:
|
||||
hasResourceUrlContext = true;
|
||||
break;
|
||||
case SecurityContext.NONE:
|
||||
hasNoneContext = true;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
((hasUrlContext || hasResourceUrlContext) && hasNoneContext) ||
|
||||
(hasUrlContext && hasResourceUrlContext)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that there is only a single security context and returns it.
|
||||
* Asserts that there is only a single non-NONE security context and returns it.
|
||||
*/
|
||||
function getOnlySecurityContext(
|
||||
securityContext: SecurityContext | SecurityContext[],
|
||||
): SecurityContext {
|
||||
if (Array.isArray(securityContext)) {
|
||||
if (securityContext.length > 1) {
|
||||
// TODO: What should we do here? TDB just took the first one, but this feels like something we
|
||||
// would want to know about and create a special case for like we did for Url/ResourceUrl. My
|
||||
// guess is that, outside of the Url/ResourceUrl case, this never actually happens. If there
|
||||
// do turn out to be other cases, throwing an error until we can address it feels safer.
|
||||
throw Error(`AssertionError: Ambiguous security context`);
|
||||
}
|
||||
return securityContext[0] || SecurityContext.NONE;
|
||||
if (!Array.isArray(securityContext)) {
|
||||
return securityContext;
|
||||
}
|
||||
return securityContext;
|
||||
|
||||
if (securityContext.length < 2) {
|
||||
return securityContext[0] ?? SecurityContext.NONE;
|
||||
}
|
||||
|
||||
const nonNoneSecurityContexts = securityContext.filter(
|
||||
(context) => context !== SecurityContext.NONE,
|
||||
);
|
||||
if (nonNoneSecurityContexts.length > 1) {
|
||||
// TODO: What should we do here? TDB just took the first one, but this feels like something we
|
||||
// would want to know about and create a special case for like we did for Url/ResourceUrl. My
|
||||
// guess is that, outside of the Url/ResourceUrl case, this never actually happens. If there
|
||||
// do turn out to be other cases, throwing an error until we can address it feels safer.
|
||||
throw Error(`AssertionError: Ambiguous security context`);
|
||||
}
|
||||
return nonNoneSecurityContexts[0] ?? SecurityContext.NONE;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ const LINK_ELEMENT = 'link';
|
||||
const LINK_STYLE_REL_ATTR = 'rel';
|
||||
const LINK_STYLE_HREF_ATTR = 'href';
|
||||
const LINK_STYLE_REL_VALUE = 'stylesheet';
|
||||
const STYLE_ELEMENTS: ReadonlySet<string> = new Set([':svg:style', 'style']);
|
||||
const STYLE_ELEMENT = 'style';
|
||||
const SCRIPT_ELEMENTS: ReadonlySet<string> = new Set([':svg:script', 'script']);
|
||||
const NG_NON_BINDABLE_ATTR = 'ngNonBindable';
|
||||
const NG_PROJECT_AS = 'ngProjectAs';
|
||||
@@ -50,7 +50,7 @@ export function preparseElement(ast: html.Element): PreparsedElement {
|
||||
let type = PreparsedElementType.OTHER;
|
||||
if (isNgContent(nodeName)) {
|
||||
type = PreparsedElementType.NG_CONTENT;
|
||||
} else if (STYLE_ELEMENTS.has(nodeName)) {
|
||||
} else if (STYLE_ELEMENT === nodeName) {
|
||||
type = PreparsedElementType.STYLE;
|
||||
} else if (SCRIPT_ELEMENTS.has(nodeName)) {
|
||||
type = PreparsedElementType.SCRIPT;
|
||||
|
||||
@@ -910,7 +910,7 @@ describe('R3 template transform', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ignored elements', () => {
|
||||
describe('<script> and <style> elements', () => {
|
||||
it('should ignore <script> elements', () => {
|
||||
expectFromHtml('<script></script>a').toEqual([['Text', 'a']]);
|
||||
});
|
||||
@@ -918,6 +918,14 @@ describe('R3 template transform', () => {
|
||||
it('should ignore <style> elements', () => {
|
||||
expectFromHtml('<style></style>a').toEqual([['Text', 'a']]);
|
||||
});
|
||||
|
||||
it('should not ignore namespaced SVG <style> elements', () => {
|
||||
expectFromHtml('<svg><style>.a { fill: none; }</style></svg>').toEqual([
|
||||
['Element', ':svg:svg'],
|
||||
['Element', ':svg:style'],
|
||||
['Text', '.a { fill: none; }'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<link rel="stylesheet">', () => {
|
||||
|
||||
@@ -168,6 +168,12 @@ If 'onAnything' is a directive input, make sure the directive is imported by the
|
||||
expect(registry.securityContext(':svg:set', 'to', false)).toBe(
|
||||
SecurityContext.ATTRIBUTE_NO_BINDING,
|
||||
);
|
||||
|
||||
// SVG link attributes
|
||||
expect(registry.securityContext(':svg:a', 'href', false)).toBe(SecurityContext.URL);
|
||||
expect(registry.securityContext(':svg:a', 'xlink:href', false)).toBe(SecurityContext.URL);
|
||||
expect(registry.securityContext(':svg:a', 'href', true)).toBe(SecurityContext.URL);
|
||||
expect(registry.securityContext(':svg:a', 'xlink:href', true)).toBe(SecurityContext.URL);
|
||||
});
|
||||
|
||||
it('should detect properties on namespaced elements', () => {
|
||||
@@ -198,6 +204,23 @@ If 'onAnything' is a directive input, make sure the directive is imported by the
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom XML / XHTML namespaces', () => {
|
||||
it('should support elements with custom namespaces', () => {
|
||||
expect(registry.hasElement(':xhtml:a', [])).toBeTruthy();
|
||||
expect(registry.hasElement(':foo:div', [])).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should support properties on custom namespaced elements', () => {
|
||||
expect(registry.hasProperty(':xhtml:a', 'href', [])).toBeTruthy();
|
||||
expect(registry.hasProperty(':foo:div', 'id', [])).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return correct security contexts for custom namespaced elements', () => {
|
||||
expect(registry.securityContext(':xhtml:a', 'href', false)).toBe(SecurityContext.URL);
|
||||
expect(registry.securityContext(':foo:div', 'innerHTML', false)).toBe(SecurityContext.HTML);
|
||||
});
|
||||
});
|
||||
|
||||
// Uncomment to see the generated schema which can then be pasted to the DomElementSchemaRegistry
|
||||
// if (!isNode) {
|
||||
// it('generate a new schema', () => {
|
||||
|
||||
@@ -1,370 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
const SVG_PREFIX = ':svg:';
|
||||
const MATH_PREFIX = ':math:';
|
||||
|
||||
// Element | Node interfaces
|
||||
// see https://developer.mozilla.org/en-US/docs/Web/API/Element
|
||||
// see https://developer.mozilla.org/en-US/docs/Web/API/Node
|
||||
const ELEMENT_IF = '[Element]';
|
||||
// HTMLElement interface
|
||||
// see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement
|
||||
const HTMLELEMENT_IF = '[HTMLElement]';
|
||||
|
||||
const HTMLELEMENT_TAGS =
|
||||
'abbr,address,article,aside,b,bdi,bdo,cite,content,code,dd,dfn,dt,em,figcaption,figure,footer,header,hgroup,i,kbd,main,mark,nav,noscript,rb,rp,rt,rtc,ruby,s,samp,search,section,small,strong,sub,sup,u,var,wbr';
|
||||
|
||||
const ALL_HTML_TAGS =
|
||||
// https://www.w3.org/TR/html5/index.html
|
||||
'a,abbr,address,area,article,aside,audio,b,base,bdi,bdo,blockquote,body,br,button,canvas,caption,cite,code,col,colgroup,content,data,datalist,dd,del,dfn,div,dl,dt,em,embed,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,head,header,hgroup,hr,html,i,iframe,img,input,ins,kbd,keygen,label,legend,li,link,main,map,mark,meta,meter,nav,noscript,object,ol,optgroup,option,output,p,param,pre,progress,q,rb,rp,rt,rtc,ruby,s,samp,script,search,section,select,small,source,span,strong,style,sub,sup,table,tbody,td,template,textarea,tfoot,th,thead,time,title,tr,track,u,ul,var,video,wbr,' +
|
||||
// https://html.spec.whatwg.org/
|
||||
'details,summary,menu,menuitem,geolocation';
|
||||
|
||||
// Via https://developer.mozilla.org/en-US/docs/Web/MathML
|
||||
const ALL_MATH_TAGS =
|
||||
'math,maction,menclose,merror,mfenced,mfrac,mi,mmultiscripts,mn,mo,mover,mpadded,mphantom,mroot,mrow,ms,mspace,msqrt,mstyle,msub,msubsup,msup,mtable,mtd,mtext,mtr,munder,munderover,semantics';
|
||||
|
||||
// Elements missing from Chrome (HtmlUnknownElement), to be manually added
|
||||
const MISSING_FROM_CHROME: {[el: string]: string[]} = {
|
||||
'data^[HTMLElement]': ['value'],
|
||||
'keygen^[HTMLElement]': ['!autofocus', 'challenge', '!disabled', 'form', 'keytype', 'name'],
|
||||
// TODO(vicb): Figure out why Chrome and WhatWG do not agree on the props
|
||||
// 'menu^[HTMLElement]': ['type', 'label'],
|
||||
'menuitem^[HTMLElement]': [
|
||||
'type',
|
||||
'label',
|
||||
'icon',
|
||||
'!disabled',
|
||||
'!checked',
|
||||
'radiogroup',
|
||||
'!default',
|
||||
],
|
||||
'summary^[HTMLElement]': [],
|
||||
'time^[HTMLElement]': ['dateTime'],
|
||||
':svg:cursor^:svg:': [],
|
||||
};
|
||||
|
||||
const _G: any =
|
||||
(typeof window != 'undefined' && window) ||
|
||||
(typeof global != 'undefined' && global) ||
|
||||
(typeof self != 'undefined' && self);
|
||||
|
||||
const document: any = typeof _G['document'] == 'object' ? _G['document'] : null;
|
||||
|
||||
export function extractSchema(): Map<string, string[]> | null {
|
||||
if (!document) return null;
|
||||
const SVGGraphicsElement = _G['SVGGraphicsElement'];
|
||||
if (!SVGGraphicsElement) return null;
|
||||
|
||||
const element = document.createElement('video');
|
||||
const descMap: Map<string, string[]> = new Map();
|
||||
const visited: {[name: string]: boolean} = {};
|
||||
|
||||
// HTML top level
|
||||
extractProperties(Node, element, visited, descMap, ELEMENT_IF, '');
|
||||
extractProperties(Element, element, visited, descMap, ELEMENT_IF, '');
|
||||
extractProperties(HTMLElement, element, visited, descMap, HTMLELEMENT_IF, ELEMENT_IF);
|
||||
extractProperties(HTMLElement, element, visited, descMap, HTMLELEMENT_TAGS, HTMLELEMENT_IF);
|
||||
extractProperties(HTMLMediaElement, element, visited, descMap, 'media', HTMLELEMENT_IF);
|
||||
|
||||
// SVG top level
|
||||
const svgAnimation = document.createElementNS('http://www.w3.org/2000/svg', 'set');
|
||||
const svgPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
const svgFeFuncA = document.createElementNS('http://www.w3.org/2000/svg', 'feFuncA');
|
||||
const svgGradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
|
||||
const svgText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
|
||||
const SVGAnimationElement = _G['SVGAnimationElement'];
|
||||
const SVGGeometryElement = _G['SVGGeometryElement'];
|
||||
const SVGComponentTransferFunctionElement = _G['SVGComponentTransferFunctionElement'];
|
||||
const SVGGradientElement = _G['SVGGradientElement'];
|
||||
const SVGTextContentElement = _G['SVGTextContentElement'];
|
||||
const SVGTextPositioningElement = _G['SVGTextPositioningElement'];
|
||||
extractProperties(SVGElement, svgText, visited, descMap, SVG_PREFIX, HTMLELEMENT_IF);
|
||||
|
||||
extractProperties(
|
||||
SVGGraphicsElement,
|
||||
svgText,
|
||||
visited,
|
||||
descMap,
|
||||
SVG_PREFIX + 'graphics',
|
||||
SVG_PREFIX,
|
||||
);
|
||||
extractProperties(
|
||||
SVGAnimationElement,
|
||||
svgAnimation,
|
||||
visited,
|
||||
descMap,
|
||||
SVG_PREFIX + 'animation',
|
||||
SVG_PREFIX,
|
||||
);
|
||||
extractProperties(
|
||||
SVGGeometryElement,
|
||||
svgPath,
|
||||
visited,
|
||||
descMap,
|
||||
SVG_PREFIX + 'geometry',
|
||||
SVG_PREFIX,
|
||||
);
|
||||
extractProperties(
|
||||
SVGComponentTransferFunctionElement,
|
||||
svgFeFuncA,
|
||||
visited,
|
||||
descMap,
|
||||
SVG_PREFIX + 'componentTransferFunction',
|
||||
SVG_PREFIX,
|
||||
);
|
||||
extractProperties(
|
||||
SVGGradientElement,
|
||||
svgGradient,
|
||||
visited,
|
||||
descMap,
|
||||
SVG_PREFIX + 'gradient',
|
||||
SVG_PREFIX,
|
||||
);
|
||||
extractProperties(
|
||||
SVGTextContentElement,
|
||||
svgText,
|
||||
visited,
|
||||
descMap,
|
||||
SVG_PREFIX + 'textContent',
|
||||
SVG_PREFIX + 'graphics',
|
||||
);
|
||||
extractProperties(
|
||||
SVGTextPositioningElement,
|
||||
svgText,
|
||||
visited,
|
||||
descMap,
|
||||
SVG_PREFIX + 'textPositioning',
|
||||
SVG_PREFIX + 'textContent',
|
||||
);
|
||||
|
||||
// Get all element types
|
||||
const types = Object.getOwnPropertyNames(window).filter((k) => /^(HTML|SVG).*?Element$/.test(k));
|
||||
|
||||
types.sort();
|
||||
|
||||
types.forEach((type) => {
|
||||
extractRecursiveProperties(visited, descMap, (window as any)[type]);
|
||||
});
|
||||
|
||||
// Add elements missed by Chrome auto-detection
|
||||
Object.keys(MISSING_FROM_CHROME).forEach((elHierarchy) => {
|
||||
descMap.set(elHierarchy, MISSING_FROM_CHROME[elHierarchy]);
|
||||
});
|
||||
|
||||
// Needed because we're running tests against some older Android versions.
|
||||
if (typeof MathMLElement !== 'undefined') {
|
||||
// Math top level
|
||||
const math = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'math');
|
||||
extractProperties(MathMLElement, math, visited, descMap, MATH_PREFIX, HTMLELEMENT_IF);
|
||||
|
||||
// This script is written under the assumption that each tag has a corresponding class name, e.g.
|
||||
// `<circle>` -> `SVGCircleElement` however this doesn't hold for Math elements which are all
|
||||
// `MathMLElement`. Furthermore, they don't have special property names, but rather are
|
||||
// configured exclusively via attributes. Register them as plain elements that inherit from
|
||||
// the top-level `:math` namespace.
|
||||
ALL_MATH_TAGS.split(',').forEach((tag) =>
|
||||
descMap.set(`${MATH_PREFIX}${tag}^${MATH_PREFIX}`, []),
|
||||
);
|
||||
}
|
||||
|
||||
assertNoMissingTags(descMap);
|
||||
|
||||
return descMap;
|
||||
}
|
||||
|
||||
function assertNoMissingTags(descMap: Map<string, string[]>): void {
|
||||
const extractedTags: string[] = [];
|
||||
|
||||
Array.from(descMap.keys()).forEach((key: string) => {
|
||||
extractedTags.push(...key.split('|')[0].split('^')[0].split(','));
|
||||
});
|
||||
|
||||
const missingTags = [
|
||||
...ALL_HTML_TAGS.split(','),
|
||||
...(typeof MathMLElement === 'undefined'
|
||||
? []
|
||||
: ALL_MATH_TAGS.split(',').map((tag) => MATH_PREFIX + tag)),
|
||||
].filter((tag) => !extractedTags.includes(tag));
|
||||
|
||||
if (missingTags.length) {
|
||||
throw new Error(`DOM schema misses tags: ${missingTags.join(',')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function extractRecursiveProperties(
|
||||
visited: {[name: string]: boolean},
|
||||
descMap: Map<string, string[]>,
|
||||
type: Function,
|
||||
): string {
|
||||
const name = extractName(type)!;
|
||||
|
||||
if (visited[name]) {
|
||||
return name;
|
||||
}
|
||||
|
||||
let superName: string;
|
||||
switch (name) {
|
||||
case ELEMENT_IF:
|
||||
// ELEMENT_IF is the top most interface (Element | Node)
|
||||
superName = '';
|
||||
break;
|
||||
case HTMLELEMENT_IF:
|
||||
superName = ELEMENT_IF;
|
||||
break;
|
||||
default:
|
||||
superName = extractRecursiveProperties(
|
||||
visited,
|
||||
descMap,
|
||||
type.prototype.__proto__.constructor,
|
||||
);
|
||||
}
|
||||
|
||||
let instance: HTMLElement | null = null;
|
||||
name.split(',').forEach((tagName) => {
|
||||
instance = type['name'].startsWith('SVG')
|
||||
? document.createElementNS('http://www.w3.org/2000/svg', tagName.replace(SVG_PREFIX, ''))
|
||||
: document.createElement(tagName);
|
||||
|
||||
let htmlType: Function;
|
||||
|
||||
switch (tagName) {
|
||||
case 'cite':
|
||||
// <cite> interface is `HTMLQuoteElement`
|
||||
htmlType = HTMLElement;
|
||||
break;
|
||||
default:
|
||||
htmlType = type;
|
||||
}
|
||||
|
||||
if (!(instance instanceof htmlType)) {
|
||||
throw new Error(`Tag <${tagName}> is not an instance of ${htmlType['name']}`);
|
||||
}
|
||||
});
|
||||
|
||||
extractProperties(type, instance, visited, descMap, name, superName);
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
function extractProperties(
|
||||
type: Function,
|
||||
instance: any,
|
||||
visited: {[name: string]: boolean},
|
||||
descMap: Map<string, string[]>,
|
||||
name: string,
|
||||
superName: string,
|
||||
) {
|
||||
if (!type) return;
|
||||
|
||||
visited[name] = true;
|
||||
|
||||
const fullName = name + (superName ? '^' + superName : '');
|
||||
|
||||
const props: string[] = descMap.has(fullName) ? descMap.get(fullName)! : [];
|
||||
|
||||
const prototype = type.prototype;
|
||||
const keys = Object.getOwnPropertyNames(prototype);
|
||||
|
||||
keys.sort();
|
||||
keys.forEach((name) => {
|
||||
if (name.startsWith('on')) {
|
||||
props.push('*' + name.slice(2));
|
||||
} else {
|
||||
const typeCh = _TYPE_MNEMONICS[typeof instance[name]];
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, name);
|
||||
const isSetter = descriptor && descriptor.set;
|
||||
if (typeCh !== void 0 && !name.startsWith('webkit') && isSetter) {
|
||||
props.push(typeCh + name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// There is no point in using `Node.nodeValue`, filter it out
|
||||
descMap.set(fullName, type === Node ? props.filter((p) => p != '%nodeValue') : props);
|
||||
}
|
||||
|
||||
function extractName(type: Function): string | null {
|
||||
let name = type['name'];
|
||||
|
||||
// The polyfill @webcomponents/custom-element/src/native-shim.js overrides the
|
||||
// window.HTMLElement and does not have the name property. Check if this is the
|
||||
// case and if so, set the name manually.
|
||||
if (name === '' && type === HTMLElement) {
|
||||
name = 'HTMLElement';
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
// see https://www.w3.org/TR/html5/index.html
|
||||
// TODO(vicb): generate this map from all the element types
|
||||
case 'Element':
|
||||
return ELEMENT_IF;
|
||||
case 'HTMLElement':
|
||||
return HTMLELEMENT_IF;
|
||||
case 'HTMLImageElement':
|
||||
return 'img';
|
||||
case 'HTMLAnchorElement':
|
||||
return 'a';
|
||||
case 'HTMLDListElement':
|
||||
return 'dl';
|
||||
case 'HTMLDirectoryElement':
|
||||
return 'dir';
|
||||
case 'HTMLHeadingElement':
|
||||
return 'h1,h2,h3,h4,h5,h6';
|
||||
case 'HTMLModElement':
|
||||
return 'ins,del';
|
||||
case 'HTMLOListElement':
|
||||
return 'ol';
|
||||
case 'HTMLParagraphElement':
|
||||
return 'p';
|
||||
case 'HTMLQuoteElement':
|
||||
return 'q,blockquote,cite';
|
||||
case 'HTMLTableCaptionElement':
|
||||
return 'caption';
|
||||
case 'HTMLTableCellElement':
|
||||
return 'th,td';
|
||||
case 'HTMLTableColElement':
|
||||
return 'col,colgroup';
|
||||
case 'HTMLTableRowElement':
|
||||
return 'tr';
|
||||
case 'HTMLTableSectionElement':
|
||||
return 'tfoot,thead,tbody';
|
||||
case 'HTMLUListElement':
|
||||
return 'ul';
|
||||
case 'SVGGraphicsElement':
|
||||
return SVG_PREFIX + 'graphics';
|
||||
case 'SVGMPathElement':
|
||||
return SVG_PREFIX + 'mpath';
|
||||
case 'SVGSVGElement':
|
||||
return SVG_PREFIX + 'svg';
|
||||
case 'SVGTSpanElement':
|
||||
return SVG_PREFIX + 'tspan';
|
||||
default:
|
||||
const isSVG = name.startsWith('SVG');
|
||||
if (name.startsWith('HTML') || isSVG) {
|
||||
name = name.replace('HTML', '').replace('SVG', '').replace('Element', '');
|
||||
if (isSVG && name.startsWith('FE')) {
|
||||
name = 'fe' + name.substring(2);
|
||||
} else if (name) {
|
||||
name = name.charAt(0).toLowerCase() + name.substring(1);
|
||||
}
|
||||
return isSVG ? SVG_PREFIX + name : name.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const _TYPE_MNEMONICS: {[type: string]: string} = {
|
||||
'string': '',
|
||||
'number': '#',
|
||||
'boolean': '!',
|
||||
'object': '%',
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
load("@bazel_lib//lib:write_source_files.bzl", "write_source_file")
|
||||
load("//adev/shared-docs/pipeline/api-gen:generate_api_docs.bzl", "generate_api_docs")
|
||||
load("//packages/common/locales:index.bzl", "generate_base_locale_file")
|
||||
load("//tools:defaults.bzl", "api_golden_test", "api_golden_test_npm_package", "ng_package", "ng_project", "ts_config", "tsec_test")
|
||||
@@ -151,3 +152,10 @@ genrule(
|
||||
outs = ["event-dispatch-contract.min.js"],
|
||||
cmd = "cp $< $@",
|
||||
)
|
||||
|
||||
write_source_file(
|
||||
name = "dom_security_schema",
|
||||
check_that_out_file_exists = False,
|
||||
in_file = "//packages/compiler:src/schema/dom_security_schema.ts",
|
||||
out_file = ":src/sanitization/dom_security_schema.ts",
|
||||
)
|
||||
|
||||
@@ -95,7 +95,7 @@ export * from './core_private_export';
|
||||
export * from './core_render3_private_export';
|
||||
export * from './core_reactivity_export';
|
||||
export * from './resource';
|
||||
export {SecurityContext} from './sanitization/security';
|
||||
export {SecurityContext} from './sanitization/dom_security_schema';
|
||||
export {Sanitizer} from './sanitization/sanitizer';
|
||||
export {
|
||||
createNgModule,
|
||||
|
||||
@@ -25,6 +25,8 @@ import {ERROR_DETAILS_PAGE_BASE_URL} from './error_details_base_url';
|
||||
* - animations: 3000-3999
|
||||
* - router: 4000-4999
|
||||
* - platform-browser: 5000-5500
|
||||
* - service-worker: 5600-5699
|
||||
* - platform-server: 5700-5800
|
||||
*/
|
||||
export const enum RuntimeErrorCode {
|
||||
// Change Detection Errors
|
||||
|
||||
@@ -11,9 +11,11 @@ import {global} from '../util/global';
|
||||
import localeEn from './locale_en';
|
||||
|
||||
/**
|
||||
* This const is used to store the locale data registered with `registerLocaleData`
|
||||
* This const is used to store the locale data registered with `registerLocaleData`.
|
||||
* Use `Object.create(null)` to prevent prototype pollution.
|
||||
*/
|
||||
let LOCALE_DATA: {[localeId: string]: any} = {};
|
||||
// tslint:disable-next-line:no-toplevel-property-access
|
||||
let LOCALE_DATA: {[localeId: string]: any} = /* @__PURE__ */ Object.create(null);
|
||||
|
||||
/**
|
||||
* Register locale data to be used internally by Angular. See the
|
||||
@@ -102,11 +104,20 @@ export function getLocalePluralCase(locale: string): (value: number) => number {
|
||||
*/
|
||||
export function getLocaleData(normalizedLocale: string): any {
|
||||
if (!(normalizedLocale in LOCALE_DATA)) {
|
||||
LOCALE_DATA[normalizedLocale] =
|
||||
const globalLocaleData =
|
||||
global.ng &&
|
||||
global.ng.common &&
|
||||
global.ng.common.locales &&
|
||||
global.ng.common.locales[normalizedLocale];
|
||||
// Only cache global locale data when an entry is actually found, to avoid
|
||||
// caching missing lookups. In SSR this cache is process-wide across requests,
|
||||
// so caching `undefined` would retain attacker-controlled locale identifiers
|
||||
// indefinitely. It would also make the `in` check above short-circuit on
|
||||
// subsequent lookups and skip the global fallback.
|
||||
if (globalLocaleData !== undefined) {
|
||||
LOCALE_DATA[normalizedLocale] = globalLocaleData;
|
||||
}
|
||||
return globalLocaleData;
|
||||
}
|
||||
return LOCALE_DATA[normalizedLocale];
|
||||
}
|
||||
@@ -115,7 +126,7 @@ export function getLocaleData(normalizedLocale: string): any {
|
||||
* Helper function to remove all the locale data from `LOCALE_DATA`.
|
||||
*/
|
||||
export function unregisterAllLocaleData() {
|
||||
LOCALE_DATA = {};
|
||||
LOCALE_DATA = Object.create(null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,7 @@ import {ComponentFactoryResolver as AbstractComponentFactoryResolver} from '../l
|
||||
import {createElementRef, ElementRef} from '../linker/element_ref';
|
||||
import {NgModuleRef} from '../linker/ng_module_factory';
|
||||
import {RendererFactory2} from '../render/api';
|
||||
import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../sanitization/dom_security_schema';
|
||||
import {Sanitizer} from '../sanitization/sanitizer';
|
||||
|
||||
import {assertComponentType} from './assert';
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
TElementContainerNode,
|
||||
TElementNode,
|
||||
TNode,
|
||||
TNodeName,
|
||||
TNodeType,
|
||||
} from './interfaces/node';
|
||||
import {RElement, RNode} from './interfaces/renderer_dom';
|
||||
@@ -60,7 +62,6 @@ import {
|
||||
TVIEW,
|
||||
TViewType,
|
||||
} from './interfaces/view';
|
||||
import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from './namespaces';
|
||||
|
||||
import {retrieveHydrationInfo} from '../hydration/utils';
|
||||
import {ChainedInjector} from './chained_injector';
|
||||
@@ -197,6 +198,15 @@ function createHostElement(componentDef: ComponentDef<unknown>, renderer: Render
|
||||
return createElementNode(renderer, tagName, namespace);
|
||||
}
|
||||
|
||||
function assertNotScriptHostElement(tagName: string | null | undefined): void {
|
||||
if (tagName?.toLowerCase() === 'script') {
|
||||
throw new RuntimeError(
|
||||
RuntimeErrorCode.UNSAFE_VALUE_IN_SCRIPT,
|
||||
ngDevMode && `"<script>" tag is not allowed as a component host element.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infers the tag name that should be used for a component based on its definition.
|
||||
* @param componentDef Definition for which to resolve the tag name.
|
||||
@@ -318,6 +328,7 @@ export class ComponentFactory<T> extends AbstractComponentFactory<T> {
|
||||
const hostElement = rootSelectorOrNode
|
||||
? locateHostElement(hostRenderer, rootSelectorOrNode, cmpDef.encapsulation, rootViewInjector)
|
||||
: createHostElement(cmpDef, hostRenderer);
|
||||
assertNotScriptHostElement(hostElement?.tagName);
|
||||
const hasInputBindings =
|
||||
componentBindings?.some(isInputBinding) ||
|
||||
directives?.some((d) => typeof d !== 'function' && d.bindings.some(isInputBinding));
|
||||
@@ -352,7 +363,7 @@ export class ComponentFactory<T> extends AbstractComponentFactory<T> {
|
||||
HEADER_OFFSET,
|
||||
rootLView,
|
||||
TNodeType.Element,
|
||||
'#host',
|
||||
TNodeName.DynamicHost,
|
||||
() => rootTView.directiveRegistry,
|
||||
true,
|
||||
0,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {RuntimeError, RuntimeErrorCode} from '../../errors';
|
||||
import {Type, Writable} from '../../interface/type';
|
||||
import {EMPTY_ARRAY, EMPTY_OBJ} from '../../util/empty';
|
||||
import {fillProperties} from '../../util/property';
|
||||
import {NG_COMP_DEF, NG_DIR_DEF} from '../fields';
|
||||
import {
|
||||
ComponentDef,
|
||||
ContentQueriesFunction,
|
||||
@@ -45,13 +46,20 @@ export function ɵɵInheritDefinitionFeature(
|
||||
let shouldInheritFields = true;
|
||||
const inheritanceChain: WritableDef[] = [definition];
|
||||
|
||||
while (superType) {
|
||||
// Only accept defs declared on the current type to avoid polluted prototype members.
|
||||
// Don't use getComponentDef/getDirectiveDef. This logic relies on inheritance.
|
||||
while (superType && superType !== Function.prototype && superType !== Object.prototype) {
|
||||
let superDef: DirectiveDef<any> | ComponentDef<any> | undefined = undefined;
|
||||
const cmpDef = Object.hasOwn(superType, NG_COMP_DEF)
|
||||
? ((superType as any)[NG_COMP_DEF] as ComponentDef<any>)
|
||||
: undefined;
|
||||
const dirDef = Object.hasOwn(superType, NG_DIR_DEF)
|
||||
? ((superType as any)[NG_DIR_DEF] as DirectiveDef<any>)
|
||||
: undefined;
|
||||
if (isComponentDef(definition)) {
|
||||
// Don't use getComponentDef/getDirectiveDef. This logic relies on inheritance.
|
||||
superDef = superType.ɵcmp || superType.ɵdir;
|
||||
superDef = cmpDef ?? dirDef;
|
||||
} else {
|
||||
if (superType.ɵcmp) {
|
||||
if (cmpDef) {
|
||||
throw new RuntimeError(
|
||||
RuntimeErrorCode.INVALID_INHERITANCE,
|
||||
ngDevMode &&
|
||||
@@ -60,8 +68,7 @@ export function ɵɵInheritDefinitionFeature(
|
||||
)} is attempting to extend component ${stringifyForError(superType)}`,
|
||||
);
|
||||
}
|
||||
// Don't use getComponentDef/getDirectiveDef. This logic relies on inheritance.
|
||||
superDef = superType.ɵdir;
|
||||
superDef = dirDef;
|
||||
}
|
||||
|
||||
if (superDef) {
|
||||
|
||||
@@ -70,7 +70,7 @@ export function ɵɵgetReplaceMetadataURL(id: string, timestamp: string, base: s
|
||||
* Replaces the metadata of a component type and re-renders all live instances of the component.
|
||||
* @param type Class whose metadata will be replaced.
|
||||
* @param applyMetadata Callback that will apply a new set of metadata on the `type` when invoked.
|
||||
* @param environment Syntehtic namespace imports that need to be passed along to the callback.
|
||||
* @param environment Synthetic namespace imports that need to be passed along to the callback.
|
||||
* @param locals Local symbols from the source location that have to be exposed to the callback.
|
||||
* @param importMeta `import.meta` from the call site of the replacement function. Optional since
|
||||
* it isn't used internally.
|
||||
|
||||
@@ -49,8 +49,10 @@ import {
|
||||
} from '../dom_node_manipulation';
|
||||
import {
|
||||
getBindingIndex,
|
||||
getSelectedIndex,
|
||||
isInSkipHydrationBlock,
|
||||
lastNodeWasCreated,
|
||||
setSelectedIndex,
|
||||
wasLastNodeCreated,
|
||||
} from '../state';
|
||||
import {renderStringify} from '../util/stringify_utils';
|
||||
@@ -442,14 +444,20 @@ function applyUpdateOpCodes(
|
||||
sanitizeFn,
|
||||
);
|
||||
} else {
|
||||
setPropertyAndInputs(
|
||||
tNodeOrTagName,
|
||||
lView,
|
||||
propName,
|
||||
value,
|
||||
lView[RENDERER],
|
||||
sanitizeFn,
|
||||
);
|
||||
const prevSelectedIndex = getSelectedIndex();
|
||||
setSelectedIndex(nodeIndex);
|
||||
try {
|
||||
setPropertyAndInputs(
|
||||
tNodeOrTagName,
|
||||
lView,
|
||||
propName,
|
||||
value,
|
||||
lView[RENDERER],
|
||||
sanitizeFn,
|
||||
);
|
||||
} finally {
|
||||
setSelectedIndex(prevSelectedIndex);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case I18nUpdateOpCode.Text:
|
||||
|
||||
@@ -9,18 +9,17 @@ import '../../util/ng_dev_mode';
|
||||
import '../../util/ng_i18n_closure_mode';
|
||||
|
||||
import {XSS_SECURITY_URL} from '../../error_details_base_url';
|
||||
import {
|
||||
getTemplateContent,
|
||||
SENSITIVE_ATTRS,
|
||||
VALID_ATTRS,
|
||||
VALID_ELEMENTS,
|
||||
} from '../../sanitization/html_sanitizer';
|
||||
import {getTemplateContent, VALID_ATTRS, VALID_ELEMENTS} from '../../sanitization/html_sanitizer';
|
||||
import {getInertBodyHelper} from '../../sanitization/inert_body';
|
||||
import {_sanitizeUrl} from '../../sanitization/url_sanitizer';
|
||||
import {
|
||||
ɵɵsanitizeHtml as _sanitizeHtml,
|
||||
ɵɵsanitizeStyle as _sanitizeStyle,
|
||||
ɵɵsanitizeScript as _sanitizeScript,
|
||||
ɵɵsanitizeResourceUrl as _sanitizeResourceUrl,
|
||||
ɵɵvalidateAttribute as _validateAttribute,
|
||||
SECURITY_SENSITIVE_ELEMENTS,
|
||||
} from '../../sanitization/sanitization';
|
||||
import {SECURITY_SCHEMA, SecurityContext} from '../../sanitization/dom_security_schema';
|
||||
import {
|
||||
assertDefined,
|
||||
assertEqual,
|
||||
@@ -73,6 +72,8 @@ import {
|
||||
setTNodeInsertBeforeIndex,
|
||||
} from './i18n_util';
|
||||
import {createTNodeAtIndex} from '../tnode_manipulation';
|
||||
import {MATH_ML_NAMESPACE, NAMESPACE_URIS, SVG_NAMESPACE} from '../namespaces';
|
||||
import {splitNsName} from '../util/tags';
|
||||
import {allocExpando} from '../view/construction';
|
||||
|
||||
const BINDING_REGEXP = /<2F>(\d+):?\d*<2A>/gi;
|
||||
@@ -386,13 +387,16 @@ export function i18nAttributesFirstPass(tView: TView, index: number, values: str
|
||||
// the compiler treats static i18n attributes as regular attribute bindings.
|
||||
// Since this may not be the first i18n attribute on this element we need to pass in how
|
||||
// many previous bindings there have already been.
|
||||
const tagName = previousElement.namespace
|
||||
? `:${previousElement.namespace}:${previousElement.value}`
|
||||
: previousElement.value;
|
||||
generateBindingUpdateOpCodes(
|
||||
updateOpCodes,
|
||||
message,
|
||||
previousElementIndex,
|
||||
attrName,
|
||||
countBindings(updateOpCodes),
|
||||
i18nSanitizeAttribute(attrName),
|
||||
i18nResolveSanitizer(attrName, tagName),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -812,6 +816,10 @@ function walkIcuTree(
|
||||
const attr = elAttrs.item(i)!;
|
||||
const lowerAttrName = attr.name.toLowerCase();
|
||||
const hasBinding = !!attr.value.match(BINDING_REGEXP);
|
||||
const namespaceUri = element.namespaceURI;
|
||||
const namespace = namespaceUri && NAMESPACE_URIS[namespaceUri];
|
||||
const tagNameWithNamespace = namespace ? `:${namespace}:${tagName}` : tagName;
|
||||
|
||||
if (hasBinding) {
|
||||
if (VALID_ATTRS.hasOwnProperty(lowerAttrName)) {
|
||||
generateBindingUpdateOpCodes(
|
||||
@@ -820,7 +828,7 @@ function walkIcuTree(
|
||||
newIndex,
|
||||
attr.name,
|
||||
0,
|
||||
i18nSanitizeAttribute(lowerAttrName),
|
||||
i18nResolveSanitizer(lowerAttrName, tagNameWithNamespace),
|
||||
);
|
||||
} else {
|
||||
ngDevMode &&
|
||||
@@ -831,9 +839,9 @@ function walkIcuTree(
|
||||
);
|
||||
}
|
||||
} else if (VALID_ATTRS[lowerAttrName]) {
|
||||
if (SENSITIVE_ATTRS[lowerAttrName]) {
|
||||
// Don't sanitize, because no value is acceptable in sensitive attributes.
|
||||
// Translators are not allowed to create URIs.
|
||||
let val = attr.value;
|
||||
const sanitizer = i18nResolveSanitizer(lowerAttrName, tagNameWithNamespace);
|
||||
if (sanitizer) {
|
||||
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
|
||||
console.warn(
|
||||
`WARNING: ignoring unsafe attribute ` +
|
||||
@@ -841,9 +849,10 @@ function walkIcuTree(
|
||||
`(see ${XSS_SECURITY_URL})`,
|
||||
);
|
||||
}
|
||||
|
||||
addCreateAttribute(create, newIndex, attr.name, 'unsafe:blocked');
|
||||
} else {
|
||||
addCreateAttribute(create, newIndex, attr.name, attr.value);
|
||||
addCreateAttribute(create, newIndex, attr.name, val);
|
||||
}
|
||||
} else {
|
||||
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
|
||||
@@ -974,32 +983,38 @@ function addCreateAttribute(
|
||||
create.push((newIndex << IcuCreateOpCode.SHIFT_REF) | IcuCreateOpCode.Attr, attrName, attrValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Caches all keys of `SECURITY_SENSITIVE_ELEMENTS` in a Set to avoid recomputing
|
||||
* or scanning them on every invocation.
|
||||
*/
|
||||
const SECURITY_SENSITIVE_ATTRS: ReadonlySet<string> = /* @__PURE__ */ (() =>
|
||||
new Set(
|
||||
Object.values(SECURITY_SENSITIVE_ELEMENTS).flatMap((attrs) =>
|
||||
attrs ? Object.keys(attrs) : [],
|
||||
),
|
||||
))();
|
||||
function normalizeTagName(tagName: string): string {
|
||||
const tagNameLower = tagName.toLowerCase();
|
||||
const [ns, name] = splitNsName(tagNameLower, false);
|
||||
|
||||
/**
|
||||
* Returns a sanitizer for the given attribute name or null if the attribute is not security sensitive.
|
||||
*
|
||||
* @param attrName The name of the attribute to sanitize.
|
||||
* @returns The sanitizer for the given attribute name.
|
||||
*/
|
||||
function i18nSanitizeAttribute(attrName: string): SanitizerFn | null {
|
||||
const lowerAttrName = attrName.toLowerCase();
|
||||
if (SENSITIVE_ATTRS[lowerAttrName]) {
|
||||
return _sanitizeUrl;
|
||||
}
|
||||
|
||||
if (SECURITY_SENSITIVE_ATTRS.has(lowerAttrName)) {
|
||||
return _validateAttribute;
|
||||
}
|
||||
|
||||
return null;
|
||||
return ns === SVG_NAMESPACE || ns === MATH_ML_NAMESPACE ? `:${ns}:${name}` : name;
|
||||
}
|
||||
|
||||
function i18nResolveSanitizer(attrName: string, tagName?: string): SanitizerFn | null {
|
||||
const lowerAttrName = attrName.toLowerCase();
|
||||
const lowerTagName = tagName ? normalizeTagName(tagName) : '*';
|
||||
const [namespace] = splitNsName(lowerTagName, false);
|
||||
const schema = SECURITY_SCHEMA();
|
||||
const schemaContext =
|
||||
schema[`${lowerTagName}|${lowerAttrName}`] ||
|
||||
(namespace ? schema[`:${namespace}:*|${lowerAttrName}`] : undefined) ||
|
||||
schema[`*|${lowerAttrName}`] ||
|
||||
SecurityContext.NONE;
|
||||
|
||||
switch (schemaContext) {
|
||||
case SecurityContext.HTML:
|
||||
return _sanitizeHtml;
|
||||
case SecurityContext.STYLE:
|
||||
return _sanitizeStyle;
|
||||
case SecurityContext.SCRIPT:
|
||||
return _sanitizeScript;
|
||||
case SecurityContext.URL:
|
||||
return _sanitizeUrl;
|
||||
case SecurityContext.RESOURCE_URL:
|
||||
return _sanitizeResourceUrl;
|
||||
case SecurityContext.ATTRIBUTE_NO_BINDING:
|
||||
return _validateAttribute;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {stringify} from '../../util/stringify';
|
||||
import {assertFirstCreatePass, assertHasParent, assertLView} from '../assert';
|
||||
import {attachPatchData} from '../context_discovery';
|
||||
import {getNodeInjectable, getOrCreateNodeInjectorForNode} from '../di';
|
||||
import {RuntimeError, RuntimeErrorCode} from '../../errors';
|
||||
import {throwMultipleComponentError} from '../errors';
|
||||
import {ComponentDef, ComponentTemplate, DirectiveDef, RenderFlags} from '../interfaces/definition';
|
||||
import {
|
||||
@@ -181,12 +180,6 @@ export function locateHostElement(
|
||||
encapsulation === ViewEncapsulation.ShadowDom ||
|
||||
encapsulation === ViewEncapsulation.ExperimentalIsolatedShadowDom;
|
||||
const rootElement = renderer.selectRootElement(elementOrSelector, preserveContent);
|
||||
if (rootElement.tagName.toLowerCase() === 'script') {
|
||||
throw new RuntimeError(
|
||||
RuntimeErrorCode.UNSAFE_VALUE_IN_SCRIPT,
|
||||
ngDevMode && `"<script>" tag is not allowed as a component host element.`,
|
||||
);
|
||||
}
|
||||
applyRootElementTransform(rootElement as HTMLElement);
|
||||
return rootElement;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@ import {CssSelector} from './projection';
|
||||
import {RNode} from './renderer_dom';
|
||||
import type {LView, TView} from './view';
|
||||
|
||||
/**
|
||||
* Internal tag name used for a root host `TNode` when Angular creates a component against an
|
||||
* existing host element. The concrete DOM tag is resolved from the native element at runtime.
|
||||
*/
|
||||
export const enum TNodeName {
|
||||
DynamicHost = '#host',
|
||||
}
|
||||
|
||||
/**
|
||||
* TNodeType corresponds to the {@link TNode} `type` property.
|
||||
*
|
||||
|
||||
@@ -8,3 +8,8 @@
|
||||
|
||||
export const SVG_NAMESPACE = 'svg';
|
||||
export const MATH_ML_NAMESPACE = 'math';
|
||||
|
||||
export const NAMESPACE_URIS: Record<string, string> = {
|
||||
'http://www.w3.org/2000/svg': SVG_NAMESPACE,
|
||||
'http://www.w3.org/1998/Math/MathML': MATH_ML_NAMESPACE,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import {InternalInjectFlags} from '../di/interface/injector';
|
||||
import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../sanitization/dom_security_schema';
|
||||
import {
|
||||
assertDefined,
|
||||
assertEqual,
|
||||
@@ -31,7 +32,6 @@ import {
|
||||
TView,
|
||||
TViewType,
|
||||
} from './interfaces/view';
|
||||
import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from './namespaces';
|
||||
import {getTNode, walkUpViews} from './util/view_utils';
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
/**
|
||||
* Splits an element name into its namespace and local name.
|
||||
*
|
||||
* @param elementName The element name to split, in the format ":namespace:name".
|
||||
* @param fatal If true, throws an error if the element name is not in the correct format.
|
||||
* @returns A tuple containing the namespace and local name.
|
||||
*/
|
||||
export function splitNsName(elementName: string, fatal: boolean = true): [string | null, string] {
|
||||
if (elementName[0] != ':') {
|
||||
return [null, elementName];
|
||||
}
|
||||
|
||||
const colonIndex = elementName.indexOf(':', 1);
|
||||
|
||||
if (colonIndex === -1) {
|
||||
if (fatal) {
|
||||
throw new Error(`Unsupported format "${elementName}" expecting ":namespace:name"`);
|
||||
} else {
|
||||
return [null, elementName];
|
||||
}
|
||||
}
|
||||
|
||||
return [elementName.slice(1, colonIndex), elementName.slice(colonIndex + 1)];
|
||||
}
|
||||
@@ -323,7 +323,7 @@ export function addToEndOfViewTree<T extends LView | LContainer>(
|
||||
): T {
|
||||
// TODO(benlesh/misko): This implementation is incorrect, because it always adds the LContainer
|
||||
// to the end of the queue, which means if the developer retrieves the LContainers from RNodes out
|
||||
// of order, the change detection will run out of order, as the act of retrieving the the
|
||||
// of order, the change detection will run out of order, as the act of retrieving the
|
||||
// LContainer from the RNode is what adds it to the queue.
|
||||
if (lView[CHILD_HEAD]) {
|
||||
lView[CHILD_TAIL]![NEXT] = lViewOrLContainer;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user