尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
C# Web应用输入验证实战:从信任边界到防御性编码全指南
写这类主题我最初只是觉得它适合写成一篇“安全清单式”的文章但真正动手梳理后发现C# Web应用在输入处理上踩的坑远比“少写几行校验”严重得多。很多项目在开发环境和内网测试时一切正常一旦公开上线就被各种畸形请求打得措手不及。今天这篇就是围绕“输入验证”这件事把我这些年见过的、自己踩过的坑以及能直接落地的做法一次讲透。1. 为什么 C# Web 应用仍然会在输入上翻车1.1 框架不是万能的信任边界才是关键很多刚起步的团队会有一种错觉既然用了 ASP.NET Core框架自带模型验证、Razor 自带 HTML 编码、EF Core 自带参数化查询那输入安全是不是就“自动搞定”了事实远没有这么乐观。框架能够保护你的是它已经封装好的那几条通道。比如 [ApiController] 会自动帮你返回 400 错误EF Core 的 LINQ 查询会参数化你的条件Razor 的Model.Name会默认做 HTML 编码。但只要你开始写自定义逻辑保障责任就立刻回到了开发者身上。我见过一个典型的例子某个团队使用了 EF Core但为了拼一个动态排序字段把用户传入的列名直接拼进OrderBy也见过为了批量查询把用户拼好的条件直接塞进FromSqlRaw。这些场景中框架根本不知道你在做什么只能眼睁睁看着你把信任边界撕开。真正的安全边界应该建立在“所有进入应用的数据都不可信”这个假设上。框架是工具不是保险箱。1.2 “裸奔”的三种典型表现我做过不少代码评审几乎每次都能看到下面这三类问题它们基本可以概括为“裸奔”的真实含义。第一种表现是前端校验当成了安全校验。虽然客户端 JavaScript 用正则把用户名、邮箱、手机号都拦截了一遍但攻击者根本不打开浏览器页面直接拿 Postman、curl 或者脚本往接口发数据前端那层拦截形同虚设。服务端如果没有一套独立的、完整的校验就等于大门敞开。第二种表现是校验逻辑散落在各个控制器里。这个接口里判断一下字符串长度那个接口里再判断一下枚举范围没有统一的处理入口。时间一长同一个字段在不同接口里接受的规则都不一样开发时没人注意测试时也很难覆盖到所有组合最终被利用的往往是某个“漏网”的入口。第三种表现更加隐蔽只检查“是不是空”不检查“是不是坏”。一个字符串只要不是 null 就能通过一个整数只要落在 int 范围内就能进入业务层。至于这个字符串是不是含有控制字符、整数是否超出业务允许范围、集合元素数量是否大到能拖垮内存统统不管。这样的接口在攻防测试里基本是“一碰就碎”。2. 输入验证不只是检查非空而是定义数据形态2.1 先划分信源再决定信任等级很多人对输入的理解太窄以为只有用户在表单里填的内容才是输入。实际上对于 C# Web 应用来说你能接触到的一切外部数据都是输入URL 中的查询字符串和路由参数HTTP 请求体中的 JSON 或表单数据请求头包括 User-Agent、Referer、Cookie上传的文件内容、文件名、Content-Type第三方回调接口推送的数据缓存、消息队列中来自其他系统的消息这些数据到达应用时已经经过了网络传输和中间设备它们的内容可能被篡改类型可能不符合预期甚至可能夹带恶意构造的载荷。即便某些请求头看起来是“固定值”也不能盲目信任因为客户端理论上可以发送任何字符串。我的原则很简单凡是能由外部影响的数据一律按“不可信”处理。只有在接口内部明确验证过、转换过、限制过的数据才能进入业务逻辑和存储层。2.2 数据形态验证类型、长度、范围、枚举都跑不掉以 C# 的常见类型来说输入验证至少要覆盖下面这些维度数据类型必须验证的内容容易漏掉的点字符串长度上限、格式正则、是否包含控制字符空字符串和纯空格字符串不同整数业务范围、最大值、最小值0 可能是默认值也可能是非法值小数精度、范围、是否允许负数货币字段要限制小数点位数日期时间有效范围、时区、是否允许过去时间DateTime.MinValue 也算“有值”枚举是否在定义范围内强转 int 后可能得到未定义的值集合元素数量上限、每个元素是否合法空列表和 null 列表语义不同文件大小、扩展名、真实内容类型客户端可以伪造 Content-Type一个最常见的反面例子是这样的接口接收一个Age属性类型定义成int代码只写了[Required]。但int在 C# 中永远不可能为 null客户如果什么都不传模型绑定出来的结果是0。业务上用户的年龄是 0 显然不对但很多校验规则却把它放过去了。解决方式很简单把年龄改成int?类型再用[Range(1, 120)]去定义合法范围。字符串验证的重点是长度。我曾经接手过一个老模块用户名字段允许输入 5000 个字符存储层也照样能存进去。后来发现日志系统、报表系统、搜索系统全部因为这个字段慢得离谱。限制字符串长度的意义一部分是为了安全另一部分其实是为了性能和系统稳定性。2.3 “看起来正常”的输入变体才是最需要注意的这一类问题光靠简单的类型解析和“非空判断”是发现不了的。最常见的是 Unicode 变体攻击。比如某个系统做了用户名唯一性校验规则是数据库层的唯一索引。于是攻击者注册了一个看起来完全一样的用户名但其中一个字符用全角编码另一个用了零宽空格数据库认为它们是两个完全不同的字符串用户却在界面上看到了完全相同的名字。这样的问题在英文环境里很少暴露在中文和表情符号相关的业务里非常常见。再比如正则校验。很多人写正则时喜欢用^...$在 .NET 的[RegularExpression]特性中默认行为是要求整个匹配但如果你在自定义校验逻辑里直接使用Regex.IsMatch就非常容易忽略“只匹配了一部分”的情况。一个本该只允许字母数字的用户名被传入abcscript时^[a-zA-Z0-9]$会直接拒绝但如果你写的是[a-zA-Z0-9]不带锚点它就会直接通过。正确的做法是在验证时使用明确的开始和结束锚点同时传入匹配超时时间避免构造复杂的正则表达式造成灾难性回溯。在 .NET 中可以给Regex构造器传入TimeSpan或者使用[RegularExpression]时注意查看框架版本对超时的支持情况。还有一个容易踩坑的变体是 URL 编码和百分号编码。客户端发送%3Cscript%3E时有些中间层会自动解码一次有些框架会保留或解码两次。如果某一层做黑名单拦截只拦截了明文script而没拦截编码形式攻击者就能轻松绕过。所以我一贯的建议是不要做“拦截坏字符”的黑名单而是做“只允许好字符”的允许列表。3. 防御性编码处理不可信输入的实操细节3.1 模型验证与 DataAnnotations先搭好服务端的第一道防线在 ASP.NET Core 中使用 DataAnnotations 是最直接的模型验证方式。下面是一个比较标准的用户注册请求 DTOpublic class UserRegistrationRequest { [Required] [StringLength(64, MinimumLength 3)] [RegularExpression(^[A-Za-z0-9_]$)] public string UserName { get; set; } [Required] [EmailAddress] [StringLength(256)] public string Email { get; set; } [Required] [Range(18, 120)] public int? Age { get; set; } }这个 DTO 表达了三层意思用户名必须存在、长度为 3 到 64、只能由字母数字和下划线组成邮箱必须能通过[EmailAddress]的格式检查年龄必须落在 18 到 120 之间。但这里必须留个心眼。[EmailAddress]只做格式验证它不验证邮箱是否真实存在也不验证是否属于允许的域名。有些系统的业务规则要求“只能使用企业邮箱”那你就得在服务端再加一层业务校验而不是只靠[EmailAddress]。更麻烦的是自定义验证场景。比如某个下单接口要求“发货日期不能早于今天”DataAnnotations 内置里没有这个规则。此时可以自定义一个验证特性public class NotInPastAttribute : ValidationAttribute { protected override ValidationResult? IsValid(object? value, ValidationContext context) { if (value is DateTime dt dt DateTime.UtcNow.Date) { return new ValidationResult(发货日期不能早于今天); } return ValidationResult.Success; } }使用的时候直接挂在 DTO 属性上即可。这样做的好处是验证逻辑跟着数据模型走而不是散落在控制器里只要构建了新的接口规则也能被复用。3.2 实体与 DTO 分离防止过度发布输入验证的另一个重要问题是明确“客户端到底能影响哪些字段”。很多 C# 团队为了图省事直接把 Entity 模型当作接口的请求/响应模型结果就是灾难。假设数据库有一个User实体里面有Id、UserName、PasswordHash、Role四个属性。如果你把整个实休直接暴露给 POST 接口模型绑定器会根据 JSON 中的字段名自动做赋值。攻击者在 JSON 里带上Role: Admin如果这个属性恰好可写权限就直接被提起来了。这种攻击叫“过度发布”也叫 Mass Assignment。规避它的最好办法就是接口层只使用专用的 DTO并在 DTO 中只定义允许客户端传入的字段。public record CreateUserRequest( string UserName, string Email, string Password);这种写法的好处非常直观客户端传什么服务端就只接受什么。有人可能会问用 DTO 会不会导致代码量增加确实会但对于安全来说这点成本非常值得。数据模型层关注的是数据库表结构接口 DTO 层关注的是“客户端能和系统交互的边界”两者不应该混在一起。3.3 参数化查询与 ORM记住“拼 SQL”永远是禁忌EF Core 在大部分情况下能帮你把糊得结结实实但总有程序员会想走“后门”。最常见的绕法是动态拼排序字符串或者为了复用 SQL 模板而使用FromSqlRaw。先看一段极其危险的代码var sql SELECT * FROM Users WHERE Email input ;如果input的值是admin OR 11生成的 SQL 就会变成SELECT * FROM Users WHERE Email admin OR 11这条语句会返回整张用户表的数据。这就是经典的 SQL 注入。EF Core 的 LINQ 查询通常是安全的因为框架会把 Lambda 表达式中的参数转换成SqlParameter。但如果你必须使用原生 SQL请务必这么做var sql SELECT * FROM Users WHERE Email email; var emailParam new SqlParameter(email, SqlDbType.NVarChar, 256) { Value input.Email }; var users await context.Users .FromSqlRaw(sql, emailParam) .ToListAsync();请注意这里显式声明了SqlDbType.NVarChar和长度上限。这样既解决了注入问题也让数据库查询计划更稳定不会因为参数类型不明确而产生不必要的转换。3.4 输出编码输入校验之后输出同样需要防御很多人觉得输入安全做好就完事了。但 XSS跨站脚本攻击真正爆发的位置往往在输出阶段。ASP.NET Core 的 Razor 默认会对Model.Name这类表达式做 HTML 编码所以大部分基础 XSS 可以被拦截。但总有人为了“灵活”地渲染富文本到处写Html.Raw()。一旦你决定使用Html.Raw等于亲口告诉 Razor“这里的内容是安全的你不需要编码”。如果内容来自用户输入或者由用户输入拼接而成就会直接变成 XSS 漏洞。我的原则是能用默认编码就绝不开Raw。如果确实需要渲染富文本比如常见的“帖子正文”请引入一个成熟的富文本清洗方案或者约定只允许使用 Markdown不要妄图自己写一个 HTML 清洗器。还有一类容易被忽略的输出点是 JavaScript 变量注入。比如在页面中输出配置数据var userName Model.UserName;如果UserName中包含单引号用户的输入就可以逃逸出字符串变成可执行的 JavaScript。要安全地往 JavaScript 上下文塞数据推荐使用 JSON 序列化并把它当成数据写入var userData JsonSerializer.Serialize(userObject);序列化器会自动对字符串中的引号、换行、特殊字符做转义这比手工拼接要可靠得多。3.5 文件上传与路径规范化文件名和路径都是攻击面文件上传可能是输入验证中“最能裸奔”的一类接口。最常见的风险是任意文件写入和路径穿越。假设上传接口把客户端传过来的文件名和上传目录拼接var fullPath Path.Combine(uploadPath, fileName);当fileName为../../wwwroot/evil.aspx时Path.Combine仍然会生成一个“看起来合法”的路径。如果不做校验文件就可能写到上传目录之外。一个非常稳健的做法是不信任任何客户端文件名服务端用随机 GUID 重新生成文件名并将原始文件名只存到数据库的元数据字段里。var safeName Guid.NewGuid().ToString(N) .bin; var fullPath Path.Combine(uploadRoot, safeName);如果必须保留用户提供的文件名至少要做两步校验先对最终路径执行Path.GetFullPath再确认规范化后的路径是否以uploadRoot开头。只有前缀匹配成功才允许写文件。另外文件大小检查尽量不要放在ReadAllBytes之后再判断因为文件一旦被读入内存就已经造成了资源消耗。正确做法是先读取Content-Length请求头或在流式读取过程中边检查边丢弃超限数据。4. 一套可以直接抄的输入安全检查清单4.1 在入口处统一拦截Filter 和中间件是最好的位置如果把输入验证散落在每个方法里终究会有漏网之鱼。更可靠的做法是在框架层面做统一拦截。对于传统 MVC 控制器你可以写一个全局过滤器用来处理模型验证public class ValidateModelAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext context) { if (!context.ModelState.IsValid) { context.Result new BadRequestObjectResult(context.ModelState); } } }然后在启动类中注册为全局过滤器确保所有控制器都应用它。对于 Minimal API可以使用AddEndpointFilter或手动在委托开头检查ValidationResult。统一拦截的好处是返回的失败格式一致。前端、客户端 SDK、调用方都能用一个固定的结构去解析错误而不是面对每套接口各自不同的返回体。4.2 建立一个输入验证矩阵在设计接口时我建议先画一个验证矩阵把所有入口和字段规则列出来。下面是我常用的一张示例表接口字段数据类型校验规则业务附加规则UserNamestring长度 3-64正则^[A-Za-z0-9_]$唯一性校验Emailstring格式邮箱长度 ≤256域名允许列表Ageint?范围 18-120无ProductIdGuid必须非空 Guid必须存在于商品表TagsListstring数量 ≤10每项 ≤20 字符不允许重复AvatarIFormFile大小 ≤2MB扩展名白名单重命名保存这个矩阵不一定每次都要画成文档但至少要能在脑子里快速过一遍。当新接口新增字段时先看它属于哪一类再套用对应规则能省下很多返工的时间。4.3 校验、标准化、存储、返回的完整链路输入验证不是一道单独的闸门它应该贯彻到数据的完整生命周期。第一步是接收。限制请求体大小拒绝非法 Content-Type防止超大请求体打满内存。第二步是验证。检查类型、长度、格式、范围、枚举值。第三步是标准化。比如对字符串去掉首尾空格将 Unicode 统一到某种规范化形式对换行符做统一处理。第四步是存储。所有数值和字符串全部通过参数化方式进入数据库不拼 SQL。第五步是返回。统一错误格式不把异常堆栈返回给客户端。这一套链路中最关键的是标准化和存储之间的顺序。比如用户名唯一性检查要建立在标准化之后否则你检查的是admin但用户输入的是admin带尾随空格依然可能绕过唯一索引。建议在数据库字段中直接使用固定长度的列并在写入前做Trim()和长度校验。4.4 日志与监控验证失败也是重要安全信号很多系统记录日志时只记录“成功的业务操作”完全忽略了验证失败请求。这是非常可惜的因为大量扫描攻击和试探请求在打入系统的那一刻就会触发验证失败而这些信号恰恰是发现攻击的前兆。建议至少记录以下信息请求路径、客户端 IP、请求 ID、失败原因、涉及的字段名、原始输入值如果敏感则做脱敏处理。需要注意的是不要记录明文密码、令牌、身份证号日志本身也可能成为泄露源。当验证失败频率突然升高比如某个 IP 在短时间内触发了成百上千次失败监控系统就应该报警。这比业务接口报 500 错误更容易发现问题也是把“安全”和“可观测性”结合起来的一个好实践。5. 常见陷阱与排查实录5.1 返回 400 却泄露了内部细节我在实际项目中见过一个接口校验失败时直接把底层异常消息拼到了返回结果里类似于“Value cannot be null. Parameter name: _context”。这类信息对用户毫无价值却能让攻击者推断出后端用了什么库、什么框架版本甚至某些中间件的细节。排查的方法是统一包装错误响应。使用全局异常过滤器把内部信息完整记录到日志而在响应体里只返回错误码和字段信息。public record ValidationErrorResponse( string Code, string Message, Dictionarystring, string[] Fields);这样不管底层发生了什么客户端拿到的永远是一个结构清晰、不泄露内部细节的响应。5.2 校验规则只做了一半更危险有时候开发环境没有发现问题是因为前端和后端运行在同一台机器上用的也是同一套逻辑。但一旦部署上线前端和后端分离后就会出现“前端限制了 100 个字符后端却允许 5000 个字符”的情况。攻击者只要绕过前端直接构造超过 100 个字符的请求后端就会照单全收。这种不一致往往隐藏在实际业务里不会在开发阶段暴露。我的建议是前端校验是用户体验后端校验是安全边界。永远不要因为前端做了校验就减少后端的校验。在做接口测试时也要有意识地去构造那些“前端不可能通过”的请求后端应该同样返回清晰的验证错误。5.3 Unicode 和正则造成的“看起来没问题”绕过前面提到的 Unicode 变体攻击在实际排查中往往是最折磨人的。现象是什么两个字符串在界面上显示完全一样但系统的唯一性校验就是没拦住。查数据库时发现一个字符串中间被插入了零宽空格U200B另一个在 Normalization Form 上不同。解决方案是把“展示值”和“存储值”分开考虑。在唯一性检查、搜索、排序中统一使用同一种 Unicode 规范化形式比如 NFKC在展示时尽量保留原始输入。这样既能满足业务上的“看起来唯一”又能避免让你防不胜防的绕过。关于正则还有一个特别容易被忽视的点如果你拿用户输入去拼正则表达式也就是用户能够控制部分模式本身那已经属于非常严重的漏洞。正则应该由开发者完全控制输入的只能是“被匹配的文本”绝不能成为模式的一部分。5.4 性能与安全的平衡别让检查本身变成新瓶颈安全校验如果设计得不好也会拖垮应用。最典型的是在正则上做文章。一个复杂的、带多层嵌套量词的正则遇到超长输入时可能触发灾难性回溯CPU 直接飙升。.NET 的正则引擎允许你设置匹配超时时间建议在任何自定义校验里都设置比如 500ms超过就判定失败。另一个性能陷阱是文件上传。有的系统先在内存里ReadAllBytes再检查大小、再校验格式。结果就是攻击者只要发一个 2GB 的文件在还没触发大小校验之前服务器内存就已经被占满了。正确的顺序应该是一边读流一边统计字节数超限立刻断开。还有集合参数。当接口接收一个数组时除了校验每个元素还要对数组长度设置上限。比如一个标签列表最多只能传 10 个每个标签不超过 20 个字符。否则攻击者可以提交几十万个元素让 JSON 反序列化器把内存撑爆。5.5 常见问题速查表现象可能原因排查与修复方法年龄字段明明没传却校验通过了int默认值为 0改为int?并加[Range]明明写了 SQL 参数还是被注入字符串拼接发生在SqlParameter之前检查所有 SQL 构造路径拒绝任何拼接页面显示正常但浏览器报 XSS使用了Html.Raw或手动拼接 HTML停止输出用户原始 HTML使用编码两个用户名看起来一样却能重复注册Unicode 编码不同统一使用规范化和唯一索引判断文件上传偶尔写入异常路径客户端文件名未规范化重命名文件或校验Path.GetFullPath前缀接口报错信息里带着堆栈全局异常未统一包装加全局过滤器日志记录内部响应返回错误码一个正则校验把 CPU 跑满复杂正则触发灾难性回溯简化正则限制长度设置超时6. 在项目里落地的一点个人经验说句实在话我见过太多“上线前一天补校验”的项目最后效果都不好。原因很简单输入验证不是某个接口的补丁它是整个应用的数据契约。哪里的边界弱攻击者就会从哪里进来。我个人的落地步骤大概是这样的先给所有接口做一次“信源盘点”把客户端传参、请求头、上传文件、第三方回调全部列出来。然后建立核心输入字段的校验矩阵不管接口有多少凡是同一类字段就用同一套验证规则。接着在框架层注册统一验证过滤器让每个入口的行为一致。最后在日志和监控里补上验证失败的记录让安全事件可追溯。在这个过程中我最看重的是“默认拒绝”心态。遇到一个拿不准的字段默认选择不放行而不是默认放行以后再补。少一个字段的验证短期看是功能更快了长期看就是埋下一个雷。宁可让调用方补传真实信息也不要让任何可疑数据直接进入数据库。最后再分享一个实用的小技巧在所有接口测试用例里专门保留一组“恶意输入用例”比如超长字符串、带控制字符的字符串、Unicode 全角变体、SQL 注入片段每次发版之前跑一遍。很多人会觉得麻烦但只要你经历过一次线上数据被删、或者用户越权的生产事故就会明白这组用例才是整个系统最值得保留的测试资产。
RELATED

相关推荐

PDF 怎么转 Word 用什么软件?多种实用方法整理,办公党收藏

PDF 怎么转 Word 用什么软件?多种实用方法整理,办公党收藏

日常办公、写论文处理文件,经常会碰到需要把 PDF 转换成可编辑 Word 文档的情况。很多人踩坑,转换完之后全是图片、排版错乱、出现大量乱码。今天整理不同场景下可用的转换方式,电脑本地、网页、手机端分开讲,大家可以根据自己的文…

📅 2026/10/16 11:13:47
面向对象核心:类与对象的区别、原理与实战

面向对象核心:类与对象的区别、原理与实战

我经常被初学者问到一个问题:类和对象到底有什么区别?带新同学入门的时候,几乎每个人都会在这个地方卡一下。这个问题看似基础,其实特别值得花时间想透,因为后面所有东西——继承、多态、封装、设计模式——全都站在这…

📅 2026/10/16 11:08:43
WGS84转ENU坐标转换从原理到实战,避免那些坑

WGS84转ENU坐标转换从原理到实战,避免那些坑

在户外测量、无人机航线规划、自动驾驶路测或者机器人室内外定位的时候,你一定遇到过这种场景:手里拿到的是GPS设备输出的经纬度——也就是WGS84坐标系下的数据,但你要用这套数据去控制车辆或者飞机,去跟激光雷达点云做对齐&#…

📅 2026/10/16 11:08:43
MORE NEWS

更多资讯

📰

27B 硬刚千亿参数:Hemmingway-1 用十分之一参数打的写作仗

27B 硬刚千亿参数:Hemmingway-1 用十分之一参数打的写作仗 【免费下载链接】Hemmingway-1 项目地址: https://ai.gitcode.com/hf_mirrors/Altworld/Hemmingway-1 "跟千亿参数的旗舰模型拼写作"——放在两年前,这会被当成一句营销话术。…

📰

SpringBoot+Vue+MySQL宠物猫认养系统全栈实战与部署指南

最近整理了一套 SpringBoot Vue MySQL 的 web 宠物猫认养系统源码,标题里写的是“可直接运行”,我实际部署验证了一下,确实没吹牛。这个项目不是那种只有登录注册的架子工程,而是把用户端、认养申请、管理员审核、猫咪上下架、留…

📰

PR曲线与ROC曲线的本质区别及业务选型指南

1. 这不是“画图游戏”:PR曲线和ROC曲线到底在解决什么问题?刚接触模型评估时,我见过太多人把PR曲线和ROC曲线当成PPT里两张必须出现的“装饰性图表”——调用几行sklearn代码,plt.plot一下,配上AUC数值,就…

📰

韦达定理如何解码矩阵的迹与行列式

1. 这不是数学课,是工程师手里的“系统解码器”你有没有遇到过这样的情况:调试一个控制算法,明明参数调得挺顺,但系统一上电就振荡;或者训练一个简单的线性回归模型,loss曲线忽高忽低,收敛特别慢…

📰

校园网生成树协议实战:环路排查与RSTP/MSTP调优指南

做网络运维这些年,校园网故障里最让我绷紧神经的,永远是环路问题。某个教学楼的三层交换机一抽风,广播帧就像雪崩一样在冗余链路里疯狂转发,核心设备CPU飙高,全校断网。生成树协议,就是在这种混乱局面里负责…

📰

2026年9月望京GEO公司哪家懂婚介相亲?该比哪几项

摘要:这一篇把婚介相亲当作落地场景,对望京的GEO公司重新做一遍适配梳理,本批对照的机构均属 AI搜索优化(GEO)方向。做法分三步:还原婚介机构在AI端提出的真实问题,拆成七项可核对要点&#xff…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬