Skip to content

Auto-configuration defaults fail open: AllowAllAclResolver and in-memory stores are the silent default #30

Description

@tcheeric

Summary

Two related defaults in NapAutoConfiguration fail open. Setting nap.enabled=true and nothing else produces a server that authenticates correctly and authorizes nobody in particular: AllowAllAclResolver admits every principal who can prove key control, and InMemorySessionStore/InMemoryChallengeStore silently make sessions per-instance and process-lifetime.

Severity: Medium — neither is a bug in isolation, but the combination is the default, and both failures are silent.

AllowAllAclResolver as the default AclResolver

@Bean
@ConditionalOnMissingBean
public AclResolver aclResolver() {
    return new AllowAllAclResolver();
}

AllowAllAclResolver.resolve() returns AclDecision.allowed(List.of(), List.of()) for every principal. The effect is that anyone who holds any Nostr key gets a session. Empty roles and permissions mean @RequiresPermission handlers still 403 — but combined with #29 (an unannotated handler under a protected prefix is served), "authenticated" becomes "reachable" for any endpoint whose annotation was forgotten.

The deeper problem is that this is indistinguishable from working. An operator wires NAP, logs in with their own key, sees a session, and ships. Nothing reports that the ACL layer is a no-op. NapServerOptions.Builder.build() does the same thing (if (aclResolver == null) aclResolver = new AllowAllAclResolver()), so the fallback is in both paths.

Compare how the TypeScript side handles the analogous decision. createAudienceHostAllowlist() and createMintAllowlist() both throw at wiring time rather than accept an empty list, with the reasoning written down:

There is no empty-list escape hatch: an allowlist that allows everything is the state this exists to make unrepresentable. Throws here, at wiring time, rather than as a uniform 401 per request.

That is the right principle and it should apply to the ACL resolver, which is a broader authorization decision than either allowlist.

RegistryAclResolver has the same shape one level down: create(registry, aclStore) defaults autoProvision to true, so an unknown pubkey is silently written an ACL record carrying registry.defaultRole(). That is a reasonable behaviour for an open-signup app and a surprising one for anything else, and the two-argument overload is the one that reads as the default.

In-memory stores as the default

challengeStore() and sessionStore() default to the in-memory implementations. Consequences an operator will not be told about:

InMemorySessionStore's javadoc says "for testing and single-instance deployments," which is honest. Being the auto-configuration default is what makes it reachable without that sentence ever being read.

Fix

1. Refuse to start with an implicit allow-all. Replace the default bean with a startup failure that names the decision:

@Bean
@ConditionalOnMissingBean
public AclResolver aclResolver(NapProperties properties) {
    if (!properties.allowAllPrincipals()) {
        throw new IllegalStateException(
            "NAP requires an AclResolver bean. Supply RegistryAclResolver (or your own), or set "
          + "nap.allow-all-principals=true to accept every principal who proves key control — "
          + "which is what the previous default did silently.");
    }
    log.warn("nap_acl_allow_all_enabled: every principal proving key control is authorized");
    return new AllowAllAclResolver();
}

The escape hatch stays available and becomes a written decision rather than an omission. The same check belongs in NapServerOptions.Builder.build().

2. Warn loudly on the in-memory stores. These are genuinely useful for local development, so a hard failure would be the wrong trade. A startup log.warn naming both consequences is enough:

log.warn("nap_in_memory_session_store: sessions are lost on restart and are not shared "
       + "between instances — revocation reaches only this node. Supply a JdbcSessionStore "
       + "bean for any multi-instance or production deployment.");

3. Make autoProvision explicit. Deprecate the two-argument RegistryAclResolver.create(registry, aclStore) in favour of the three-argument form, so the choice is made at the call site.

Regression test

@Test
void contextFailsToStartWithoutAnAclResolver() {
    assertThatThrownBy(() -> runner.withPropertyValues("nap.enabled=true").run(ctx -> ctx.getBean(NapServer.class)))
        .hasMessageContaining("nap.allow-all-principals");
}

@Test
void allowAllStartsWhenExplicitlyRequested() {
    runner.withPropertyValues("nap.enabled=true", "nap.allow-all-principals=true")
          .run(ctx -> assertThat(ctx).hasSingleBean(NapServer.class));
}

Documentation

The README's Spring Boot setup block should show an AclResolver bean as part of the minimal configuration, and say plainly that the auto-configured stores are for development. Right now the quickstart produces a server that looks finished and authorizes everyone.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    securitySecurity-sensitive change

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions