Drop HTMX (unpkg, no integrity); guard against third-party assets #61
10 changed files with 143 additions and 24 deletions
|
|
@ -30,7 +30,7 @@
|
|||
## Locked Technical Defaults
|
||||
|
||||
- stack: ASP.NET Core 8 LTS
|
||||
- UI: Razor Pages + HTMX
|
||||
- UI: Razor Pages
|
||||
- database: PostgreSQL
|
||||
- hosting target: `bifrost.gamecult.org` on Yggdrasil
|
||||
- deployment pattern: nginx + systemd + localhost-only Postgres, matching `E:\Projects\gamecult-ops`
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ It is closer to deployable now, but it is still not a finished internet-facing m
|
|||
## Chosen Stack
|
||||
|
||||
- ASP.NET Core 8 LTS
|
||||
- Razor Pages + HTMX
|
||||
- Razor Pages
|
||||
- PostgreSQL
|
||||
- Bifrost-native identity registration
|
||||
- GitHub OAuth for linked sign-in and repository integration
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
- Create Bifrost as a separate GameCult app repo and deploy it on Yggdrasil.
|
||||
- Build a member alpha, not a public launch.
|
||||
- Use ASP.NET Core 8 with Razor Pages + HTMX and PostgreSQL.
|
||||
- Use ASP.NET Core 8 with Razor Pages and PostgreSQL.
|
||||
- Support invite-only GitHub-linked members, GitHub-backed and internal work items, app-native governance, Reddit organizing threads, and internal ledgers.
|
||||
- Preserve the Labor Platform loop: user/member/patron demand raises work priority and reward pressure, contributors pick up valuable work, maintainers accept completion artifacts, and Bifrost credits the contributor plus any bounty/reward allocation.
|
||||
- Do not include Ethereum, wallets, DAO mechanics, or payout execution in v1.
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
## System Shape
|
||||
|
||||
- One ASP.NET web app process for v1.
|
||||
- Server-rendered UI with HTMX for progressive interactions.
|
||||
- Server-rendered UI; no client-side framework or third-party script.
|
||||
- PostgreSQL as the system of record.
|
||||
- Bifrost-native identity registration for login, with GitHub OAuth available as a linked external identity.
|
||||
- GitHub App plus webhooks for issue and pull request lifecycle sync.
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ Bifrost head `bf83a93` (2026-10-10; `20d5efa` plus the prior-art note, docs only
|
|||
21. Main lacks EF migration discovery metadata on seven migrations: `20260622142000_AddBridgeActionIdentityProvenance`, `20260622202000_AddBridgeActionTargetSurfaceName` and `20260705190000_AddPatronSupportProjectAttribution` have neither `[DbContext]` nor `[Migration]`; `20260608210000_AddHeimdallUserIdentity`, `20260609143000_AddPatronSupportDerivation`, `20260609160000_AddExternalPatronProviderIntake` and `20260622165000_AddBifrostNativeIdentity` have `[Migration]` only; none of the seven has a `.Designer.cs`. EF discovers a migration only with both attributes, so `MigrateAsync` from main would skip them on a fresh database. `daedcc4` adds exactly those attributes (17 lines). The other five migrations have Designer files and are discovered. Migrations run at startup when `Bootstrap:ApplyMigrationsOnStartup` is true (`Features/Shared/ApplicationBootstrapper.cs:17-21`); the live env sets that key.
|
||||
22. Tests use EF InMemory (`tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs:52-58`), which enforces no unique index; a uniqueness rule must be proven in service code and in the model/migration metadata, not by an InMemory insert failing. `TestAuthHandler` offers two actors only: the bootstrap admin `test-admin`, or anonymous via header `X-Test-Anonymous` (`Support/TestAuthHandler.cs:17-37`). `CurrentBifrostActor` is a public record with a public constructor (`Features/Shared/CurrentBifrostActor.cs:5`), so service tests can build account and member actors directly. `Program` is `public partial` (`Program.cs:582`).
|
||||
23. Startup configuration rules live in one owner, `Features/Shared/StartupConfigurationValidator.cs`, which skips Development and Testing and throws on a missing production key (`:15-74`).
|
||||
24. The shared layout loads HTMX from `https://unpkg.com/htmx.org@1.9.12` (`Pages/Shared/_Layout.cshtml:65`); no page posts with `hx-post` today. Admin and vote handlers use plain Razor page handlers with `RedirectToPage()` (`Pages/App/Motions/Index.cshtml.cs:56-61`).
|
||||
24. The shared layout loads no third-party script or stylesheet (HTMX was dropped; `NoThirdPartyAssetTests` fails on any cross-origin `<script src>` or `<link href>`), and no page uses `hx-` attributes. Admin and vote handlers use plain Razor page handlers with `RedirectToPage()` (`Pages/App/Motions/Index.cshtml.cs:56-61`).
|
||||
25. Verification on Yggdrasil: `ygg-verify.sh`'s `dotnet` alias is `mcr.microsoft.com/dotnet/sdk:10.0`, which has no .NET 8 runtime for this `net8.0` test project; passing the image name `mcr.microsoft.com/dotnet/sdk:8.0` works (any other value is used as an image name). At `bf83a93`, `dotnet test tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj -c Release --filter "FullyQualifiedName!~BridgeCliTests&FullyQualifiedName!~ProviderAdvertisementTests"` passes 29 of 29 (scratch log `bifrost-base-test-2.log`; that run's filter excluded only `BridgeCliTests`, and the two `ProviderAdvertisementTests` failed for want of Node). The other 37 tests shell out to Node: with no Node they fail to start the process; with Debian's Node 18 they fail on newer Node APIs (`bifrost-base-test-3.log`, 30 pass, 36 fail). Starfire has Node 24 and SDK 10.0.202 only. None of the cuts touch the Node tools.
|
||||
|
||||
### The Mill page
|
||||
|
|
|
|||
|
|
@ -14,12 +14,7 @@
|
|||
governance pressure, and recent team activity in one place.
|
||||
</p>
|
||||
</div>
|
||||
<button class="button secondary"
|
||||
hx-get="@Url.Page("/App/Index", "Summary")"
|
||||
hx-target="#summary-cards"
|
||||
hx-swap="outerHTML">
|
||||
Refresh snapshot
|
||||
</button>
|
||||
<a class="button secondary" href="@Url.Page("/App/Index")">Refresh snapshot</a>
|
||||
</div>
|
||||
|
||||
<div id="summary-cards">
|
||||
|
|
|
|||
|
|
@ -18,11 +18,4 @@ public sealed class IndexModel(
|
|||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
Snapshot = await snapshotService.GetAsync(Actor.UserAccount?.Id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PartialViewResult> OnGetSummaryAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
Snapshot = await snapshotService.GetAsync(Actor.UserAccount?.Id, cancellationToken);
|
||||
return Partial("_SummaryCards", Snapshot);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@
|
|||
</article>
|
||||
<article class="status-card">
|
||||
<span>Stack</span>
|
||||
<strong>ASP.NET Core 8 + Razor Pages + HTMX</strong>
|
||||
<strong>ASP.NET Core 8 + Razor Pages</strong>
|
||||
</article>
|
||||
<article class="status-card">
|
||||
<span>System of record</span>
|
||||
|
|
|
|||
|
|
@ -28,15 +28,15 @@
|
|||
</p>
|
||||
|
||||
<div class="mill-actions">
|
||||
<form method="post" asp-page-handler="Up" asp-antiforgery="false" hx-boost="false">
|
||||
<form method="post" asp-page-handler="Up" asp-antiforgery="false">
|
||||
<button class="button" type="submit">Up</button>
|
||||
</form>
|
||||
<form method="post" asp-page-handler="Down" asp-antiforgery="false" hx-boost="false">
|
||||
<form method="post" asp-page-handler="Down" asp-antiforgery="false">
|
||||
<button class="button" type="submit">Down</button>
|
||||
</form>
|
||||
@if (stance is not null)
|
||||
{
|
||||
<form method="post" asp-page-handler="Retract" asp-antiforgery="false" hx-boost="false">
|
||||
<form method="post" asp-page-handler="Retract" asp-antiforgery="false">
|
||||
<button class="button ghost" type="submit">Retract</button>
|
||||
</form>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/Bifrost.Web.styles.css" asp-append-version="true" />
|
||||
</head>
|
||||
<body hx-boost="true">
|
||||
<body>
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
<a class="brand" asp-page="/Index">
|
||||
|
|
@ -62,7 +62,6 @@
|
|||
</main>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
<script src="~/js/site.js" asp-append-version="true"></script>
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
</body>
|
||||
|
|
|
|||
132
tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs
Normal file
132
tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
// The site serves every script and stylesheet itself: a page that pulls code or styling from another origin hands
|
||||
// that origin the visitor's session. Fails on any cross-origin <script src> / <link href> and on htmx remnants.
|
||||
public sealed partial class NoThirdPartyAssetTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
// Known standing exception, not a ruling: the layout loads Google Fonts (fonts.googleapis.com, fonts.gstatic.com).
|
||||
// Remove the lookahead when the fonts are self-hosted so the guard becomes absolute.
|
||||
[GeneratedRegex(@"<(?:script|link)\b[^>]*\b(?:src|href)\s*=\s*[""']?\s*(?:https?:)?//(?!fonts\.(?:googleapis|gstatic)\.com)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex CrossOriginAsset();
|
||||
|
||||
// An htmx remnant is an attribute (` hx-get="..."`); a bare "hx-" can occur inside a random token.
|
||||
[GeneratedRegex(@"\shx-[a-z-]+\s*=", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex HtmxAttribute();
|
||||
|
||||
// Source-level: any element that fetches a resource (script/link/img/iframe/...) from an absolute or protocol-relative
|
||||
// URL outside the Google Fonts allowance, in markup that may sit in C# strings (escaped or doubled quotes), plus CSS @import / url().
|
||||
[GeneratedRegex(@"<(?:script|link|img|iframe|frame|source|embed|object|video|audio|input)\b[^>]*?\b(?:src|href|data|srcset)\s*=\s*(?:\\|"")?[""']?\s*(?:https?:)?//(?!fonts\.(?:googleapis|gstatic)\.com)|@import\s+(?:url\()?\s*(?:\\|"")?[""']?\s*(?:https?:)?//(?!fonts\.(?:googleapis|gstatic)\.com)|\burl\(\s*[""']?\s*(?:https?:)?//(?!fonts\.(?:googleapis|gstatic)\.com)", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
|
||||
private static partial Regex CrossOriginSource();
|
||||
|
||||
private static string RepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Bifrost.slnx")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
return dir?.FullName ?? throw new InvalidOperationException("Bifrost.slnx not found above the test binary.");
|
||||
}
|
||||
|
||||
// Every file that can emit HTML or CSS: all .cshtml, all .cs under src (the C# page builders), and the site's own css/js.
|
||||
private static IEnumerable<string> MarkupSources(string root)
|
||||
{
|
||||
var src = Path.Combine(root, "src", "Bifrost.Web");
|
||||
var sep = Path.DirectorySeparatorChar;
|
||||
return Directory.EnumerateFiles(src, "*", SearchOption.AllDirectories)
|
||||
.Where(f => !f.Contains($"{sep}bin{sep}") && !f.Contains($"{sep}obj{sep}") && !f.Contains($"{sep}wwwroot{sep}lib{sep}"))
|
||||
.Where(f => f.EndsWith(".cshtml") || f.EndsWith(".cs") || f.EndsWith(".css") || f.EndsWith(".js") || f.EndsWith(".html"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_source_that_emits_markup_loads_a_cross_origin_asset()
|
||||
{
|
||||
var files = MarkupSources(RepoRoot()).ToList();
|
||||
Assert.True(files.Count(f => f.EndsWith(".cshtml")) >= 10, "the scan found too few views; the root is wrong");
|
||||
Assert.Contains(files, f => f.EndsWith("HeimdallCompletePage.cs"));
|
||||
Assert.Contains(files, f => f.EndsWith(Path.Combine("Membership", "Status.cshtml")));
|
||||
|
||||
var offenders = files.Where(f => CrossOriginSource().IsMatch(File.ReadAllText(f))).ToList();
|
||||
Assert.True(offenders.Count == 0, "cross-origin asset in: " + string.Join(", ", offenders));
|
||||
Assert.DoesNotContain(files, f => HtmxAttribute().IsMatch(File.ReadAllText(f)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("<script src=\"https://evil.example/x.js\"></script>", true)]
|
||||
[InlineData("<script src='//evil.example/x.js'></script>", true)]
|
||||
[InlineData("html.Append(\"<script src=\\\"https://evil.example/x.js\\\">\")", true)]
|
||||
[InlineData("<script src=\"\"https://evil.example/x.js\"\">", true)]
|
||||
[InlineData("<img src=\"http://evil.example/p.png\">", true)]
|
||||
[InlineData("<iframe src=\"https://evil.example\"></iframe>", true)]
|
||||
[InlineData("@import url('https://evil.example/a.css');", true)]
|
||||
[InlineData("background: url(https://evil.example/a.png)", true)]
|
||||
[InlineData("<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=X\">", false)]
|
||||
[InlineData("<script src=\"/js/site.js\"></script>", false)]
|
||||
[InlineData("<a href=\"https://github.com/GameCult\">repo</a>", false)]
|
||||
public void The_source_pattern_catches_cross_origin_assets_and_allows_same_origin_and_fonts(string markup, bool flagged) =>
|
||||
Assert.Equal(flagged, CrossOriginSource().IsMatch(markup));
|
||||
|
||||
[Theory]
|
||||
[InlineData("<div hx-get=\"/x\">", true)]
|
||||
[InlineData("<div class=a hx-post = \"/x\">", true)]
|
||||
[InlineData("token=ab-hx-9f3", false)]
|
||||
public void Htmx_is_matched_only_as_an_attribute(string markup, bool flagged) =>
|
||||
Assert.Equal(flagged, HtmxAttribute().IsMatch(markup));
|
||||
|
||||
[Fact]
|
||||
public async Task Real_pages_load_no_third_party_asset()
|
||||
{
|
||||
var collection = "c" + Guid.NewGuid().ToString("N");
|
||||
var identity = $"assets-{Guid.NewGuid():N}";
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
db.ConceptSubjects.Add(new ConceptSubject { Collection = collection, Key = "alpha", Title = "alpha", RegisteredAtUtc = DateTimeOffset.UtcNow });
|
||||
db.UserAccounts.Add(new UserAccount { BifrostIdentity = identity, NormalizedBifrostIdentity = identity, DisplayName = identity });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, BaseAddress = new Uri("https://localhost") });
|
||||
var pages = new (string Path, string? Identity, bool Anonymous)[]
|
||||
{
|
||||
("/", null, true),
|
||||
($"/mill/{collection}/alpha", null, true),
|
||||
($"/mill/{collection}/alpha", null, false),
|
||||
("/App", null, false),
|
||||
("/auth/sign-in", null, true),
|
||||
("/auth/bifrost/register", null, true),
|
||||
("/auth/bifrost/consent", identity, false),
|
||||
};
|
||||
|
||||
foreach (var (path, who, anonymous) in pages)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, path);
|
||||
if (anonymous)
|
||||
{
|
||||
request.Headers.Add("X-Test-Anonymous", "true");
|
||||
}
|
||||
else if (who is not null)
|
||||
{
|
||||
request.Headers.Add("X-Test-Bifrost-Identity", who);
|
||||
}
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK, $"{path} returned {response.StatusCode}");
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
Assert.False(string.IsNullOrWhiteSpace(html), path);
|
||||
Assert.DoesNotContain("unpkg.com", html, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("htmx", html, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.False(HtmxAttribute().IsMatch(html), $"{path} carries an hx- attribute");
|
||||
Assert.False(CrossOriginAsset().IsMatch(html), $"{path} loads a cross-origin asset");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue