Files
2026-10-14 06:59:51 +02:00

134 lines
5.6 KiB
C#
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
/// <summary>
/// The website takes PLAIN TEXT only (2026-09-27 request): no "fancy font" letters (𝓯𝓸𝓷𝓽,
/// 𝕗𝕠𝕟𝕥, font - Unicode look-alikes that dodge the word filter and impersonate names), no
/// zalgo marks, no invisible characters, no emoji. One gate over every JSON write to /api/site/*,
/// so a text field added later is covered too - names, bio, comments, photo captions, reports,
/// support tickets, friend nicknames, email.
///
/// What a phone keyboard inserts on its own is converted, not refused: curly quotes, dashes,
/// the ellipsis and non-breaking spaces become their plain ASCII forms. Anything else outside
/// printable ASCII (plus new lines and tabs) refuses the request with a 400 the site shows.
/// Passwords are never inspected. File uploads (multipart) are not JSON and pass untouched.
/// </summary>
public class SitePlainTextMiddleware
{
private readonly RequestDelegate _next;
public SitePlainTextMiddleware(RequestDelegate next) => _next = next;
public const string Message = "Only plain text is allowed - no special fonts, symbols or emoji.";
private static readonly HashSet<string> SkipKeys = new(StringComparer.OrdinalIgnoreCase)
{
"password", "confirm_password", "current_password", "new_password", "old_password",
};
private static readonly Dictionary<char, string> Convert = new()
{
['‘'] = "'", ['’'] = "'", ['‚'] = "'", ['′'] = "'",
['“'] = "\"", ['”'] = "\"", ['„'] = "\"", ['″'] = "\"",
['–'] = "-", ['—'] = "-", ['−'] = "-",
['…'] = "...",
[' '] = " ", [' '] = " ",
};
public static bool IsPlainChar(char c) => (c >= ' ' && c <= '~') || c == '\n' || c == '\r' || c == '\t';
/// <summary>The text with keyboard punctuation made plain, or null when something else is left.</summary>
public static string? Plain(string s)
{
StringBuilder? sb = null;
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
if (IsPlainChar(c)) { sb?.Append(c); continue; }
if (!Convert.TryGetValue(c, out var rep)) return null;
sb ??= new StringBuilder(s, 0, i, s.Length + 8);
sb.Append(rep);
}
return sb?.ToString() ?? s;
}
public async Task Invoke(HttpContext context)
{
var req = context.Request;
string path = req.Path.Value ?? "";
bool write = HttpMethods.IsPost(req.Method) || HttpMethods.IsPut(req.Method) || HttpMethods.IsPatch(req.Method);
// Any body that isn't a file upload or a form: some handlers read the raw body whatever the
// Content-Type says, so a JSON body labelled text/plain must not slip past.
string ct = req.ContentType ?? "";
bool json = !ct.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase)
&& !ct.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase);
if (!write || !json || !path.StartsWith("/api/site/", StringComparison.OrdinalIgnoreCase))
{
await _next(context);
return;
}
string body;
using (var reader = new StreamReader(req.Body, Encoding.UTF8))
body = await reader.ReadToEndAsync();
JsonNode? root = null;
try { if (!string.IsNullOrWhiteSpace(body)) root = JsonNode.Parse(body); } catch { }
if (root != null)
{
bool changed = false;
if (!Clean(root, null, ref changed))
{
Console.WriteLine($"[plaintext] refused {req.Method} {path}: non-plain characters");
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsJsonAsync(new { ok = false, error = Message });
return;
}
if (changed) body = root.ToJsonString();
}
var bytes = Encoding.UTF8.GetBytes(body);
req.Body = new MemoryStream(bytes);
req.ContentLength = bytes.Length;
await _next(context);
}
// Walks every string value (not property names); false = a value has a non-plain character.
private static bool Clean(JsonNode node, string? key, ref bool changed)
{
switch (node)
{
case JsonObject o:
foreach (var kv in o.ToList())
{
if (kv.Value == null || SkipKeys.Contains(kv.Key)) continue;
if (kv.Value is JsonValue v && v.TryGetValue<string>(out var s))
{
var p = Plain(s);
if (p == null) return false;
if (!ReferenceEquals(p, s)) { o[kv.Key] = p; changed = true; }
}
else if (!Clean(kv.Value, kv.Key, ref changed)) return false;
}
return true;
case JsonArray a:
for (int i = 0; i < a.Count; i++)
{
var item = a[i];
if (item == null) continue;
if (item is JsonValue v && v.TryGetValue<string>(out var s))
{
var p = Plain(s);
if (p == null) return false;
if (!ReferenceEquals(p, s)) { a[i] = p; changed = true; }
}
else if (!Clean(item, key, ref changed)) return false;
}
return true;
default:
return true;
}
}
}