Skip to content

Commit 7b59010

Browse files
committed
fix(compiler-cli): disallow template access to private members of base classes
Previously, TS2341 diagnostics in type check blocks were suppressed whenever the accessed property was preceded by `this.` or `(this).`. While this allowed templates and host bindings to access private members declared on their own component or directive class, it also inadvertently suppressed TS2341 when accessing private members inherited from a base class or narrowed subtype, and failed to handle parenthesized, non-null asserted, or optional-chained `this` receivers. Inspect the AST node and symbol declarations via `ts.TypeChecker` when handling TS2341 diagnostics so that private member access is only permitted on `this` when all private declarations of the accessed property belong to the host component or directive class itself. Fixes #71032
1 parent 03a4832 commit 7b59010

4 files changed

Lines changed: 320 additions & 14 deletions

File tree

‎packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -669,14 +669,15 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
669669
const fileRecord = this.state.get(sfPath)!;
670670

671671
const typeCheckProgram = this.programDriver.getProgram();
672+
const typeChecker = typeCheckProgram.getTypeChecker();
672673

673674
const diagnostics: (ts.Diagnostic | null)[] = [];
674675
if (fileRecord.hasInlines) {
675676
const inlineSf = getSourceFileOrError(typeCheckProgram, sfPath);
676677
diagnostics.push(
677678
...typeCheckProgram
678679
.getSemanticDiagnostics(inlineSf)
679-
.map((diag) => convertDiagnostic(diag, fileRecord.sourceManager)),
680+
.map((diag) => convertDiagnostic(diag, fileRecord.sourceManager, typeChecker)),
680681
);
681682
}
682683

@@ -687,7 +688,9 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
687688
const filteredDiagnostics = this.filterShimDiagnostics(shimSf, semanticDiagnostics);
688689

689690
diagnostics.push(
690-
...filteredDiagnostics.map((diag) => convertDiagnostic(diag, fileRecord.sourceManager)),
691+
...filteredDiagnostics.map((diag) =>
692+
convertDiagnostic(diag, fileRecord.sourceManager, typeChecker),
693+
),
691694
);
692695
diagnostics.push(...shimRecord.genesisDiagnostics);
693696

@@ -757,14 +760,15 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
757760
const shimRecord = fileRecord.shimData.get(shimPath)!;
758761

759762
const typeCheckProgram = this.programDriver.getProgram();
763+
const typeChecker = typeCheckProgram.getTypeChecker();
760764

761765
const diagnostics: (TemplateDiagnostic | null)[] = [];
762766
if (shimRecord.hasInlines) {
763767
const inlineSf = getSourceFileOrError(typeCheckProgram, sfPath);
764768
diagnostics.push(
765769
...typeCheckProgram
766770
.getSemanticDiagnostics(inlineSf)
767-
.map((diag) => convertDiagnostic(diag, fileRecord.sourceManager)),
771+
.map((diag) => convertDiagnostic(diag, fileRecord.sourceManager, typeChecker)),
768772
);
769773
}
770774

@@ -773,7 +777,9 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
773777
const filteredDiagnostics = this.filterShimDiagnostics(shimSf, semanticDiagnostics);
774778

775779
diagnostics.push(
776-
...filteredDiagnostics.map((diag) => convertDiagnostic(diag, fileRecord.sourceManager)),
780+
...filteredDiagnostics.map((diag) =>
781+
convertDiagnostic(diag, fileRecord.sourceManager, typeChecker),
782+
),
777783
);
778784
diagnostics.push(...shimRecord.genesisDiagnostics);
779785

@@ -1784,8 +1790,9 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
17841790
function convertDiagnostic(
17851791
diag: ts.Diagnostic,
17861792
sourceResolver: TypeCheckSourceResolver,
1793+
typeChecker: ts.TypeChecker,
17871794
): TemplateDiagnostic | null {
1788-
if (!shouldReportDiagnostic(diag)) {
1795+
if (!shouldReportDiagnostic(diag, typeChecker)) {
17891796
return null;
17901797
}
17911798
return translateDiagnostic(diag, sourceResolver);
@@ -2004,9 +2011,10 @@ function getDeprecatedSuggestionDiagnostics(
20042011
return [];
20052012
}
20062013

2014+
const typeChecker = program.getTypeChecker();
20072015
const tsDiags = tsLs.getSuggestionDiagnostics(path).filter(isDeprecatedDiagnostics);
20082016
const commonTemplateDiags = tsDiags.map((diag) => {
2009-
return convertDiagnostic(diag, fileRecord.sourceManager);
2017+
return convertDiagnostic(diag, fileRecord.sourceManager, typeChecker);
20102018
});
20112019

20122020
const elementTagDiags = getTheElementTagDeprecatedSuggestionDiagnostics(

‎packages/compiler-cli/src/ngtsc/typecheck/src/diagnostics.ts‎

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,75 @@
77
*/
88
import ts from 'typescript';
99

10+
import {getTokenAtPosition} from '../../util/src/typescript';
1011
import {TemplateDiagnostic} from '../api';
1112
import {makeTemplateDiagnostic} from '../diagnostics';
1213

1314
import {getSourceMapping, TypeCheckSourceResolver} from './tcb_util';
1415

1516
/**
16-
* This function will the check the source text of the TCB instead of doing a more expensive AST traversal (via getTokenAtPosition)
17+
* Determines whether a TS2341 ("Property 'x' is private and only accessible within class 'Y'")
18+
* diagnostic occurred on a property access on the component/directive's `this` context for a
19+
* private member declared on that component/directive class itself (as opposed to an inherited
20+
* private member from a base class or a private member on a narrowed subtype).
1721
*/
18-
function isAccessOnThis(text: string, start: number): boolean {
19-
return (
20-
text.substring(start - 5, start) === 'this.' || text.substring(start - 7, start) === '(this).'
21-
);
22+
function isPrivatePropertyOnHostThis(
23+
sf: ts.SourceFile,
24+
start: number,
25+
typeChecker: ts.TypeChecker,
26+
): boolean {
27+
const node = getTokenAtPosition(sf, start);
28+
if (!ts.isPropertyAccessExpression(node.parent) || node.parent.name !== node) {
29+
return false;
30+
}
31+
32+
let receiver: ts.Expression = node.parent.expression;
33+
while (ts.isParenthesizedExpression(receiver) || ts.isNonNullExpression(receiver)) {
34+
receiver = receiver.expression;
35+
}
36+
if (receiver.kind !== ts.SyntaxKind.ThisKeyword) {
37+
return false;
38+
}
39+
40+
const thisSymbol = typeChecker.getSymbolAtLocation(receiver);
41+
if (thisSymbol === undefined) {
42+
return false;
43+
}
44+
const hostDeclarations: readonly ts.Node[] | undefined = typeChecker
45+
.getTypeOfSymbol(thisSymbol)
46+
.getSymbol()?.declarations;
47+
if (hostDeclarations === undefined || hostDeclarations.length === 0) {
48+
return false;
49+
}
50+
51+
const propSymbol = typeChecker.getSymbolAtLocation(node);
52+
if (propSymbol?.declarations === undefined || propSymbol.declarations.length === 0) {
53+
return false;
54+
}
55+
56+
let hasPrivateDeclaration = false;
57+
for (const decl of propSymbol.declarations) {
58+
if ((ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Private) !== 0) {
59+
hasPrivateDeclaration = true;
60+
const declaringClass = ts.isParameter(decl) ? decl.parent?.parent : decl.parent;
61+
if (declaringClass === undefined || !hostDeclarations.includes(declaringClass)) {
62+
return false;
63+
}
64+
}
65+
}
66+
67+
return hasPrivateDeclaration;
2268
}
2369

2470
/**
2571
* Determines if the diagnostic should be reported. Some diagnostics are produced because of the
2672
* way TCBs are generated; those diagnostics should not be reported as type check errors of the
2773
* template.
2874
*/
29-
export function shouldReportDiagnostic(diagnostic: ts.Diagnostic): boolean {
75+
export function shouldReportDiagnostic(
76+
diagnostic: ts.Diagnostic,
77+
typeChecker: ts.TypeChecker,
78+
): boolean {
3079
const {code} = diagnostic;
3180
if (code === 6133 /* $var is declared but its value is never read. */) {
3281
return false;
@@ -38,8 +87,9 @@ export function shouldReportDiagnostic(diagnostic: ts.Diagnostic): boolean {
3887
return false;
3988
} else if (code === 2341 /* Property 'X' is private and only accessible within class */) {
4089
if (diagnostic.file !== undefined && diagnostic.start !== undefined) {
41-
// Here we're discarding private property reads error to allow them to be used in template expressions
42-
if (isAccessOnThis(diagnostic.file.text, diagnostic.start)) {
90+
// Discard private property access errors when accessing a private member declared on the
91+
// component/directive class itself via `this` in a template or host binding expression.
92+
if (isPrivatePropertyOnHostThis(diagnostic.file, diagnostic.start, typeChecker)) {
4393
return false;
4494
}
4595
}

‎packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts‎

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,221 @@ class TestComponent {
778778
`TestComponent.html(1, 14): Property 'a' is private and only accessible within class 'Model'.`,
779779
]);
780780
});
781+
782+
it('disallows access to private members of a parent class while allowing own private and parent protected members', () => {
783+
const messages = diagnose(
784+
`<button (click)="parentMethod(); childMethod(); parentSetter = 'a'">{{ parentProp }} {{ parentParam }} {{ parentGetter }} {{ childProp }} {{ childParam }} {{ parentProtected }}</button>`,
785+
`
786+
export class Parent {
787+
private parentProp = 'parent';
788+
private get parentGetter(): string { return 'getter'; }
789+
private set parentSetter(v: string) {}
790+
protected parentProtected = 'protected';
791+
constructor(private parentParam: string) {}
792+
private parentMethod(): void {}
793+
}
794+
795+
export class TestComponent extends Parent {
796+
private childProp = 'child';
797+
constructor(private childParam: string) {
798+
super(childParam);
799+
}
800+
private childMethod(): void {}
801+
}
802+
`,
803+
);
804+
805+
expect(messages).toEqual([
806+
`TestComponent.html(1, 18): Property 'parentMethod' is private and only accessible within class 'Parent'.`,
807+
`TestComponent.html(1, 49): Property 'parentSetter' is private and only accessible within class 'Parent'.`,
808+
`TestComponent.html(1, 72): Property 'parentProp' is private and only accessible within class 'Parent'.`,
809+
`TestComponent.html(1, 89): Property 'parentParam' is private and only accessible within class 'Parent'.`,
810+
`TestComponent.html(1, 107): Property 'parentGetter' is private and only accessible within class 'Parent'.`,
811+
]);
812+
});
813+
814+
it('disallows access to private members across multi-level inheritance', () => {
815+
const messages = diagnose(
816+
`{{ grandParentPrivate }} {{ parentPrivate }} {{ childPrivate }} {{ grandParentProtected }}`,
817+
`
818+
export class GrandParent {
819+
private grandParentPrivate = 1;
820+
protected grandParentProtected = 2;
821+
}
822+
823+
export class Parent extends GrandParent {
824+
private parentPrivate = 3;
825+
}
826+
827+
export class TestComponent extends Parent {
828+
private childPrivate = 4;
829+
}
830+
`,
831+
);
832+
833+
expect(messages).toEqual([
834+
`TestComponent.html(1, 4): Property 'grandParentPrivate' is private and only accessible within class 'GrandParent'.`,
835+
`TestComponent.html(1, 29): Property 'parentPrivate' is private and only accessible within class 'Parent'.`,
836+
]);
837+
});
838+
839+
it('handles private members on generic parent and generic child classes', () => {
840+
const messages = diagnose(
841+
`{{ parentPrivate }} {{ childPrivate }} {{ parentProtected }}`,
842+
`
843+
export class Parent<T> {
844+
private parentPrivate!: T;
845+
protected parentProtected!: T;
846+
}
847+
848+
export class TestComponent<T> extends Parent<T> {
849+
private childPrivate!: T;
850+
}
851+
`,
852+
);
853+
854+
expect(messages).toEqual([
855+
`TestComponent.html(1, 4): Property 'parentPrivate' is private and only accessible within class 'Parent<T>'.`,
856+
]);
857+
});
858+
859+
it('disallows access to private members from a mixin base class', () => {
860+
const messages = diagnose(
861+
`{{ basePrivate }} {{ mixinPrivate }} {{ ownPrivate }}`,
862+
`
863+
type Constructor<T = {}> = new (...args: any[]) => T;
864+
865+
export class Base {
866+
private basePrivate = 'base';
867+
}
868+
869+
function WithMixin<TBase extends Constructor>(Ctor: TBase) {
870+
return class MixinClass extends Ctor {
871+
private mixinPrivate = 'mixin';
872+
};
873+
}
874+
875+
export class TestComponent extends WithMixin(Base) {
876+
private ownPrivate = 'own';
877+
}
878+
`,
879+
);
880+
881+
expect(messages).toEqual([
882+
`TestComponent.html(1, 4): Property 'basePrivate' is private and only accessible within class 'Base'.`,
883+
`TestComponent.html(1, 22): Property 'mixinPrivate' is private and only accessible within class 'MixinClass'.`,
884+
]);
885+
});
886+
887+
it('handles getter and setter with divergent visibility on parent and child classes', () => {
888+
const messages = diagnose(
889+
`<button (click)="parentProp = 'a'; ownProp = 'b'">{{ parentProp }} {{ ownProp }}</button>`,
890+
`
891+
export class Parent {
892+
get parentProp(): string { return ''; }
893+
private set parentProp(v: string) {}
894+
}
895+
896+
export class TestComponent extends Parent {
897+
get ownProp(): string { return ''; }
898+
private set ownProp(v: string) {}
899+
}
900+
`,
901+
);
902+
903+
expect(messages).toEqual([
904+
`TestComponent.html(1, 18): Property 'parentProp' is private and only accessible within class 'Parent'.`,
905+
]);
906+
});
907+
908+
it('handles optional chaining, non-null assertion, and parentheses on explicit this for private members', () => {
909+
const messages = diagnose(
910+
`{{ this.own }} {{ this?.own }} {{ this!.own }} {{ (this).own }} {{ this.parent }} {{ this?.parent }} {{ this!.parent }} {{ (this).parent }}`,
911+
`
912+
export class Parent {
913+
private parent = 'parent';
914+
}
915+
916+
export class TestComponent extends Parent {
917+
private own = 'own';
918+
}
919+
`,
920+
);
921+
922+
expect(messages).toEqual([
923+
`TestComponent.html(1, 73): Property 'parent' is private and only accessible within class 'Parent'.`,
924+
`TestComponent.html(1, 92): Property 'parent' is private and only accessible within class 'Parent'.`,
925+
`TestComponent.html(1, 111): Property 'parent' is private and only accessible within class 'Parent'.`,
926+
`TestComponent.html(1, 131): Property 'parent' is private and only accessible within class 'Parent'.`,
927+
]);
928+
});
929+
930+
it('handles type narrowing of this with private members', () => {
931+
const messages = diagnose(
932+
`@if (isSub()) { {{ subPrivate }} {{ ownPrivate }} } @if (hasExtra()) { {{ extra }} {{ ownPrivate }} } @if (ownNullable !== null) { {{ ownNullable.value }} }`,
933+
`
934+
export class TestComponent {
935+
private ownPrivate = 'own';
936+
private ownNullable: {value: string} | null = null;
937+
938+
isSub(): this is SubComponent {
939+
return true;
940+
}
941+
942+
hasExtra(): this is { extra: string } {
943+
return true;
944+
}
945+
}
946+
947+
export class SubComponent extends TestComponent {
948+
private subPrivate = 'sub';
949+
}
950+
`,
951+
);
952+
953+
expect(messages).toEqual([
954+
`TestComponent.html(1, 20): Property 'subPrivate' is private and only accessible within class 'SubComponent'.`,
955+
]);
956+
});
957+
958+
it('allows access to own private members when component class merges with an interface', () => {
959+
const messages = diagnose(
960+
`{{ ownPrivate }} {{ parentPrivate }}`,
961+
`
962+
export class Parent {
963+
private parentPrivate = 'parent';
964+
}
965+
966+
export interface TestComponent {
967+
extraProp: string;
968+
}
969+
970+
export class TestComponent extends Parent {
971+
private ownPrivate = 'own';
972+
}
973+
`,
974+
);
975+
976+
expect(messages).toEqual([
977+
`TestComponent.html(1, 21): Property 'parentPrivate' is private and only accessible within class 'Parent'.`,
978+
]);
979+
});
980+
981+
it('disallows access to private members on another instance of the same component class', () => {
982+
const messages = diagnose(
983+
`{{ other.secret }}`,
984+
`
985+
export class TestComponent {
986+
private secret = 'shh';
987+
other!: TestComponent;
988+
}
989+
`,
990+
);
991+
992+
expect(messages).toEqual([
993+
`TestComponent.html(1, 10): Property 'secret' is private and only accessible within class 'TestComponent'.`,
994+
]);
995+
});
781996
});
782997

783998
describe('method call spans', () => {

0 commit comments

Comments
 (0)